commit 5177028bae63cbf9647e36538c3da292847db55f Author: ching Date: Sat Feb 21 05:57:22 2026 +0000 Initial commit: din - Do It Now 记录器 - 核心功能:一键记录 din 时刻 - 统计面板:日/周/月/总计 + 同比 - 成就系统:24个成就,支持配置文件扩展 - PWA 支持:离线可用,可安装到主屏幕 - 东八区时区支持 - SQLite 数据存储 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a9e8b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ + +# 虚拟环境 +venv/ +.venv/ +env/ + +# 数据库和日志 +*.db +*.db-journal +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# 环境变量 +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..f878a86 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# din - Do It Now 记录器 + +一个极简的「想到就做」记录工具。 + +## 功能 +- 🔴 一键记录 din 时刻 +- 📊 日/周/月统计 +- 🏆 成就系统 +- 📝 可选填写事项内容 + +## 运行 + +```bash +# 安装依赖 +pip install flask + +# 启动 +python app.py + +# 访问 http://localhost:5000 +``` + +## 技术栈 +- 后端: Python + Flask + SQLite +- 前端: 原生 HTML/CSS/JS diff --git a/achievements.json b/achievements.json new file mode 100644 index 0000000..6583326 --- /dev/null +++ b/achievements.json @@ -0,0 +1,170 @@ +[ + { + "id": "first_din", + "name": "第一次", + "desc": "完成第一个 din", + "icon": "🌱", + "condition": { "type": "total", "min": 1 } + }, + { + "id": "din_5", + "name": "初出茅庐", + "desc": "累计 5 个 din", + "icon": "🌿", + "condition": { "type": "total", "min": 5 } + }, + { + "id": "din_10", + "name": "渐入佳境", + "desc": "累计 10 个 din", + "icon": "🌲", + "condition": { "type": "total", "min": 10 } + }, + { + "id": "din_25", + "name": "小有所成", + "desc": "累计 25 个 din", + "icon": "🌳", + "condition": { "type": "total", "min": 25 } + }, + { + "id": "din_50", + "name": "行动力爆棚", + "desc": "累计 50 个 din", + "icon": "🚀", + "condition": { "type": "total", "min": 50 } + }, + { + "id": "din_100", + "name": "百折不挠", + "desc": "累计 100 个 din", + "icon": "💯", + "condition": { "type": "total", "min": 100 } + }, + { + "id": "din_250", + "name": "行动大师", + "desc": "累计 250 个 din", + "icon": "👑", + "condition": { "type": "total", "min": 250 } + }, + { + "id": "din_500", + "name": "传奇", + "desc": "累计 500 个 din", + "icon": "🦸", + "condition": { "type": "total", "min": 500 } + }, + { + "id": "din_1000", + "name": "神级", + "desc": "累计 1000 个 din", + "icon": "🔱", + "condition": { "type": "total", "min": 1000 } + }, + { + "id": "streak_2", + "name": "二连击", + "desc": "连续 2 天有 din", + "icon": "✌️", + "condition": { "type": "streak", "min": 2 } + }, + { + "id": "streak_3", + "name": "三连击", + "desc": "连续 3 天有 din", + "icon": "🔥", + "condition": { "type": "streak", "min": 3 } + }, + { + "id": "streak_7", + "name": "一周战士", + "desc": "连续 7 天有 din", + "icon": "⚡", + "condition": { "type": "streak", "min": 7 } + }, + { + "id": "streak_14", + "name": "两周坚持", + "desc": "连续 14 天有 din", + "icon": "🌟", + "condition": { "type": "streak", "min": 14 } + }, + { + "id": "streak_30", + "name": "月度达人", + "desc": "连续 30 天有 din", + "icon": "🌙", + "condition": { "type": "streak", "min": 30 } + }, + { + "id": "streak_60", + "name": "双月之王", + "desc": "连续 60 天有 din", + "icon": "👑", + "condition": { "type": "streak", "min": 60 } + }, + { + "id": "streak_100", + "name": "百日筑基", + "desc": "连续 100 天有 din", + "icon": "🏆", + "condition": { "type": "streak", "min": 100 } + }, + { + "id": "streak_365", + "name": "年度传奇", + "desc": "连续 365 天有 din", + "icon": "🌞", + "condition": { "type": "streak", "min": 365 } + }, + { + "id": "day_5", + "name": "高产日", + "desc": "单日完成 5 个 din", + "icon": "🎯", + "condition": { "type": "day_max", "min": 5 } + }, + { + "id": "day_10", + "name": "超能日", + "desc": "单日完成 10 个 din", + "icon": "💥", + "condition": { "type": "day_max", "min": 10 } + }, + { + "id": "day_20", + "name": "无敌日", + "desc": "单日完成 20 个 din", + "icon": "🦸‍♂️", + "condition": { "type": "day_max", "min": 20 } + }, + { + "id": "early_bird", + "name": "早起的鸟儿", + "desc": "早上 6 点前完成 din", + "icon": "🐦", + "condition": { "type": "early_time", "hour": 6 } + }, + { + "id": "night_owl", + "name": "夜猫子", + "desc": "晚上 11 点后完成 din", + "icon": "🦉", + "condition": { "type": "late_time", "hour": 23 } + }, + { + "id": "writer", + "name": "记录者", + "desc": "10 条记录写了描述", + "icon": "📝", + "condition": { "type": "content_count", "min": 10 } + }, + { + "id": "storyteller", + "name": "故事王", + "desc": "50 条记录写了描述", + "icon": "📚", + "condition": { "type": "content_count", "min": 50 } + } +] diff --git a/app.py b/app.py new file mode 100644 index 0000000..e65f6bc --- /dev/null +++ b/app.py @@ -0,0 +1,340 @@ +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/', 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/', 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('/') +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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5bd19d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask==3.0.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..00af795 --- /dev/null +++ b/static/app.js @@ -0,0 +1,388 @@ +// API 基础地址 +const API_BASE = ''; + +// 离线队列(用于后台同步) +const DB_NAME = 'din-offline'; +const DB_VERSION = 1; +let db = null; + +// 初始化 IndexedDB +function initDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + db = request.result; + resolve(db); + }; + request.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains('pending')) { + db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true }); + } + }; + }); +} + +// 添加到离线队列 +async function addToQueue(data) { + if (!db) await initDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('pending', 'readwrite'); + const store = tx.objectStore('pending'); + const request = store.add({ data, timestamp: Date.now() }); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// 注册后台同步 +async function registerSync() { + if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { + const reg = await navigator.serviceWorker.ready; + await reg.sync.register('sync-din'); + } +} + +// DOM 元素 +const dinBtn = document.getElementById('din-btn'); +const inputSection = document.getElementById('input-section'); +const dinContent = document.getElementById('din-content'); +const saveBtn = document.getElementById('save-btn'); +const skipBtn = document.getElementById('skip-btn'); +const toast = document.getElementById('toast'); + +// 状态 +let currentRecordId = null; + +// 初始化 +async function init() { + await initDB(); + await loadStats(); + await loadRecent(); + await loadAchievements(); + + // 检查网络状态 + window.addEventListener('online', () => { + showToast('已连接到网络', true); + syncPendingData(); + }); + window.addEventListener('offline', () => { + showToast('进入离线模式', false); + }); +} + +// 同步离线数据 +async function syncPendingData() { + if (!db || !navigator.onLine) return; + + return new Promise((resolve, reject) => { + const tx = db.transaction('pending', 'readonly'); + const store = tx.objectStore('pending'); + const request = store.getAll(); + + request.onsuccess = async () => { + const pending = request.result; + if (pending.length === 0) { + resolve(); + return; + } + + for (const item of pending) { + try { + await fetch(`${API_BASE}/api/din`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item.data) + }); + + // 删除已同步的 + const delTx = db.transaction('pending', 'readwrite'); + const delStore = delTx.objectStore('pending'); + await delStore.delete(item.id); + } catch (err) { + console.error('Sync failed for item:', item.id); + } + } + + await loadStats(); + await loadRecent(); + resolve(); + }; + + request.onerror = () => reject(request.error); + }); +} + +// 加载统计数据 +async function loadStats() { + try { + const res = await fetch(`${API_BASE}/api/stats`); + const stats = await res.json(); + + document.getElementById('stat-today').textContent = stats.today; + document.getElementById('stat-week').textContent = stats.week; + document.getElementById('stat-month').textContent = stats.month; + document.getElementById('stat-total').textContent = stats.total; + + // 增长率 + updateGrowth('growth-today', stats.day_growth); + updateGrowth('growth-week', stats.week_growth); + updateGrowth('growth-month', stats.month_growth); + } catch (err) { + console.error('加载统计失败:', err); + } +} + +function updateGrowth(id, value) { + const el = document.getElementById(id); + if (value > 0) { + el.textContent = `+${value}%`; + el.classList.remove('negative'); + } else if (value < 0) { + el.textContent = `${value}%`; + el.classList.add('negative'); + } else { + el.textContent = '-'; + } +} + +// 加载最近记录 +async function loadRecent() { + try { + const res = await fetch(`${API_BASE}/api/din?limit=10`); + const records = await res.json(); + + const list = document.getElementById('recent-list'); + + if (records.length === 0) { + list.innerHTML = '
还没有记录,点击上方按钮开始!
'; + return; + } + + list.innerHTML = records.map(r => ` +
+
${formatTime(r.created_at)}
+
${r.content || '(无描述)'}
+
+ + +
+
+ `).join(''); + } catch (err) { + console.error('加载记录失败:', err); + } +} + +// 加载成就 +async function loadAchievements() { + try { + const res = await fetch(`${API_BASE}/api/achievements`); + const data = await res.json(); + + document.getElementById('achievement-progress').textContent = + `(${data.unlocked_count}/${data.total_count})`; + + const list = document.getElementById('achievement-list'); + list.innerHTML = data.achievements.map(a => ` +
+
${a.icon}
+
${a.name}
+
${a.desc}
+
+ `).join(''); + } catch (err) { + console.error('加载成就失败:', err); + } +} + +// 点击大按钮 +dinBtn.addEventListener('click', async () => { + dinBtn.classList.add('recording'); + dinBtn.disabled = true; + + const content = ''; // 先创建空记录 + + try { + // 检查网络状态 + if (!navigator.onLine) { + // 离线模式:存入队列 + await addToQueue({ content }); + await registerSync(); + showToast('已保存,将在联网时同步'); + + // 显示输入区域(离线编辑) + currentRecordId = 'offline_' + Date.now(); + inputSection.classList.remove('hidden'); + dinContent.value = ''; + dinContent.focus(); + return; + } + + const res = await fetch(`${API_BASE}/api/din`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + }); + + const data = await res.json(); + currentRecordId = data.id; + + // 显示输入区域 + inputSection.classList.remove('hidden'); + dinContent.value = ''; + dinContent.focus(); + + // 更新统计 + await loadStats(); + + } catch (err) { + console.error('创建记录失败:', err); + showToast('记录失败,请重试', false); + } finally { + dinBtn.classList.remove('recording'); + dinBtn.disabled = false; + } +}); + +// 保存内容 +saveBtn.addEventListener('click', async () => { + if (!currentRecordId) return; + + const content = dinContent.value.trim(); + + try { + await fetch(`${API_BASE}/api/din/${currentRecordId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + }); + + inputSection.classList.add('hidden'); + currentRecordId = null; + + await loadRecent(); + await loadAchievements(); + showToast('记录成功!'); + + } catch (err) { + console.error('保存失败:', err); + showToast('保存失败', false); + } +}); + +// 跳过 +skipBtn.addEventListener('click', async () => { + inputSection.classList.add('hidden'); + currentRecordId = null; + await loadRecent(); + await loadAchievements(); + showToast('记录成功!'); +}); + +// 回车保存 +dinContent.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + saveBtn.click(); + } +}); + +// 点击外部自动跳过(不丢失记录) +document.addEventListener('click', (e) => { + // 输入框隐藏时不处理 + if (inputSection.classList.contains('hidden')) return; + + // 点击输入框内部不处理 + if (inputSection.contains(e.target)) return; + + // 点击大按钮时不处理(刚点击过) + if (dinBtn.contains(e.target)) return; + + // 点击其他地方:自动跳过 + skipBtn.click(); +}); + +// 编辑记录 +async function editRecord(id, currentContent) { + const newContent = prompt('修改内容:', currentContent); + if (newContent === null) return; // 取消 + + try { + await fetch(`${API_BASE}/api/din/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: newContent.trim() }) + }); + + await loadRecent(); + showToast('修改成功!'); + } catch (err) { + console.error('修改失败:', err); + showToast('修改失败', false); + } +} + +// 删除记录 +async function deleteRecord(id) { + if (!confirm('确定删除这条记录?')) return; + + try { + await fetch(`${API_BASE}/api/din/${id}`, { method: 'DELETE' }); + + await loadRecent(); + await loadStats(); + await loadAchievements(); + showToast('删除成功'); + } catch (err) { + console.error('删除失败:', err); + showToast('删除失败', false); + } +} + +// 显示提示 +function showToast(text, success = true) { + toast.querySelector('.toast-text').textContent = text; + toast.querySelector('.toast-icon').textContent = success ? '✓' : '✗'; + toast.style.background = success ? 'rgba(74,222,128,0.9)' : 'rgba(248,113,113,0.9)'; + + toast.classList.remove('hidden'); + + setTimeout(() => { + toast.classList.add('hidden'); + }, 2000); +} + +// 格式化时间 +function formatTime(isoString) { + const date = new Date(isoString); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const recordDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + if (recordDate.getTime() === today.getTime()) { + return `${hours}:${minutes}`; + } + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (recordDate.getTime() === yesterday.getTime()) { + return `昨天 ${hours}:${minutes}`; + } + + return `${date.getMonth() + 1}/${date.getDate()}`; +} + +// HTML 转义 +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// 启动 +init(); diff --git a/static/icon-128.png b/static/icon-128.png new file mode 100644 index 0000000..fd1a98f Binary files /dev/null and b/static/icon-128.png differ diff --git a/static/icon-144.png b/static/icon-144.png new file mode 100644 index 0000000..ba46bfd Binary files /dev/null and b/static/icon-144.png differ diff --git a/static/icon-152.png b/static/icon-152.png new file mode 100644 index 0000000..69054b1 Binary files /dev/null and b/static/icon-152.png differ diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..b5cb617 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-384.png b/static/icon-384.png new file mode 100644 index 0000000..68f413c Binary files /dev/null and b/static/icon-384.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..d7dafe7 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/icon-72.png b/static/icon-72.png new file mode 100644 index 0000000..267718a Binary files /dev/null and b/static/icon-72.png differ diff --git a/static/icon-96.png b/static/icon-96.png new file mode 100644 index 0000000..45f7144 Binary files /dev/null and b/static/icon-96.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..6ee1c37 --- /dev/null +++ b/static/index.html @@ -0,0 +1,96 @@ + + + + + + + + + + din - Do It Now + + + + + + +
+ +
+

