綦霖
好记性不如烂笔头~ 持续写bug中...

版本更新啦!
周五么 就是要把事办黄,把尿喝白!

现在情况作出修改

1、锁屏后不再计时,只对不锁屏状态进线统计时间
2、需要增加一个自动开始计时开机状态下9:00到19:00(时间可修改)
3、历史查询的日期做一个日期控件可点击选择
4、可以支持周或月统计,然后可以导出为Excel


先放上新的效果图

image

image

image


增加日期控件需要安装openpyxl

PS C:\Users\xxx.xxx> pip install openpyxl
Collecting openpyxl
  Downloading openpyxl-3.1.5-py2.py3-none-any.whl.metadata (2.5 kB)
Collecting et-xmlfile (from openpyxl)
  Downloading et_xmlfile-2.0.0-py3-none-any.whl.metadata (2.7 kB)
Downloading openpyxl-3.1.5-py2.py3-none-any.whl (250 kB)
Downloading et_xmlfile-2.0.0-py3-none-any.whl (18 kB)
Installing collected packages: et-xmlfile, openpyxl
Successfully installed et-xmlfile-2.0.0 openpyxl-3.1.5

完整代码:

# -*- coding: utf-8 -*-
# idle_timerv4.py
# 空闲计时器应用 V5 (Python 3.x)
# 导出Excel功能需要安装 openpyxl: pip install openpyxl

import sqlite3
import time
import threading
import datetime
import calendar
import ctypes
from ctypes import wintypes
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

# --- 1. 数据库管理模块 ---
class DatabaseManager:
    def __init__(self, db_path='idle_tracker.db'):
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.lock = threading.Lock()
        self.create_table()

    def create_table(self):
        with self.lock:
            cursor = self.conn.cursor()
            cursor.execute('''
            CREATE TABLE IF NOT EXISTS idle_sessions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                start_time TEXT NOT NULL,
                end_time TEXT NOT NULL,
                duration_seconds REAL NOT NULL,
                date TEXT NOT NULL
            )
            ''')
            self.conn.commit()

    def insert_record(self, start_time, end_time, duration):
        with self.lock:
            cursor = self.conn.cursor()
            start_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
            end_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
            date_str = start_time.strftime('%Y-%m-%d')
            cursor.execute('''
            INSERT INTO idle_sessions (start_time, end_time, duration_seconds, date)
            VALUES (?, ?, ?, ?)
            ''', (start_str, end_str, duration, date_str))
            self.conn.commit()

    def get_sessions_by_date(self, date_str):
        with self.lock:
            cursor = self.conn.cursor()
            cursor.execute('''
            SELECT start_time, end_time, duration_seconds 
            FROM idle_sessions 
            WHERE date = ?
            ''', (date_str,))
            return cursor.fetchall()

# --- 2. Windows API 辅助类 ---
class WindowsAPIHelper:
    @staticmethod
    def get_last_input_info():
        try:
            class LASTINPUTINFO(ctypes.Structure):
                _fields_ = [("cbSize", wintypes.UINT), ("dwTime", wintypes.DWORD)]
            lii = LASTINPUTINFO()
            lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
            if ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
                return lii.dwTime
            return 0
        except Exception:
            return 0

    @staticmethod
    def is_workstation_locked():
        try:
            user32 = ctypes.windll.user32
            h_desktop = user32.OpenInputDesktop(0, False, 0x0100)
            if h_desktop == 0:
                return True
            user32.CloseDesktop(h_desktop)
            return False
        except Exception:
            return False

