din/app.py
ching 5177028bae Initial commit: din - Do It Now 记录器
- 核心功能:一键记录 din 时刻
- 统计面板:日/周/月/总计 + 同比
- 成就系统:24个成就,支持配置文件扩展
- PWA 支持:离线可用,可安装到主屏幕
- 东八区时区支持
- SQLite 数据存储
2026-02-21 05:57:22 +00:00

341 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from flask import Flask, request, jsonify, send_from_directory
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import sqlite3
import os
import json
app = Flask(__name__, static_folder='static')
DB_PATH = 'din.db'
# 东八区时区
TZ = ZoneInfo('Asia/Shanghai')
def now_tz():
"""获取东八区当前时间"""
return datetime.now(TZ)
def init_db():
"""初始化数据库"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS din_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
content TEXT,
year INTEGER,
month INTEGER,
week INTEGER,
day INTEGER
)
''')
conn.commit()
conn.close()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def get_week_number(date):
"""获取ISO周数"""
return date.isocalendar()[1]
# ========== API 路由 ==========
@app.route('/api/din', methods=['POST'])
def create_din():
"""创建新的 din 记录"""
data = request.get_json() or {}
content = data.get('content', '').strip()
now = now_tz()
conn = get_db()
c = conn.cursor()
c.execute('''
INSERT INTO din_records (created_at, content, year, month, week, day)
VALUES (?, ?, ?, ?, ?, ?)
''', (now, content, now.year, now.month, get_week_number(now), now.day))
conn.commit()
record_id = c.lastrowid
conn.close()
return jsonify({
'id': record_id,
'created_at': now.isoformat(),
'content': content
})
@app.route('/api/din', methods=['GET'])
def get_dins():
"""获取 din 记录列表"""
limit = request.args.get('limit', 100, type=int)
conn = get_db()
c = conn.cursor()
c.execute('''
SELECT * FROM din_records
ORDER BY created_at DESC
LIMIT ?
''', (limit,))
records = [dict(row) for row in c.fetchall()]
conn.close()
return jsonify(records)
@app.route('/api/din/<int:record_id>', methods=['PUT'])
def update_din(record_id):
"""更新 din 记录内容"""
data = request.get_json() or {}
content = data.get('content', '').strip()
conn = get_db()
c = conn.cursor()
c.execute('UPDATE din_records SET content = ? WHERE id = ?', (content, record_id))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/din/<int:record_id>', methods=['DELETE'])
def delete_din(record_id):
"""删除 din 记录"""
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM din_records WHERE id = ?', (record_id,))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/stats', methods=['GET'])
def get_stats():
"""获取统计数据"""
conn = get_db()
c = conn.cursor()
now = now_tz()
today = now.day
this_week = get_week_number(now)
this_month = now.month
this_year = now.year
# 今日统计
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND day = ?', (this_year, today))
today_count = c.fetchone()[0]
# 本周统计
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND week = ?', (this_year, this_week))
week_count = c.fetchone()[0]
# 本月统计
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND month = ?', (this_year, this_month))
month_count = c.fetchone()[0]
# 总计
c.execute('SELECT COUNT(*) FROM din_records')
total_count = c.fetchone()[0]
# 昨日同比
yesterday = now - timedelta(days=1)
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND day = ?',
(yesterday.year, yesterday.day))
yesterday_count = c.fetchone()[0]
# 上周同比
last_week = now - timedelta(weeks=1)
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND week = ?',
(last_week.year, get_week_number(last_week)))
last_week_count = c.fetchone()[0]
# 上月同比
last_month = now.replace(month=this_month-1) if this_month > 1 else now.replace(year=this_year-1, month=12)
c.execute('SELECT COUNT(*) FROM din_records WHERE year = ? AND month = ?',
(last_month.year, last_month.month))
last_month_count = c.fetchone()[0]
conn.close()
return jsonify({
'today': today_count,
'week': week_count,
'month': month_count,
'total': total_count,
'yesterday': yesterday_count,
'last_week': last_week_count,
'last_month': last_month_count,
'day_growth': calculate_growth(today_count, yesterday_count),
'week_growth': calculate_growth(week_count, last_week_count),
'month_growth': calculate_growth(month_count, last_month_count)
})
def calculate_growth(current, previous):
"""计算增长率"""
if previous == 0:
return 100 if current > 0 else 0
return round((current - previous) / previous * 100, 1)
# 加载成就配置
ACHIEVEMENTS_FILE = 'achievements.json'
def load_achievements_config():
"""加载成就配置"""
if os.path.exists(ACHIEVEMENTS_FILE):
with open(ACHIEVEMENTS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return []
def check_achievement(ach, stats):
"""检查单个成就是否解锁"""
cond = ach.get('condition', {})
cond_type = cond.get('type', '')
if cond_type == 'total':
return stats['total'] >= cond.get('min', 0)
elif cond_type == 'streak':
return stats['streak'] >= cond.get('min', 0)
elif cond_type == 'day_max':
return stats.get('day_max', 0) >= cond.get('min', 0)
elif cond_type == 'content_count':
return stats.get('content_count', 0) >= cond.get('min', 0)
elif cond_type == 'early_time':
return stats.get('has_early', False)
elif cond_type == 'late_time':
return stats.get('has_late', False)
return False
def get_achievement_stats(conn):
"""获取成就统计信息"""
c = conn.cursor()
stats = {}
# 总计
c.execute('SELECT COUNT(*) FROM din_records')
stats['total'] = c.fetchone()[0]
# 连续天数
c.execute('SELECT DISTINCT year, day FROM din_records ORDER BY created_at DESC')
days = c.fetchall()
stats['streak'] = calculate_streak(days)
# 单日最高记录
c.execute('''
SELECT COUNT(*) as cnt FROM din_records
GROUP BY year, day ORDER BY cnt DESC LIMIT 1
''')
result = c.fetchone()
stats['day_max'] = result[0] if result else 0
# 有描述的记录数
c.execute('SELECT COUNT(*) FROM din_records WHERE content IS NOT NULL AND content != ""')
stats['content_count'] = c.fetchone()[0]
# 早晚记录检查(东八区)
# SQLite datetime 存储的是 UTC需要 +8 小时转换为东八区
c.execute('SELECT created_at FROM din_records')
rows = c.fetchall()
hours = []
for row in rows:
try:
# 解析时间并转换为东八区
dt_str = row[0]
if 'T' in dt_str:
# ISO format with timezone
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
else:
# SQLite default format
dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
dt = dt.replace(tzinfo=timezone.utc)
# 转换为东八区
dt_cn = dt.astimezone(TZ)
hours.append(dt_cn.hour)
except Exception as e:
print(f"Parse time error: {e}, value: {dt_str}")
continue
stats['has_early'] = any(h < 6 for h in hours)
stats['has_late'] = any(h >= 23 for h in hours)
return stats
@app.route('/api/achievements', methods=['GET'])
def get_achievements():
"""获取成就数据"""
conn = get_db()
# 获取统计数据
stats = get_achievement_stats(conn)
conn.close()
# 加载成就配置
config = load_achievements_config()
# 检查每个成就
achievements = []
for ach in config:
achievements.append({
'id': ach['id'],
'name': ach['name'],
'desc': ach['desc'],
'icon': ach['icon'],
'unlocked': check_achievement(ach, stats)
})
return jsonify({
'achievements': achievements,
'unlocked_count': sum(1 for a in achievements if a['unlocked']),
'total_count': len(achievements),
'current_streak': stats['streak']
})
def calculate_streak(days):
"""计算连续天数"""
if not days:
return 0
# 转换为 date 对象
date_set = set()
for row in days:
try:
d = datetime(row['year'], 1, 1, tzinfo=TZ) + timedelta(days=row['day']-1)
date_set.add(d.date())
except:
continue
sorted_days = sorted(date_set, reverse=True)
if not sorted_days:
return 0
streak = 1
today = now_tz().date()
# 检查今天或昨天是否有记录
if sorted_days[0] != today and (today - sorted_days[0]).days > 1:
return 0
for i in range(1, len(sorted_days)):
if (sorted_days[i-1] - sorted_days[i]).days == 1:
streak += 1
else:
break
return streak
# ========== 静态文件 ==========
@app.route('/')
def index():
return send_from_directory('static', 'index.html')
@app.route('/<path:path>')
def static_files(path):
return send_from_directory('static', path)
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000, debug=True)