- 核心功能:一键记录 din 时刻 - 统计面板:日/周/月/总计 + 同比 - 成就系统:24个成就,支持配置文件扩展 - PWA 支持:离线可用,可安装到主屏幕 - 东八区时区支持 - SQLite 数据存储
389 lines
12 KiB
JavaScript
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();
|