feat: 紫微·磁场管理打卡系统

- Flomo 风格左右布局(侧边栏+主内容区)
- 日历导航:月视图,三色状态圆点,点击日期快速切换
- 四层修为:立志/责善/改过/勤学
- 勤学 8 个固定预设项目,勾选后显示备注
- 编辑后 1.5 秒自动保存,切换日期静默保存
- Flask + SQLite,端口 5056
This commit is contained in:
mac
2026-06-01 23:11:00 +08:00
commit 7999d7700b
9 changed files with 2520 additions and 0 deletions

658
static/app.js Normal file
View File

@@ -0,0 +1,658 @@
/* ziwei-power 前端逻辑 — Flomo 风格左右布局 */
(function() {
'use strict';
/* ================================================================
State
================================================================ */
var calendarStatus = {}; // { "2026-06-01": "pass", ... }
var calYear, calMonth; // 日历显示的月份
var activePanel = 'daily'; // 当前面板
var weekOffset = 0; // 每周评分偏移
var autoSaveTimer = null; // 自动保存防抖
var lastSavedData = null; // 上次保存快照
var lastSavedDate = null; // 上次保存日期
/* 勤学预设项目 */
var PRESET_STUDY_ITEMS = [
'晚上 11 点 30 之前睡觉',
'早上 6 点 30 之前起床',
'起床后做 30 个俯卧撑',
'销售客情能力提升',
'管理能力提升',
'产品规划有进一步的提升',
'AI 能力提升',
'知识库能力提升'
];
/* ================================================================
Toast
================================================================ */
var toastTimer = null;
function showToast(msg, type) {
type = type || 'info';
var el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ el.classList.remove('show'); }, 2500);
}
/* ================================================================
API
================================================================ */
function apiGetCheckin(date, cb) {
fetch('/api/checkin?date=' + encodeURIComponent(date))
.then(function(r){ return r.json(); })
.then(function(res){ if (res.ok) cb(null, res.data); else cb(res.error); })
.catch(function(e){ cb(e.message); });
}
function apiSaveCheckin(date, data, cb) {
fetch('/api/checkin', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({date: date, data: data})
})
.then(function(r){ return r.json(); })
.then(function(res){ if (res.ok) cb(null); else cb(res.error); })
.catch(function(e){ cb(e.message); });
}
function apiDeleteCheckin(date, cb) {
fetch('/api/checkin/' + encodeURIComponent(date), {method: 'DELETE'})
.then(function(r){ return r.json(); })
.then(function(res){ if (res.ok) cb(null); else cb(res.error); })
.catch(function(e){ cb(e.message); });
}
function apiGetHistory(cb) {
fetch('/api/history')
.then(function(r){ return r.json(); })
.then(function(res){ if (res.ok) cb(null, res.data); else cb(res.error); })
.catch(function(e){ cb(e.message); });
}
function apiGetStats(cb) {
fetch('/api/stats')
.then(function(r){ return r.json(); })
.then(function(res){ if (res.ok) cb(null, res.data); else cb(res.error); })
.catch(function(e){ cb(e.message); });
}
/* ================================================================
Escaping
================================================================ */
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ================================================================
Panel Switching
================================================================ */
window.switchPanel = function(name) {
activePanel = name;
var navs = document.querySelectorAll('.sidebar-nav .nav-item');
var panels = document.querySelectorAll('.main-content .panel');
for (var i=0; i<navs.length; i++) navs[i].classList.remove('active');
for (var j=0; j<panels.length; j++) panels[j].classList.remove('active');
var nav = document.querySelector('.nav-item[data-panel="' + name + '"]');
var panel = document.getElementById('panel-' + name);
if (nav) nav.classList.add('active');
if (panel) panel.classList.add('active');
if (name === 'weekly') loadWeekly();
if (name === 'history') loadHistory();
};
/* ================================================================
Calendar
================================================================ */
/** 格式化日期 */
function fmtDate(d) {
var y = d.getFullYear();
var m = ('0'+(d.getMonth()+1)).slice(-2);
var dd = ('0'+d.getDate()).slice(-2);
return y + '-' + m + '-' + dd;
}
/** 判断两个日期是否同一天 */
function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
/** 切换日历月份 */
window.changeCalMonth = function(dir) {
calMonth += dir;
if (calMonth > 12) { calMonth = 1; calYear++; }
if (calMonth < 1) { calMonth = 12; calYear--; }
renderCalendar();
};
/** 渲染日历 */
function renderCalendar() {
var today = new Date();
var firstDay = new Date(calYear, calMonth - 1, 1);
var lastDay = new Date(calYear, calMonth, 0);
var startDayOfWeek = firstDay.getDay(); // 0=Sun
var totalDays = lastDay.getDate();
var prevMonthLastDay = new Date(calYear, calMonth - 1, 0).getDate();
// 月份标签
document.getElementById('cal-month-label').textContent =
calYear + '年 ' + calMonth + '月';
var grid = document.getElementById('cal-grid');
var html = '';
// 前月填充
for (var p = startDayOfWeek - 1; p >= 0; p--) {
html += '<div class="cal-cell other-month">' + (prevMonthLastDay - p) + '</div>';
}
// 当月
for (var d = 1; d <= totalDays; d++) {
var cur = new Date(calYear, calMonth - 1, d);
var ds = fmtDate(cur);
var classes = ['cal-cell'];
var isOtherMonth = false;
if (isSameDay(cur, today)) classes.push('today');
var status = calendarStatus[ds];
if (status) classes.push(status);
html += '<div class="' + classes.join(' ') + '" data-date="' + ds + '">' + d + '</div>';
}
// 后月填充 — 始终 42 格6行 × 7列保持高度一致
var totalCells = startDayOfWeek + totalDays;
var remaining = 42 - totalCells;
for (var n = 1; n <= remaining; n++) {
html += '<div class="cal-cell other-month">' + n + '</div>';
}
grid.innerHTML = html;
// 点击事件
grid.querySelectorAll('.cal-cell:not(.other-month)').forEach(function(cell) {
cell.addEventListener('click', function() {
var date = this.dataset.date;
if (date) navigateToDate(date);
});
});
}
/** 导航到指定日期 */
function navigateToDate(date) {
switchPanel('daily');
document.getElementById('check-date').value = date;
loadCheckin();
}
/** 回到今天 */
window.goToday = function() {
var today = fmtDate(new Date());
calYear = new Date().getFullYear();
calMonth = new Date().getMonth() + 1;
renderCalendar();
navigateToDate(today);
};
/* ================================================================
Stats — 加载统计数据并更新侧边栏
================================================================ */
function loadStats() {
apiGetStats(function(err, data) {
if (err) return;
// 更新统计数字
document.getElementById('stat-days').textContent = data.total_days;
document.getElementById('stat-morning').textContent = data.total_morning;
document.getElementById('stat-study').textContent = data.total_study;
// 更新日历状态
calendarStatus = data.calendar || {};
renderCalendar();
});
}
/* ================================================================
Build / Fill Data
================================================================ */
function buildData() {
var morningItems = [];
var mEls = document.querySelectorAll('#morning-list .item-row input');
for (var i=0; i<mEls.length; i++) {
var v = mEls[i].value.trim();
if (v) morningItems.push(v);
}
var eveningItems = [];
var eRows = document.querySelectorAll('#evening-list .evening-row');
for (var j=0; j<eRows.length; j++) {
var cols = eRows[j].querySelectorAll('.col input');
var mistake = cols[0] ? cols[0].value.trim() : '';
var improve = cols[1] ? cols[1].value.trim() : '';
if (mistake || improve) eveningItems.push({ mistake: mistake, improvement: improve });
}
var studyItems = [];
var pItems = document.querySelectorAll('#study-list .preset-item');
for (var k=0; k<pItems.length; k++) {
var cb = pItems[k].querySelector('input[type="checkbox"]');
var noteEl = pItems[k].querySelector('.preset-note');
var nameEl = pItems[k].querySelector('.preset-name');
var name = nameEl ? nameEl.textContent : '';
if (cb && cb.checked) {
studyItems.push({ name: name, done: true, note: noteEl ? noteEl.value.trim() : '' });
} else {
studyItems.push({ name: name, done: false, note: '' });
}
}
return { morning: morningItems, evening: eveningItems, study: studyItems };
}
function fillForm(data) {
data = data || {};
var morning = data.morning || [];
var evening = data.evening || [];
var study = data.study || [];
// 早间立志
document.getElementById('morning-list').innerHTML = '';
if (morning.length === 0) morning = [''];
for (var m=0; m<morning.length; m++) addMorningRow(morning[m]);
// 责善改过
document.getElementById('evening-list').innerHTML = '';
if (evening.length === 0) evening = [{}];
for (var e=0; e<evening.length; e++) {
var item = typeof evening[e] === 'string' ? {mistake: evening[e], improvement: ''} : evening[e];
addEveningRow(item.mistake || '', item.improvement || '');
}
// 勤学 — 渲染预设项目并回填状态
var studyMap = {};
for (var si=0; si<study.length; si++) {
studyMap[study[si].name] = study[si];
}
document.getElementById('study-list').innerHTML = '';
for (var sp=0; sp<PRESET_STUDY_ITEMS.length; sp++) {
var sName = PRESET_STUDY_ITEMS[sp];
var saved = studyMap[sName] || { done: false, note: '' };
addPresetItem(sName, saved.done, saved.note || '');
}
}
/* ================================================================
Dynamic Rows
================================================================ */
function addMorningRow(val) {
var container = document.getElementById('morning-list');
var idx = container.children.length;
val = val || '';
var div = document.createElement('div');
div.className = 'item-row';
div.innerHTML = '<span class="idx">' + (idx+1) + '.</span>' +
'<input type="text" value="' + esc(val) + '" placeholder="今天最重要的一件事…">' +
'<button class="btn-del" onclick="this.parentElement.remove();renumberMorning()"><svg class="icon-sm"><use href="#icon-x"/></svg></button>';
container.appendChild(div);
renumberMorning();
}
function renumberMorning() {
var rows = document.querySelectorAll('#morning-list .item-row');
for (var i=0; i<rows.length; i++) {
var span = rows[i].querySelector('.idx');
if (span) span.textContent = (i+1) + '.';
}
}
function addEveningRow(mistake, improve) {
var container = document.getElementById('evening-list');
var idx = container.children.length;
mistake = mistake || '';
improve = improve || '';
var div = document.createElement('div');
div.className = 'evening-row';
div.innerHTML =
'<div class="evening-header">' +
'<span class="idx">' + (idx+1) + '.</span>' +
'<button class="btn-del" onclick="this.parentElement.parentElement.remove();renumberEvening()"><svg class="icon-sm"><use href="#icon-x"/></svg></button>' +
'</div>' +
'<div class="mistake-row">' +
'<div class="col"><label>错误</label><input type="text" value="' + esc(mistake) + '" placeholder="今天犯的错…"></div>' +
'<div class="col"><label>改进方案</label><input type="text" value="' + esc(improve) + '" placeholder="下次怎么做…"></div>' +
'</div>';
container.appendChild(div);
renumberEvening();
}
function renumberEvening() {
var rows = document.querySelectorAll('#evening-list .evening-row');
for (var i=0; i<rows.length; i++) {
var span = rows[i].querySelector('.idx');
if (span) span.textContent = (i+1) + '.';
}
}
/* ── 勤学预设项目 ── */
function addPresetItem(name, done, note) {
var container = document.getElementById('study-list');
var div = document.createElement('div');
div.className = 'preset-item' + (done ? ' active' : '');
var noteEsc = esc(note || '');
div.innerHTML =
'<label class="preset-check">' +
'<input type="checkbox"' + (done ? ' checked' : '') + ' onchange="togglePresetNote(this)">' +
'<span class="preset-name">' + esc(name) + '</span>' +
'</label>' +
'<textarea class="preset-note" placeholder="一句话备注…">' + noteEsc + '</textarea>';
container.appendChild(div);
}
window.togglePresetNote = function(cb) {
var item = cb.closest('.preset-item');
if (item) {
if (cb.checked) item.classList.add('active');
else { item.classList.remove('active'); item.querySelector('.preset-note').value = ''; }
}
triggerAutoSave();
};
function initStudyPresets() {
document.getElementById('study-list').innerHTML = '';
for (var i=0; i<PRESET_STUDY_ITEMS.length; i++) {
addPresetItem(PRESET_STUDY_ITEMS[i], false, '');
}
}
window.addMorning = function() {
var count = document.querySelectorAll('#morning-list .item-row').length;
if (count >= 3) { showToast('最多 3 条立志', 'error'); return; }
addMorningRow('');
};
window.addEvening = function() {
var count = document.querySelectorAll('#evening-list .evening-row').length;
if (count >= 5) { showToast('最多 5 条改过', 'error'); return; }
addEveningRow('', '');
};
/* ================================================================
Load & Auto Save Checkin
================================================================ */
var lastSavedData = null; // 上次保存的数据快照,避免重复保存
window.loadCheckin = function() {
var date = document.getElementById('check-date').value;
if (!date) return;
// 切换日期前先保存当前数据
var currentData = buildData();
var currentStr = JSON.stringify(currentData);
if (lastSavedDate && lastSavedData !== null && currentStr !== lastSavedData) {
// 有未保存的变更,先静默保存
var hasContent = currentData.morning.some(function(x){return x && x.trim();}) ||
currentData.evening.some(function(x){return (x.mistake||'').trim() || (x.improvement||'').trim();}) ||
currentData.study.some(function(x){return x.done;});
if (hasContent) {
apiSaveCheckin(lastSavedDate, currentData, function(){}); // 静默保存
}
}
lastSavedDate = date;
apiGetCheckin(date, function(err, row){
if (err) { initStudyPresets(); lastSavedData = '{}'; return; }
fillForm(row ? row.data : null);
lastSavedData = JSON.stringify(buildData());
});
};
/** 自动保存(带防抖) */
function triggerAutoSave() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(function() {
var date = document.getElementById('check-date').value;
if (!date) return;
var data = buildData();
var dataStr = JSON.stringify(data);
if (dataStr === lastSavedData) return; // 无变化,跳过
apiSaveCheckin(date, data, function(err){
if (err) { showToast('自动保存失败', 'error'); return; }
lastSavedData = dataStr;
showToast('已自动保存', 'info');
loadStats();
});
}, 1500);
}
/** 为每日打卡面板绑定自动保存事件 */
function bindAutoSave() {
var panel = document.getElementById('panel-daily');
if (!panel) return;
// 输入框 & 文本域input 事件
panel.addEventListener('input', function(e) {
if (e.target.matches('input[type="text"], textarea, input[type="date"]')) {
triggerAutoSave();
}
});
// 复选框change 事件
panel.addEventListener('change', function(e) {
if (e.target.matches('input[type="checkbox"]')) {
triggerAutoSave();
}
});
// 删除行按钮:点击后触发
panel.addEventListener('click', function(e) {
var btn = e.target.closest('.btn-del');
if (btn) {
setTimeout(triggerAutoSave, 100); // 等 DOM 更新后
}
});
// 预设项目勾选切换
panel.addEventListener('change', function(e) {
if (e.target.matches('.preset-check input[type="checkbox"]')) {
triggerAutoSave();
}
});
}
/* ================================================================
Weekly Score
================================================================ */
var WEEKDAYS_CN = ['日','一','二','三','四','五','六'];
function getMonday(d) {
var day = d.getDay();
var diff = d.getDate() - day + (day === 0 ? -6 : 1);
var m = new Date(d);
m.setDate(diff);
return m;
}
window.changeWeek = function(dir) {
weekOffset += dir;
loadWeekly();
};
function loadWeekly() {
var today = new Date();
today.setDate(today.getDate() + weekOffset * 7);
var monday = getMonday(today);
var sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
document.getElementById('week-label').textContent =
fmtDate(monday) + ' ~ ' + fmtDate(sunday);
apiGetHistory(function(err, rows){
if (err) { showToast('加载失败', 'error'); return; }
var map = {};
for (var i=0; i<rows.length; i++) map[rows[i].date] = rows[i].data;
var totalDays = 0, totalScore = 0, dayCells = [];
for (var j=0; j<7; j++) {
var cur = new Date(monday);
cur.setDate(monday.getDate() + j);
var ds = fmtDate(cur);
var dd = map[ds];
if (dd) {
totalDays++;
var dayScore = 0;
var morning = dd.morning || [];
var hasMorning = morning.some(function(x){ return x && typeof x === 'string' && x.trim(); });
var evening = dd.evening || [];
var hasEvening = evening.some(function(x){
var mst = typeof x === 'string' ? x : (x.mistake || '');
return mst && typeof mst === 'string' && mst.trim();
});
var study = dd.study || [];
var hasStudy = study.some(function(x){ return x.done; });
if (hasMorning) dayScore++;
if (hasEvening) dayScore++;
if (hasStudy) dayScore++;
totalScore += dayScore;
var status = dayScore >= 2 ? 'pass' : 'fail';
dayCells.push({
ds: ds, dayScore: dayScore, status: status,
weekday: '周' + WEEKDAYS_CN[cur.getDay()]
});
} else {
dayCells.push({
ds: ds, dayScore: 0, status: 'empty',
weekday: '周' + WEEKDAYS_CN[cur.getDay()]
});
}
}
var score = totalDays > 0 ? Math.round((totalScore / (totalDays * 3)) * 100) : 0;
document.getElementById('weekly-score').textContent = score;
var ring = document.querySelector('.score-ring');
if (ring) {
var R = 50, C = 2 * Math.PI * R;
ring.setAttribute('stroke-dasharray', C);
ring.setAttribute('stroke-dashoffset', C - (C * score / 100));
}
var txt = '';
if (score >= 90) txt = '卓越!磁场非常强大';
else if (score >= 70) txt = '良好,继续保持';
else if (score >= 50) txt = '一般,需要加强';
else txt = '较弱,急需调整';
document.getElementById('score-text').textContent = txt;
var completedDays = dayCells.filter(function(c){ return c.status === 'pass'; }).length;
var totalFilled = dayCells.filter(function(c){ return c.status !== 'empty'; }).length;
document.getElementById('score-stats').textContent =
'本周填卡 ' + totalFilled + '/7 天 · 达标 ' + completedDays + ' 天';
var gridHtml = '';
for (var g=0; g<dayCells.length; g++) {
var cell = dayCells[g];
var dateParts = cell.ds.split('-');
var shortDate = dateParts[1] + '/' + dateParts[2];
var badgeText = cell.status === 'pass' ? '完成' : (cell.status === 'fail' ? '未达标' : '未打卡');
gridHtml += '<div class="day-cell ' + cell.status + '">' +
'<div class="day-label">' + cell.weekday + '</div>' +
'<div class="day-date">' + shortDate + '</div>' +
'<div class="day-score">' + cell.dayScore + '<span style="font-size:11px;font-weight:400">/3</span></div>' +
'<div class="day-badge">' + badgeText + '</div>' +
'</div>';
}
document.getElementById('week-days-grid').innerHTML = gridHtml;
});
}
/* ================================================================
History
================================================================ */
function loadHistory() {
apiGetHistory(function(err, rows){
var el = document.getElementById('history-grid');
if (err) {
el.innerHTML = '<div class="empty-state"><div class="empty-icon"><svg viewBox="0 0 24 24"><use href="#icon-list-bullet"/></svg></div>加载失败</div>';
return;
}
if (!rows || rows.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="empty-icon"><svg viewBox="0 0 24 24"><use href="#icon-list-bullet"/></svg></div>暂无历史记录</div>';
return;
}
var html = '';
for (var i=0; i<rows.length; i++) {
var r = rows[i];
var morning = (r.data.morning || []).filter(function(x){ return x && typeof x === 'string' && x.trim(); });
var evening = (r.data.evening || []).filter(function(x){
var mst = typeof x === 'string' ? x : (x.mistake || '');
return mst && typeof mst === 'string' && mst.trim();
});
var study = (r.data.study || []).filter(function(x){ return x.done; });
var tags = [];
if (morning.length) tags.push('<span><svg class="icon-xs" style="color:var(--primary)"><use href="#icon-sun"/></svg> 立志 ' + morning.length + '</span>');
if (evening.length) tags.push('<span><svg class="icon-xs" style="color:var(--danger)"><use href="#icon-magnifying-glass"/></svg> 改过 ' + evening.length + '</span>');
if (study.length) tags.push('<span><svg class="icon-xs" style="color:#059669"><use href="#icon-book-open"/></svg> 勤学 ' + study.length + '</span>');
html += '<div class="history-card">' +
'<div class="info">' +
'<div class="date">' + r.date + '</div>' +
'<div class="preview">' + (tags.join('') || '暂无内容') + '</div>' +
'</div>' +
'<button class="btn-del-history" onclick="deleteHistory(\'' + r.date + '\')"><svg class="icon-xs"><use href="#icon-trash"/></svg> 删除</button>' +
'</div>';
}
el.innerHTML = html;
});
}
window.deleteHistory = function(date) {
if (!confirm('确定删除 ' + date + ' 的打卡记录吗?此操作不可恢复!')) return;
apiDeleteCheckin(date, function(err){
if (err) { showToast('删除失败: ' + err, 'error'); return; }
showToast('已删除', 'info');
loadHistory();
loadStats(); // 更新统计
});
};
/* ================================================================
Init
================================================================ */
function init() {
var today = new Date();
calYear = today.getFullYear();
calMonth = today.getMonth() + 1;
var cd = document.getElementById('check-date');
var todayStr = fmtDate(today);
if (cd) cd.value = todayStr;
initStudyPresets();
bindAutoSave();
lastSavedDate = todayStr;
loadCheckin();
loadStats();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

1052
static/style.css Normal file

File diff suppressed because it is too large Load Diff