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

389 lines
12 KiB
JavaScript

// 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();