Initial commit: din - Do It Now 记录器
- 核心功能:一键记录 din 时刻 - 统计面板:日/周/月/总计 + 同比 - 成就系统:24个成就,支持配置文件扩展 - PWA 支持:离线可用,可安装到主屏幕 - 东八区时区支持 - SQLite 数据存储
33
.gitignore
vendored
Normal file
@ -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
|
||||||
25
README.md
Normal file
@ -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
|
||||||
170
achievements.json
Normal file
@ -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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
340
app.py
Normal file
@ -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/<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)
|
||||||
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
flask==3.0.0
|
||||||
388
static/app.js
Normal file
@ -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 = '<div class="empty">还没有记录,点击上方按钮开始!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = records.map(r => `
|
||||||
|
<div class="recent-item" data-id="${r.id}">
|
||||||
|
<div class="recent-time">${formatTime(r.created_at)}</div>
|
||||||
|
<div class="recent-content ${!r.content ? 'empty' : ''}">${r.content || '(无描述)'}</div>
|
||||||
|
<div class="recent-actions">
|
||||||
|
<button onclick="editRecord(${r.id}, '${escapeHtml(r.content || '')}')" title="编辑">✏️</button>
|
||||||
|
<button onclick="deleteRecord(${r.id})" title="删除">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 => `
|
||||||
|
<div class="achievement-item ${a.unlocked ? 'unlocked' : ''}" title="${a.desc}">
|
||||||
|
<div class="achievement-icon">${a.icon}</div>
|
||||||
|
<div class="achievement-name">${a.name}</div>
|
||||||
|
<div class="achievement-desc">${a.desc}</div>
|
||||||
|
</div>
|
||||||
|
`).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();
|
||||||
BIN
static/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
static/icon-144.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icon-152.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/icon-384.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/icon-512.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
static/icon-72.png
Normal file
|
After Width: | Height: | Size: 782 B |
BIN
static/icon-96.png
Normal file
|
After Width: | Height: | Size: 1022 B |
96
static/index.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="din">
|
||||||
|
<title>din - Do It Now</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
|
<script>
|
||||||
|
// 注册 Service Worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(reg => console.log('SW registered'))
|
||||||
|
.catch(err => console.log('SW error:', err));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<header>
|
||||||
|
<h1>🦐 din</h1>
|
||||||
|
<p class="subtitle">Do It Now - 想到就做</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 统计面板 -->
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="stat-today">0</div>
|
||||||
|
<div class="stat-label">今日</div>
|
||||||
|
<div class="stat-growth" id="growth-today">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="stat-week">0</div>
|
||||||
|
<div class="stat-label">本周</div>
|
||||||
|
<div class="stat-growth" id="growth-week">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="stat-month">0</div>
|
||||||
|
<div class="stat-label">本月</div>
|
||||||
|
<div class="stat-growth" id="growth-month">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card total">
|
||||||
|
<div class="stat-value" id="stat-total">0</div>
|
||||||
|
<div class="stat-label">总计</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 大按钮 -->
|
||||||
|
<section class="main-action">
|
||||||
|
<button id="din-btn" class="din-button">
|
||||||
|
<span class="btn-text">🔴</span>
|
||||||
|
<span class="btn-label">Do It Now</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 快速输入(点击后显示) -->
|
||||||
|
<section id="input-section" class="input-section hidden">
|
||||||
|
<input type="text" id="din-content" placeholder="做了什么?(可选,直接回车跳过)" maxlength="100">
|
||||||
|
<div class="input-actions">
|
||||||
|
<button id="save-btn" class="btn-primary">保存</button>
|
||||||
|
<button id="skip-btn" class="btn-secondary">跳过</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 最近记录 -->
|
||||||
|
<section class="recent">
|
||||||
|
<h2>📝 最近记录</h2>
|
||||||
|
<div id="recent-list" class="recent-list">
|
||||||
|
<div class="empty">还没有记录,点击上方按钮开始!</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 成就 -->
|
||||||
|
<section class="achievements">
|
||||||
|
<h2>🏆 成就 <span id="achievement-progress">(0/8)</span></h2>
|
||||||
|
<div id="achievement-list" class="achievement-list">
|
||||||
|
<!-- 动态生成 -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成功提示 -->
|
||||||
|
<div id="toast" class="toast hidden">
|
||||||
|
<span class="toast-icon">✓</span>
|
||||||
|
<span class="toast-text">记录成功!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
static/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
371
static/style.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
static/sw.js
Normal file
@ -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...');
|
||||||
|
}
|
||||||