# --- 3. 智能日期与维度选择控件 ---
class DimensionDatePicker(ttk.Frame):
    def __init__(self, master, callback=None, **kwargs):
        super().__init__(master, **kwargs)
        self.callback = callback
        self.dim_var = tk.StringVar(value="day")
        self.date_var = tk.StringVar()
        
        ttk.Label(self, text="维度:").pack(side='left', padx=5)
        for text, val in [("", "day"), ("", "week"), ("", "month")]:
            rb = ttk.Radiobutton(self, text=text, variable=self.dim_var, value=val, command=self.on_dim_change)
            rb.pack(side='left', padx=2)
            
        self.entry = ttk.Entry(self, textvariable=self.date_var, width=16, state='readonly')
        self.entry.pack(side='left', padx=5)
        self.btn = ttk.Button(self, text="📅", width=3, command=self.show_picker)
        self.btn.pack(side='left', padx=2)
        
        self.on_dim_change()
        
    def on_dim_change(self):
        today = datetime.datetime.now()
        dim = self.dim_var.get()
        if dim == 'day':
            self.date_var.set(today.strftime('%Y-%m-%d'))
        elif dim == 'week':
            monday = today - datetime.timedelta(days=today.weekday())
            self.date_var.set(monday.strftime('%Y-%m-%d') + ' (周)')
        elif dim == 'month':
            self.date_var.set(today.strftime('%Y-%m') + ' (月)')
            
        if self.callback:
            self.callback(self)
            
    def get_query_range(self):
        dim = self.dim_var.get()
        date_str = self.date_var.get()
        
        if dim == 'day':
            target = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
            return target, target
        elif dim == 'week':
            base_str = date_str.split(' ')[0]
            target = datetime.datetime.strptime(base_str, '%Y-%m-%d').date()
            end = target + datetime.timedelta(days=6)
            return target, end
        elif dim == 'month':
            base_str = date_str.split(' ')[0]
            target = datetime.datetime.strptime(base_str, '%Y-%m').date()
            _, last_day = calendar.monthrange(target.year, target.month)
            end = target.replace(day=last_day)
            return target, end

    def show_picker(self):
        x = self.winfo_pointerx()
        y = self.winfo_pointery()
        
        picker_win = tk.Toplevel(self)
        picker_win.title("选择日期")
        picker_win.geometry(f"280x280+{x}+{y}")
        picker_win.transient(self.winfo_toplevel())
        picker_win.grab_set()
        
        dim = self.dim_var.get()
        date_str = self.date_var.get()
        
        if dim == 'day':
            current = datetime.datetime.strptime(date_str, '%Y-%m-%d')
        elif dim == 'week':
            current = datetime.datetime.strptime(date_str.split(' ')[0], '%Y-%m-%d')
        elif dim == 'month':
            current = datetime.datetime.strptime(date_str.split(' ')[0], '%Y-%m')
            
        year = current.year
        month = current.month

        header_frame = ttk.Frame(picker_win)
        header_frame.pack(fill='x', pady=5)
        
        def prev_month():
            nonlocal year, month
            month -= 1
            if month < 1: month = 12; year -= 1
            update_calendar()
            
        def next_month():
            nonlocal year, month
            month += 1
            if month > 12: month = 1; year += 1
            update_calendar()

        ttk.Button(header_frame, text="<", command=prev_month).pack(side='left', padx=10)
        lbl_month = ttk.Label(header_frame, text=f"{year}年{month}月", font=('Arial', 12, 'bold'))
        lbl_month.pack(side='left', expand=True)
        ttk.Button(header_frame, text=">", command=next_month).pack(side='right', padx=10)

        cal_frame = ttk.Frame(picker_win)
        cal_frame.pack(fill='both', expand=True, padx=10, pady=5)

        days = ["", "", "", "", "", "", ""]
        for i, day in enumerate(days):
            ttk.Label(cal_frame, text=day, width=4).grid(row=0, column=i, padx=2, pady=2)

        def update_calendar():
            for widget in cal_frame.winfo_children():
                if isinstance(widget, ttk.Button):
                    widget.destroy()
            lbl_month.config(text=f"{year}年{month}月")
            
            cal = calendar.Calendar(firstweekday=0)
            month_days = cal.monthdayscalendar(year, month)
            
            for r, week in enumerate(month_days):
                for c, day in enumerate(week):
                    if day != 0:
                        btn = ttk.Button(cal_frame, text=str(day), width=3,
                                         command=lambda d=day: select_date(d))
                        btn.grid(row=r+1, column=c, padx=2, pady=2)
                        
        def select_date(day):
            selected_date = datetime.date(year, month, day)
            if dim == 'day':
                self.date_var.set(selected_date.strftime('%Y-%m-%d'))
            elif dim == 'week':
                monday = selected_date - datetime.timedelta(days=selected_date.weekday())
                self.date_var.set(monday.strftime('%Y-%m-%d') + ' (周)')
            elif dim == 'month':
                self.date_var.set(selected_date.strftime('%Y-%m') + ' (月)')
            
            picker_win.destroy()
            if self.callback:
                self.callback(self)

        update_calendar()

# --- 4. 辅助函数 ---
def format_duration(seconds):
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    return f"{h:02d}:{m:02d}:{s:02d}"

