From 5177028bae63cbf9647e36538c3da292847db55f Mon Sep 17 00:00:00 2001 From: ching Date: Sat, 21 Feb 2026 05:57:22 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20din=20-=20Do=20It=20Now=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 核心功能:一键记录 din 时刻 - 统计面板:日/周/月/总计 + 同比 - 成就系统:24个成就,支持配置文件扩展 - PWA 支持:离线可用,可安装到主屏幕 - 东八区时区支持 - SQLite 数据存储 --- .gitignore | 33 ++++ README.md | 25 +++ achievements.json | 170 +++++++++++++++++++ app.py | 340 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + static/app.js | 388 +++++++++++++++++++++++++++++++++++++++++++ static/icon-128.png | Bin 0 -> 1417 bytes static/icon-144.png | Bin 0 -> 1548 bytes static/icon-152.png | Bin 0 -> 1660 bytes static/icon-192.png | Bin 0 -> 2067 bytes static/icon-384.png | Bin 0 -> 4104 bytes static/icon-512.png | Bin 0 -> 5696 bytes static/icon-72.png | Bin 0 -> 782 bytes static/icon-96.png | Bin 0 -> 1022 bytes static/index.html | 96 +++++++++++ static/manifest.json | 52 ++++++ static/style.css | 371 +++++++++++++++++++++++++++++++++++++++++ static/sw.js | 100 +++++++++++ 18 files changed, 1576 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 achievements.json create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/icon-128.png create mode 100644 static/icon-144.png create mode 100644 static/icon-152.png create mode 100644 static/icon-192.png create mode 100644 static/icon-384.png create mode 100644 static/icon-512.png create mode 100644 static/icon-72.png create mode 100644 static/icon-96.png create mode 100644 static/index.html create mode 100644 static/manifest.json create mode 100644 static/style.css create mode 100644 static/sw.js 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 0000000000000000000000000000000000000000..fd1a98ffa6695ba89adc96154bd10a60bd01f979 GIT binary patch literal 1417 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 literal 0 HcmV?d00001 diff --git a/static/icon-152.png b/static/icon-152.png new file mode 100644 index 0000000000000000000000000000000000000000..69054b183d1235386cca740c0cc60da0086c042a GIT binary patch literal 1660 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 literal 0 HcmV?d00001 diff --git a/static/icon-384.png b/static/icon-384.png new file mode 100644 index 0000000000000000000000000000000000000000..68f413c9bec475b38884bc360b1b90d1b17d4847 GIT binary patch literal 4104 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 QwH;c#xNUZAa$%(Z8y724$N&HU literal 0 HcmV?d00001 diff --git a/static/icon-72.png b/static/icon-72.png new file mode 100644 index 0000000000000000000000000000000000000000..267718aa3fa0752e2535bd63178db2cbfd9bd055 GIT binary patch literal 782 zcmV+p1M&QcP)^-DK;#!Vu^|-$S8*+hS7CG z>xm&LiXbmJ93d1Qbq|#^SkNyyDaG_*wlzf3AVDlSDa7<)v>PfZSP&^EhnPM@3?Y&h zLgEl9$JA~wu8<2>Z$V<^*xDZ`$xD!UIUNxt1$8k~qhjQA5R$mC1v#xtlhfgdi@s3s2~EXpg%c#^VL9s{SRv*Vi^V0|4}S^}=Gfyc+} z;pP{#zYpBs19x}8<|eScZ7w$&0dH@>*%`393Y?r25tM%n+SmXd9@^ulC*bqbT&`(= z+uQbdGHJ7@0Qm(iF3ygdru~@s_yFGDXWyq~?t1bIdUznojx>tih zcl!|NelY^waY&$hRta?1GgW8o3Qg#~c_#YH3!c>Q+Z~FcD2iad06!&&-ag_FCjbBd M07*qoM6N<$g5Z%;8UO$Q literal 0 HcmV?d00001 diff --git a/static/icon-96.png b/static/icon-96.png new file mode 100644 index 0000000000000000000000000000000000000000..45f7144b14880b52b386fc3c71643c0777d351d4 GIT binary patch literal 1022 zcmV zy-q7J5QWcP(tt!qLyc&mphi5v?IW+ZFaN%*s~E92K59{#mGeF`I@H)t%pjo79bniA89 zs0Iv*A42p#2F!?MLX<{W39pmbh~Ngy72j5Po4QVT?vN$~HK12~Q*j(7(q25<3}r%) zlX{JMO3?$vKy3uM0nXyb5MIYI9!e8P4R9(sDTLQ?h>ywyG6Sr|=ODa}Wvmn?{P8`( zTyhd0=#MMl+hh_0I!AwQ;wvmnptz3ZokGUqa~Hk~!+!XbBCiZ6T6~~8ZsJS`I|l&y zimwP9zq0{Wb-y$Sv%txK;!BTCz*0n0f=U)&VQ7La)&>~4T$=a_gMQg&fqw?nQuq7t z?7x)(RgZoJaTfSxK<%Sn;h_WV=YY~jzk;xz#s>UKD;fPi(eyAaLv$HX%jj1S^*8*b z2w^Tp21xla#Gwq)WI%1@M?uhUDM5)50}?Z60EgShb7n|C`wY-0;9BdyLhrxj8x$!) ziOJ7^@wolDx(aM>r;UYxQR)Dkd3|jknZw_JqaBs{T(`Rd6NAKeB zyfiat0GCBAa%D(A{*!RwhyjTScXMtH#JV~6R~@VUJAghkT}Jn5-77+H+&j87S77rb)3@}Qo(zC#ze>PVS{7X>nS>V}!YXhpz0^-pR zguFSoG!p{-E3T6Poi3MbLcm2koHM}Kgc2cKF(v$^h}^RvaQt2d6km!WtQSP`ZC6n% zW&u{Q`+j3cQi81J1Mb-nQG6iu9FUs{39?q!4F$?rH&h`j=h`7_M_b0ge(mV*$m;3C z$o>Ha=gxNj0EO(JG3WdtPp-qth?@40`Ht+Oq_q{RAsyLA?0=iF8`R;j6hf6d>2)D{ sNisrqQ)Nb}`w0tzAP9mW2toq>0U5>T(`N5Gi2wiq07*qoM6N<$f>)W_5dZ)H literal 0 HcmV?d00001 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...'); +}