🦐 din

+

Do It Now - 想到就做

+
+ + +
+
+
0
+
今日
+
-
+
+
+
0
+
本周
+
-
+
+
+
0
+
本月
+
-
+
+
+
0
+
总计
+
+
+ + +
+ +
+ + + + + +
+

📝 最近记录

+
+
还没有记录,点击上方按钮开始!
+
+
+ + +
+

🏆 成就 (0/8)

+
+ +
+
+
+ + + + + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..ea025e5 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,52 @@ +{ + "name": "din - Do It Now", + "short_name": "din", + "description": "想到就做的记录器", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "orientation": "portrait", + "icons": [ + { + "src": "icon-72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "icon-96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "icon-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "icon-144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "icon-152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b8e53e0 --- /dev/null +++ b/static/style.css @@ -0,0 +1,371 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + color: #fff; + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 600px; + margin: 0 auto; +} + +/* 头部 */ +header { + text-align: center; + margin-bottom: 30px; +} + +header h1 { + font-size: 2.5rem; + margin-bottom: 5px; +} + +.subtitle { + color: #888; + font-size: 0.9rem; +} + +/* 统计面板 */ +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 30px; +} + +.stat-card { + background: rgba(255,255,255,0.05); + border-radius: 12px; + padding: 15px 10px; + text-align: center; + border: 1px solid rgba(255,255,255,0.1); +} + +.stat-card.total { + background: rgba(255,107,107,0.15); + border-color: rgba(255,107,107,0.3); +} + +.stat-value { + font-size: 1.8rem; + font-weight: bold; + margin-bottom: 5px; +} + +.stat-card.total .stat-value { + color: #ff6b6b; +} + +.stat-label { + font-size: 0.75rem; + color: #888; + margin-bottom: 3px; +} + +.stat-growth { + font-size: 0.7rem; + color: #4ade80; +} + +.stat-growth.negative { + color: #f87171; +} + +/* 大按钮 */ +.main-action { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.din-button { + width: 180px; + height: 180px; + border-radius: 50%; + border: none; + background: linear-gradient(145deg, #ff6b6b, #ee5a5a); + box-shadow: 0 10px 40px rgba(255,107,107,0.4), + inset 0 -5px 20px rgba(0,0,0,0.2), + inset 0 5px 20px rgba(255,255,255,0.2); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} + +.din-button:hover { + transform: scale(1.05); + box-shadow: 0 15px 50px rgba(255,107,107,0.5), + inset 0 -5px 20px rgba(0,0,0,0.2), + inset 0 5px 20px rgba(255,255,255,0.2); +} + +.din-button:active { + transform: scale(0.95); + box-shadow: 0 5px 20px rgba(255,107,107,0.3), + inset 0 -3px 10px rgba(0,0,0,0.2), + inset 0 3px 10px rgba(255,255,255,0.2); +} + +.din-button.recording { + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 10px 40px rgba(255,107,107,0.4); } + 50% { box-shadow: 0 10px 60px rgba(255,107,107,0.7); } +} + +.btn-text { + font-size: 3rem; + line-height: 1; +} + +.btn-label { + font-size: 1rem; + font-weight: 600; + color: #fff; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* 输入区域 */ +.input-section { + background: rgba(255,255,255,0.05); + border-radius: 12px; + padding: 20px; + margin-bottom: 30px; + border: 1px solid rgba(255,255,255,0.1); +} + +.input-section.hidden { + display: none; +} + +#din-content { + width: 100%; + padding: 15px; + font-size: 1rem; + border: 2px solid rgba(255,255,255,0.1); + border-radius: 8px; + background: rgba(0,0,0,0.2); + color: #fff; + margin-bottom: 15px; + outline: none; + transition: border-color 0.2s; +} + +#din-content:focus { + border-color: #ff6b6b; +} + +#din-content::placeholder { + color: #666; +} + +.input-actions { + display: flex; + gap: 10px; +} + +.btn-primary, .btn-secondary { + flex: 1; + padding: 12px 20px; + border-radius: 8px; + border: none; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: #ff6b6b; + color: #fff; +} + +.btn-primary:hover { + background: #ff5252; +} + +.btn-secondary { + background: rgba(255,255,255,0.1); + color: #aaa; +} + +.btn-secondary:hover { + background: rgba(255,255,255,0.15); + color: #fff; +} + +/* 最近记录 */ +.recent { + margin-bottom: 30px; +} + +.recent h2, .achievements h2 { + font-size: 1.1rem; + margin-bottom: 15px; + color: #aaa; +} + +.recent-list { + background: rgba(255,255,255,0.03); + border-radius: 12px; + overflow: hidden; +} + +.recent-item { + display: flex; + align-items: center; + padding: 15px; + border-bottom: 1px solid rgba(255,255,255,0.05); + gap: 15px; +} + +.recent-item:last-child { + border-bottom: none; +} + +.recent-time { + font-size: 0.75rem; + color: #666; + white-space: nowrap; + min-width: 60px; +} + +.recent-content { + flex: 1; + color: #ddd; + word-break: break-all; +} + +.recent-content.empty { + color: #666; + font-style: italic; +} + +.recent-actions { + display: flex; + gap: 5px; +} + +.recent-actions button { + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 5px; + font-size: 1rem; + transition: color 0.2s; +} + +.recent-actions button:hover { + color: #ff6b6b; +} + +.empty { + padding: 30px; + text-align: center; + color: #666; +} + +/* 成就 */ +.achievement-list { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.achievement-item { + background: rgba(255,255,255,0.03); + border-radius: 12px; + padding: 15px 10px; + text-align: center; + border: 2px solid transparent; + transition: all 0.2s; + opacity: 0.5; +} + +.achievement-item.unlocked { + background: rgba(255,215,0,0.1); + border-color: rgba(255,215,0,0.3); + opacity: 1; +} + +.achievement-icon { + font-size: 2rem; + margin-bottom: 5px; +} + +.achievement-name { + font-size: 0.75rem; + color: #aaa; + margin-bottom: 3px; +} + +.achievement-desc { + font-size: 0.6rem; + color: #666; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: rgba(74,222,128,0.9); + color: #000; + padding: 15px 30px; + border-radius: 30px; + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + transition: all 0.3s ease; + z-index: 1000; +} + +.toast.hidden { + opacity: 0; + transform: translateX(-50%) translateY(20px); + pointer-events: none; +} + +/* 响应式 */ +@media (max-width: 500px) { + .stats { + grid-template-columns: repeat(2, 1fr); + } + + .achievement-list { + grid-template-columns: repeat(4, 1fr); + } + + .achievement-name { + font-size: 0.65rem; + } + + .din-button { + width: 150px; + height: 150px; + } + + .btn-text { + font-size: 2.5rem; + } +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..73be34e --- /dev/null +++ b/static/sw.js @@ -0,0 +1,100 @@ +const CACHE_NAME = 'din-v1'; +const STATIC_ASSETS = [ + '/', + '/index.html', + '/style.css', + '/app.js', + '/manifest.json', + '/icon-72.png', + '/icon-96.png', + '/icon-128.png', + '/icon-144.png', + '/icon-192.png', + '/icon-512.png' +]; + +// 安装时缓存静态资源 +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +// 激活时清理旧缓存 +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// 拦截请求 +self.addEventListener('fetch', (e) => { + const { request } = e; + const url = new URL(request.url); + + // API 请求:网络优先,失败时返回缓存 + if (url.pathname.startsWith('/api/')) { + e.respondWith( + fetch(request) + .then((response) => { + // 缓存成功的 GET 请求 + if (request.method === 'GET') { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, clone); + }); + } + return response; + }) + .catch(() => { + return caches.match(request).then((cached) => { + if (cached) return cached; + // 返回离线数据 + if (url.pathname === '/api/stats') { + return new Response( + JSON.stringify({ today: 0, week: 0, month: 0, total: 0 }), + { headers: { 'Content-Type': 'application/json' } } + ); + } + if (url.pathname === '/api/din') { + return new Response(JSON.stringify([]), { + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response('Offline', { status: 503 }); + }); + }) + ); + return; + } + + // 静态资源:缓存优先 + e.respondWith( + caches.match(request).then((cached) => { + return cached || fetch(request); + }) + ); +}); + +// 后台同步(用于离线时记录的数据) +self.addEventListener('sync', (e) => { + if (e.tag === 'sync-din') { + e.waitUntil(syncPendingDins()); + } +}); + +async function syncPendingDins() { + // 从 IndexedDB 获取待同步的记录并发送 + // 这里简化处理,实际项目可以用 idb 库 + console.log('Syncing pending dins...'); +}