# --- 5. 空闲监控模块 ---
class IdleMonitor:
    def __init__(self, threshold_seconds, db_manager, work_start_str, work_end_str, log_callback=None):
        self.threshold = threshold_seconds
        self.db = db_manager
        self.log = log_callback if log_callback else print
        self.thread = None
        self._stop_event = threading.Event()
        self.is_timing = False
        self.session_start_time = None
        self.manual_mode = False
        
        try:
            self.work_start = datetime.datetime.strptime(work_start_str, "%H:%M").time()
            self.work_end = datetime.datetime.strptime(work_end_str, "%H:%M").time()
        except ValueError:
            self.work_start = datetime.time(9, 0)
            self.work_end = datetime.time(19, 0)
            self.log("[警告] 工作时间格式错误,已重置为 09:00 - 19:00")

    def _monitor_loop(self):
        last_logged_idle_seconds = -1
        while not self._stop_event.is_set():
            try:
                if self.manual_mode:
                    self._stop_event.wait(1.0)
                    continue

                last_input_ms = WindowsAPIHelper.get_last_input_info()
                current_ms = ctypes.windll.kernel32.GetTickCount()
                locked = WindowsAPIHelper.is_workstation_locked()
                current_time = datetime.datetime.now().time()

                idle_seconds = (current_ms - last_input_ms) / 1000.0
                in_work_hours = self.work_start <= current_time <= self.work_end

                if locked:
                    if self.is_timing:
                        self._end_session("检测到锁屏,结束空闲会话。")
                else:
                    if in_work_hours and idle_seconds > self.threshold:
                        if not self.is_timing:
                            self._start_session()
                    else:
                        if self.is_timing:
                            if not in_work_hours:
                                self._end_session("超出工作时间,结束空闲会话。")
                            else:
                                self._end_session("检测到用户活动,结束空闲会话。")

                if int(idle_seconds) != last_logged_idle_seconds:
                    self.log(f"空闲: {idle_seconds:.1f}s | 工作时间: {'是' if in_work_hours else '否'} | 锁屏: {'是' if locked else '否'}")
                    last_logged_idle_seconds = int(idle_seconds)

            except Exception as e:
                self.log(f"[错误] 监控循环发生异常: {e}")
            
            self._stop_event.wait(1.0)

    def _start_session(self):
        self.is_timing = True
        self.session_start_time = datetime.datetime.now()
        self.log(f"⏱️ 开始计时: {self.session_start_time.strftime('%H:%M:%S')}")

    def _end_session(self, reason=""):
        if not self.is_timing: return
        self.is_timing = False
        end_time = datetime.datetime.now()
        duration = (end_time - self.session_start_time).total_seconds()
        
        self.log(f"⏱️ 结束计时: {end_time.strftime('%H:%M:%S')} | 原因: {reason} | 持续: {format_duration(duration)}")
        
        try:
            self.db.insert_record(self.session_start_time, end_time, duration)
        except Exception as e:
            self.log(f"[错误] 写入数据库失败: {e}")
        self.session_start_time = None

    def start_monitoring(self, manual=False):
        self.manual_mode = manual
        self._stop_event.clear()
        self.log(f"监控已启动,阈值: {self.threshold}s。模式: {'手动' if manual else '自动'}")
        self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
        self.thread.start()

    def stop_monitoring(self):
        self._stop_event.set()
        if self.thread: self.thread.join(timeout=2.0)
        self.log("监控已停止。")

    def manual_start(self):
        if not self.is_timing: self._start_session()

    def manual_stop(self):
        if self.is_timing: self._end_session("手动结束计时")

# --- 6. GUI 主应用 ---
class App:
    def __init__(self, root):
        self.root = root
        self.root.title("空闲计时器 (增强版 V5)")
        self.root.geometry("750x520")
        
        self.db_manager = DatabaseManager()
        self.idle_monitor = None

        config_frame = ttk.LabelFrame(root, text="设置")
        config_frame.pack(fill="x", padx=10, pady=5)

        ttk.Label(config_frame, text="空闲阈值(秒):").grid(row=0, column=0, padx=5, pady=5)
        self.threshold_entry = ttk.Entry(config_frame, width=8)
        self.threshold_entry.grid(row=0, column=1, padx=5, pady=5)
        self.threshold_entry.insert(0, "180")

        ttk.Label(config_frame, text="工作开始:").grid(row=0, column=2, padx=5, pady=5)
        self.work_start_entry = ttk.Entry(config_frame, width=8)
        self.work_start_entry.grid(row=0, column=3, padx=5, pady=5)
        self.work_start_entry.insert(0, "09:00")

        ttk.Label(config_frame, text="工作结束:").grid(row=0, column=4, padx=5, pady=5)
        self.work_end_entry = ttk.Entry(config_frame, width=8)
        self.work_end_entry.grid(row=0, column=5, padx=5, pady=5)
        self.work_end_entry.insert(0, "19:00")

        self.mode_var = tk.StringVar(value="auto")
        ttk.Radiobutton(config_frame, text="自动", variable=self.mode_var, value="auto").grid(row=0, column=6, padx=5)
        ttk.Radiobutton(config_frame, text="手动", variable=self.mode_var, value="manual").grid(row=0, column=7, padx=5)

        self.start_stop_button = ttk.Button(config_frame, text="启动监控", command=self.toggle_monitoring)
        self.start_stop_button.grid(row=0, column=8, padx=10, pady=5)

        self.manual_frame = ttk.Frame(root)
        self.manual_frame.pack(fill="x", padx=10, pady=5)
        self.manual_start_btn = ttk.Button(self.manual_frame, text="手动开始计时", command=self.manual_start_timer, state='disabled')
        self.manual_start_btn.pack(side='left', padx=5)
        self.manual_stop_btn = ttk.Button(self.manual_frame, text="手动结束计时", command=self.manual_stop_timer, state='disabled')
        self.manual_stop_btn.pack(side='left', padx=5)

        stats_frame = ttk.LabelFrame(root, text="今日统计")
        stats_frame.pack(fill="x", padx=10, pady=5)
        ttk.Label(stats_frame, text="总空闲时间:").grid(row=0, column=0, padx=5, pady=5)
        self.total_duration_var = tk.StringVar(value="00:00:00")
        ttk.Label(stats_frame, textvariable=self.total_duration_var, font=('Arial', 10, 'bold')).grid(row=0, column=1, padx=5, pady=5)
        ttk.Button(stats_frame, text="刷新统计", command=self.refresh_daily_stats).grid(row=0, column=2, padx=10, pady=5)
        ttk.Button(stats_frame, text="查看历史与导出", command=self.show_history).grid(row=0, column=3, padx=10, pady=5)

        log_frame = ttk.LabelFrame(root, text="日志")
        log_frame.pack(fill="both", expand=True, padx=10, pady=5)
        self.log_text = tk.Text(log_frame, state='disabled', height=8)
        self.log_text.pack(side='left', fill="both", expand=True, padx=5, pady=5)
        scrollbar = ttk.Scrollbar(log_frame, orient='vertical', command=self.log_text.yview)
        scrollbar.pack(side='right', fill='y')
        self.log_text.configure(yscrollcommand=scrollbar.set)
        
        self.log("应用已启动。请设置参数并点击'启动监控'。")
        self.update_stats_periodically()

    def log(self, message):
        def update_log():
            self.log_text.config(state='normal')
            self.log_text.insert('end', f"{message}\n")
            self.log_text.see('end')
            self.log_text.config(state='disabled')
        self.root.after(0, update_log)

    def toggle_monitoring(self):
        if self.idle_monitor is None or not self.idle_monitor.thread.is_alive():
            threshold = int(self.threshold_entry.get())
            work_start = self.work_start_entry.get()
            work_end = self.work_end_entry.get()
            is_manual = self.mode_var.get() == "manual"
            
            self.idle_monitor = IdleMonitor(threshold, self.db_manager, work_start, work_end, self.log)
            self.idle_monitor.start_monitoring(manual=is_manual)
            self.start_stop_button.config(text="停止监控")
            
            if is_manual:
                self.manual_start_btn.config(state='normal')
                self.manual_stop_btn.config(state='normal')
            else:
                self.manual_start_btn.config(state='disabled')
                self.manual_stop_btn.config(state='disabled')
        else:
            self.idle_monitor.stop_monitoring()
            self.idle_monitor = None
            self.start_stop_button.config(text="启动监控")
            self.manual_start_btn.config(state='disabled')
            self.manual_stop_btn.config(state='disabled')
        self.refresh_daily_stats()

    def manual_start_timer(self):
        if self.idle_monitor and self.idle_monitor.manual_mode:
            self.idle_monitor.manual_start()

    def manual_stop_timer(self):
        if self.idle_monitor and self.idle_monitor.manual_mode:
            self.idle_monitor.manual_stop()
            self.refresh_daily_stats()

    def refresh_daily_stats(self):
        today_str = datetime.datetime.now().strftime('%Y-%m-%d')
        records = self.db_manager.get_sessions_by_date(today_str)
        total_duration = sum(record[2] for record in records)
        self.total_duration_var.set(format_duration(total_duration))

    def update_stats_periodically(self):
        self.refresh_daily_stats()
        self.root.after(10000, self.update_stats_periodically)

    def show_history(self):
        history_win = tk.Toplevel(self.root)
        history_win.title("统计与历史记录")
        history_win.geometry("900x550")

        top_frame = ttk.Frame(history_win)
        top_frame.pack(fill='x', padx=10, pady=5)

        # ===== 1. 先创建表格和底部统计控件 =====
        table_frame = ttk.Frame(history_win)
        table_frame.pack(fill='both', expand=True, padx=10, pady=10)

        tree_scroll = ttk.Scrollbar(table_frame)
        tree_scroll.pack(side='right', fill='y')

        tree = ttk.Treeview(table_frame, columns=('start', 'end', 'duration', 'date'), 
                           show='headings', yscrollcommand=tree_scroll.set)
        tree.heading('start', text='开始时间')
        tree.heading('end', text='结束时间')
        tree.heading('duration', text='持续时间')
        tree.heading('date', text='日期')

        tree.column('start', width=180)
        tree.column('end', width=180)
        tree.column('duration', width=100)
        tree.column('date', width=100)

        tree.pack(side='left', fill='both', expand=True)
        tree_scroll.config(command=tree.yview)

        bottom_frame = ttk.Frame(history_win)
        bottom_frame.pack(fill='x', padx=10, pady=5)
        total_var = tk.StringVar(value="总计: 00:00:00")
        ttk.Label(bottom_frame, textvariable=total_var, font=('Arial', 10, 'bold')).pack(side='left', padx=10)

        # ===== 2. 定义依赖 tree 和 total_var 的函数 =====
        def load_records(picker):
            start_date, end_date = picker.get_query_range()
            with self.db_manager.lock:
                cursor = self.db_manager.conn.cursor()
                cursor.execute('''
                SELECT start_time, end_time, duration_seconds, date 
                FROM idle_sessions 
                WHERE date BETWEEN ? AND ?
                ORDER BY start_time DESC
                ''', (start_date.isoformat(), end_date.isoformat()))
                records = cursor.fetchall()

            for i in tree.get_children():
                tree.delete(i)

            total_duration = 0
            for record in records:
                start, end, duration, date = record
                total_duration += duration
                duration_str = format_duration(duration)
                start = start.replace('T', ' ') if 'T' in start else start
                end = end.replace('T', ' ') if 'T' in end else end
                tree.insert('', 'end', values=(start, end, duration_str, date))

            total_var.set(f"总计: {format_duration(total_duration)}")

        def export_excel(picker):
            start_date, end_date = picker.get_query_range()
            with self.db_manager.lock:
                cursor = self.db_manager.conn.cursor()
                cursor.execute('''
                SELECT start_time, end_time, duration_seconds, date 
                FROM idle_sessions 
                WHERE date BETWEEN ? AND ?
                ORDER BY start_time DESC
                ''', (start_date.isoformat(), end_date.isoformat()))
                records = cursor.fetchall()

            if not records:
                messagebox.showinfo("提示", "当前维度下没有可导出的记录。")
                return

            try:
                from openpyxl import Workbook
                from openpyxl.styles import Font
                wb = Workbook()
                ws = wb.active
                ws.title = "空闲统计"

                headers = ['开始时间', '结束时间', '持续时间', '日期']
                ws.append(headers)
                for cell in ws[1]:
                    cell.font = Font(bold=True)

                for row in records:
                    start, end, duration, date = row
                    start_fmt = start.replace('T', ' ') if 'T' in start else start
                    end_fmt = end.replace('T', ' ') if 'T' in end else end
                    dur_fmt = format_duration(duration)
                    ws.append([start_fmt, end_fmt, dur_fmt, date])

                filename = f"空闲统计_{start_date}_至_{end_date}.xlsx"
                wb.save(filename)
                messagebox.showinfo("成功", f"导出成功!\n文件保存在当前目录: {filename}")
                self.log(f"导出成功: {filename}")
            except ImportError:
                messagebox.showerror("错误", "导出失败:请先安装 openpyxl 库。\n请在命令行执行: pip install openpyxl")

        # ===== 3. 最后创建日期选择器(此时函数和UI组件都已就绪) =====
        date_picker = DimensionDatePicker(top_frame, callback=lambda p: load_records(p))
        date_picker.pack(side='left', padx=5, pady=5)

        ttk.Button(top_frame, text="导出Excel", 
                   command=lambda: export_excel(date_picker)).pack(side='left', padx=10, pady=5)
        ttk.Button(top_frame, text="关闭", command=history_win.destroy).pack(side='right', padx=10, pady=5)

        # 初始加载数据
        load_records(date_picker)

# --- 7. 主程序入口 ---
if __name__ == "__main__":
    root = tk.Tk()
    app = App(root)
    root.mainloop()

 

posted on 2026-08-07 15:41  綦霖  阅读(2)  评论(0)    收藏  举报