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

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
venv/
env/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Database
*.db
# WorkBuddy
.workbuddy/

185
app.py Normal file
View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""ziwei-power Flask 应用 — 磁场管理打卡系统"""
import os
from datetime import timedelta
from functools import wraps
from flask import Flask, render_template, request, jsonify, session, redirect, url_for
from werkzeug.security import generate_password_hash, check_password_hash
from database import init_db, get_checkin, save_checkin, delete_checkin, get_all_checkins
app = Flask(__name__)
app.secret_key = os.urandom(24)
# 会话持久化30 天
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30)
# ── 用户库 ────────────────────────────────────────────
USERS = {
'qiukai': {
'password_hash': generate_password_hash('AiAlex2018$'),
'name': '凯哥'
}
}
# ── 登录检查装饰器 ────────────────────────────────────
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login_page', next=request.path))
return f(*args, **kwargs)
return decorated
# ── 登录路由 ──────────────────────────────────────────
@app.route('/login', methods=['GET', 'POST'])
def login_page():
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
remember = request.form.get('remember') == 'on'
user = USERS.get(username)
if user and check_password_hash(user['password_hash'], password):
session.permanent = remember
session['logged_in'] = True
session['username'] = username
session['display_name'] = user['name']
next_url = request.args.get('next', '/')
return jsonify({'ok': True, 'next': next_url})
else:
return jsonify({'ok': False, 'error': '账号或密码错误'}), 401
# GET如果已登录直接跳转
if session.get('logged_in'):
return redirect(url_for('index'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login_page'))
# ── 主页 ──────────────────────────────────────────────
@app.route('/')
@login_required
def index():
return render_template('index.html',
username=session.get('display_name', session.get('username', '')))
# ── API ──────────────────────────────────────────────
@app.route('/api/checkin', methods=['GET'])
@login_required
def api_get_checkin():
date = request.args.get('date', '')
if not date:
return jsonify({'ok': False, 'error': '缺少 date 参数'}), 400
row = get_checkin(date)
return jsonify({'ok': True, 'data': row})
@app.route('/api/checkin', methods=['POST'])
@login_required
def api_save_checkin():
body = request.get_json(force=True)
date = body.get('date', '')
if not date:
return jsonify({'ok': False, 'error': '缺少 date 字段'}), 400
data = body.get('data', {})
save_checkin(date, data)
return jsonify({'ok': True})
@app.route('/api/checkin/<date>', methods=['DELETE'])
@login_required
def api_delete_checkin(date):
delete_checkin(date)
return jsonify({'ok': True})
@app.route('/api/history', methods=['GET'])
@login_required
def api_history():
rows = get_all_checkins()
return jsonify({'ok': True, 'data': rows})
@app.route('/api/stats', methods=['GET'])
@login_required
def api_stats():
"""返回统计数据和日历状态映射"""
rows = get_all_checkins()
total_days = len(rows)
total_morning = 0
total_evening = 0
total_study = 0
calendar = {}
for row in rows:
d = row['date']
data = row['data']
morning = data.get('morning', [])
evening = data.get('evening', [])
study = data.get('study', [])
# 统计条目
total_morning += sum(1 for x in morning if isinstance(x, str) and x.strip())
total_evening += sum(1 for x in evening if (
isinstance(x, str) and x.strip() or
isinstance(x, dict) and (x.get('mistake', '') or '').strip()
))
total_study += sum(1 for x in study if x.get('done'))
# 评分
day_score = 0
has_morning = any(isinstance(x, str) and x.strip() for x in morning)
has_evening = any(
(isinstance(x, str) and x.strip()) or
(isinstance(x, dict) and (x.get('mistake', '') or '').strip())
for x in evening
)
has_study = any(x.get('done') for x in study)
if has_morning:
day_score += 1
if has_evening:
day_score += 1
if has_study:
day_score += 1
calendar[d] = 'pass' if day_score >= 2 else 'fail'
return jsonify({
'ok': True,
'total_days': total_days,
'total_morning': total_morning,
'total_evening': total_evening,
'total_study': total_study,
'calendar': calendar
})
@app.route('/api/user', methods=['GET'])
@login_required
def api_user():
return jsonify({
'ok': True,
'username': session.get('username'),
'display_name': session.get('display_name')
})
# ── 启动 ──────────────────────────────────────────────
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5056, debug=False)

94
database.py Normal file
View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""ziwei-power SQLite 数据库操作层"""
import sqlite3
import json
import os
from datetime import datetime
DB_DIR = os.path.join(os.path.expanduser('~'), '.workbuddy', 'data', 'ziwei-power')
os.makedirs(DB_DIR, exist_ok=True)
DB_PATH = os.path.join(DB_DIR, 'ziwei_power.db')
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""初始化数据库表"""
conn = get_conn()
conn.execute('''
CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT UNIQUE NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''')
conn.commit()
conn.close()
def get_checkin(date_str):
"""获取某天的打卡记录,返回 dict 或 None"""
conn = get_conn()
row = conn.execute('SELECT * FROM checkins WHERE date = ?', (date_str,)).fetchone()
conn.close()
if row:
return {
'id': row['id'],
'date': row['date'],
'data': json.loads(row['data']),
'created_at': row['created_at'],
'updated_at': row['updated_at']
}
return None
def save_checkin(date_str, data_dict):
"""保存或更新打卡记录"""
now = datetime.now().isoformat()
conn = get_conn()
existing = conn.execute('SELECT id FROM checkins WHERE date = ?', (date_str,)).fetchone()
json_data = json.dumps(data_dict, ensure_ascii=False)
if existing:
conn.execute(
'UPDATE checkins SET data = ?, updated_at = ? WHERE date = ?',
(json_data, now, date_str)
)
else:
conn.execute(
'INSERT INTO checkins (date, data, created_at, updated_at) VALUES (?, ?, ?, ?)',
(date_str, json_data, now, now)
)
conn.commit()
conn.close()
def delete_checkin(date_str):
"""删除某天的打卡记录"""
conn = get_conn()
conn.execute('DELETE FROM checkins WHERE date = ?', (date_str,))
conn.commit()
conn.close()
def get_all_checkins():
"""获取所有打卡记录,按日期倒序"""
conn = get_conn()
rows = conn.execute('SELECT * FROM checkins ORDER BY date DESC').fetchall()
conn.close()
results = []
for row in rows:
results.append({
'id': row['id'],
'date': row['date'],
'data': json.loads(row['data']),
'created_at': row['created_at'],
'updated_at': row['updated_at']
})
return results

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
flask==3.1.0

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

66
templates/icons.html Normal file
View File

@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- 用户 -->
<symbol id="icon-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/><path d="M4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
</symbol>
<!---->
<symbol id="icon-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</symbol>
<!-- 闪电 -->
<symbol id="icon-bolt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"/>
</symbol>
<!-- 日历 -->
<symbol id="icon-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.75 2.994v2.25m10.5-2.25v2.25m-14.252 13.5V7.491a2.25 2.25 0 0 1 2.25-2.25h13.5a2.25 2.25 0 0 1 2.25 2.25v13.5m-16.5 0a2.25 2.25 0 0 0 2.25 2.25h13.5a2.25 2.25 0 0 0 2.25-2.25m-16.5 0v-6.75a2.25 2.25 0 0 1 2.25-2.25h13.5a2.25 2.25 0 0 1 2.25 2.25v6.75m-16.5 0h16.5"/>
</symbol>
<!-- 图表 -->
<symbol id="icon-chart-bar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75Zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625Zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/>
</symbol>
<!-- 文档列表 -->
<symbol id="icon-list-bullet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</symbol>
<!-- 放大镜 -->
<symbol id="icon-magnifying-glass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
</symbol>
<!-- 书本 -->
<symbol id="icon-book-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"/>
</symbol>
<!-- 太阳 -->
<symbol id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
</symbol>
<!-- 加号 -->
<symbol id="icon-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4.5v15m7.5-7.5h-15"/>
</symbol>
<!-- 删除 -->
<symbol id="icon-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12.78 0a.5.5 0 0 0 .5-.5v-1.5m-.5.5h.5"/><path d="M4.772 5.79A48.212 48.212 0 0 1 8.25 3.311m0 0a48.212 48.212 0 0 1 6.678-.774M12 3.75c2.23 0 4.27.656 5.65 1.751"/>
</symbol>
<!-- 勾选 -->
<symbol id="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m4.5 12.75 6 6 9-13.5"/>
</symbol>
<!-- 关闭 -->
<symbol id="icon-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18 18 6M6 6l12 12"/>
</symbol>
<!-- 登出 -->
<symbol id="icon-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</symbol>
<!-- 左箭头 -->
<symbol id="icon-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.75 19.5 8.25 12l7.5-7.5"/>
</symbol>
<!-- 右箭头 -->
<symbol id="icon-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

185
templates/index.html Normal file
View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>紫微 · 磁场管理</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% include "icons.html" %}
<div id="toast" class="toast"></div>
<div class="app-layout">
<!-- ═══ 左侧边栏 ═══ -->
<aside class="sidebar">
<!-- 用户信息 -->
<div class="sidebar-user">
<div class="user-avatar">{{ username[0] }}</div>
<div class="user-info">
<span class="user-name">{{ username }}</span>
<span class="user-tag">道阁 · 紫微</span>
</div>
<a href="/logout" class="btn-logout-icon" title="退出登录" onclick="return confirm('确定退出登录?')">
<svg class="icon-sm"><use href="#icon-logout"/></svg>
</a>
</div>
<!-- 统计信息 -->
<div class="sidebar-stats" id="sidebar-stats">
<div class="stat-item">
<span class="stat-num" id="stat-days">--</span>
<span class="stat-label">打卡天</span>
</div>
<div class="stat-item">
<span class="stat-num" id="stat-morning">--</span>
<span class="stat-label">立志</span>
</div>
<div class="stat-item">
<span class="stat-num" id="stat-study">--</span>
<span class="stat-label">勤学</span>
</div>
</div>
<!-- 日历 -->
<div class="calendar-widget">
<div class="cal-header">
<button class="cal-nav" onclick="changeCalMonth(-1)">
<svg class="icon-sm"><use href="#icon-chevron-left"/></svg>
</button>
<span class="cal-month-label" id="cal-month-label">2026年 6月</span>
<button class="cal-nav" onclick="changeCalMonth(1)">
<svg class="icon-sm"><use href="#icon-chevron-right"/></svg>
</button>
</div>
<div class="cal-weekdays">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="cal-grid" id="cal-grid"></div>
<div class="cal-legend">
<span class="legend-dot pass"></span> 达标
<span class="legend-dot fail"></span> 未达标
<span class="legend-dot empty"></span> 未打卡
</div>
</div>
<!-- 功能入口 -->
<nav class="sidebar-nav">
<a class="nav-item active" data-panel="daily" onclick="switchPanel('daily')">
<svg class="icon-sm"><use href="#icon-calendar"/></svg>
每日打卡
</a>
<a class="nav-item" data-panel="weekly" onclick="switchPanel('weekly')">
<svg class="icon-sm"><use href="#icon-chart-bar"/></svg>
每周评分
</a>
<a class="nav-item" data-panel="history" onclick="switchPanel('history')">
<svg class="icon-sm"><use href="#icon-list-bullet"/></svg>
历史记录
</a>
</nav>
<!-- 底部新建按钮 -->
<div class="sidebar-footer">
<button class="btn-new-day" onclick="goToday()">
<svg class="icon-sm"><use href="#icon-sun"/></svg>
回到今天
</button>
</div>
</aside>
<!-- ═══ 右侧主内容区 ═══ -->
<main class="main-content">
<!-- ── 每日打卡面板 ── -->
<section class="panel active" id="panel-daily">
<div class="panel-header">
<h2>每日打卡</h2>
<input type="date" id="check-date" class="check-date-input" onchange="loadCheckin()">
</div>
<div class="daily-grid">
<div class="card card-morning">
<div class="card-head">
<svg class="icon-h2"><use href="#icon-sun"/></svg>
<h3>早间立志</h3>
</div>
<p class="card-desc">今天最重要的 1~3 件事</p>
<div id="morning-list"></div>
<button class="btn-add" onclick="addMorning()">
<svg class="icon-sm"><use href="#icon-plus"/></svg> 增加一条
</button>
</div>
<div class="card card-evening">
<div class="card-head">
<svg class="icon-h2"><use href="#icon-magnifying-glass"/></svg>
<h3>责善 · 改过</h3>
</div>
<p class="card-desc">今天犯的错 &amp; 改进方案最多5条</p>
<div id="evening-list"></div>
<button class="btn-add" onclick="addEvening()">
<svg class="icon-sm"><use href="#icon-plus"/></svg> 增加一条
</button>
</div>
<div class="card card-study">
<div class="card-head">
<svg class="icon-h2"><use href="#icon-book-open"/></svg>
<h3>勤学打卡</h3>
</div>
<p class="card-desc">今日修行清单</p>
<div id="study-list"></div>
</div>
</div>
</section>
<!-- ── 每周评分面板 ── -->
<section class="panel" id="panel-weekly">
<div class="panel-header">
<h2>每周评分</h2>
</div>
<div class="week-nav">
<button class="btn-nav" onclick="changeWeek(-1)">
<svg class="icon-nav"><use href="#icon-chevron-left"/></svg> 上周
</button>
<span id="week-label">--</span>
<button class="btn-nav" onclick="changeWeek(1)">
下周 <svg class="icon-nav"><use href="#icon-chevron-right"/></svg>
</button>
</div>
<div class="weekly-overview">
<div class="score-circle">
<svg viewBox="0 0 120 120" class="score-svg">
<circle cx="60" cy="60" r="50" fill="none" stroke="#E5E7EB" stroke-width="8"/>
<circle cx="60" cy="60" r="50" fill="none" stroke="#4A6CF7" stroke-width="8"
stroke-dasharray="314" stroke-dashoffset="314" stroke-linecap="round"
class="score-ring" transform="rotate(-90,60,60)"/>
</svg>
<div class="score-inner">
<span class="score-num" id="weekly-score">--</span>
<span class="score-unit"></span>
</div>
</div>
<div class="score-meta">
<span class="score-text" id="score-text">选择一周查看评分</span>
<div class="score-stats" id="score-stats"></div>
</div>
</div>
<div id="week-days-grid" class="week-days-grid"></div>
</section>
<!-- ── 历史记录面板 ── -->
<section class="panel" id="panel-history">
<div class="panel-header">
<h2>历史记录</h2>
</div>
<div id="history-grid" class="history-grid"></div>
</section>
</main>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

259
templates/login.html Normal file
View File

@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>紫微 · 登录</title>
<style>
:root {
--primary: #4A6CF7;
--primary-light: #EEF1FE;
--primary-dark: #3B5DE7;
--danger: #EF4444;
--bg: #F5F7FB;
--card: #FFFFFF;
--text: #1F2937;
--text-muted: #9CA3AF;
--border: #E5E7EB;
--radius: 12px;
--shadow: 0 4px 24px rgba(74,108,247,0.10);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif;
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: var(--card);
border-radius: 20px;
padding: 48px 40px 40px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow), 0 1px 3px rgba(0,0,0,0.06);
position: relative;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, var(--primary), #818CF8, var(--primary));
background-size: 200% 100%;
animation: shimmer 3s ease-in-out infinite;
}
@keyframes shimmer {
0%,100% { background-position:0% 50%; }
50% { background-position:100% 50%; }
}
.login-card .logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 4px;
}
.login-card .logo-row .icon-logo { width:28px; height:28px; color:var(--primary); }
.login-card h1 {
font-size: 26px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.5px;
}
.login-card .sub {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 36px;
line-height: 1.5;
}
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.form-group .input-wrap {
position: relative;
}
.form-group .input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--text-muted);
pointer-events: none;
}
.form-group input {
width: 100%;
padding: 12px 16px 12px 42px;
border: 1.5px solid var(--border);
border-radius: var(--radius);
font-size: 15px;
font-family: inherit;
background: var(--bg);
color: var(--text);
transition: all 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
background: var(--card);
box-shadow: 0 0 0 3px rgba(74,108,247,0.10);
}
.form-group input::placeholder { color:var(--text-muted); }
.remember-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 28px;
font-size: 13px;
color: var(--text-muted);
}
.remember-row input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: var(--primary);
flex-shrink: 0;
}
.btn-login {
width: 100%;
padding: 14px;
border: none;
background: var(--primary);
color: #FFF;
font-size: 16px;
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
letter-spacing: 0.5px;
}
.btn-login:hover {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(74,108,247,0.30);
}
.btn-login:active { transform:translateY(0); }
.btn-login.loading { opacity:0.7; pointer-events:none; }
.error-msg {
text-align: center;
color: var(--danger);
font-size: 13px;
margin-top: 16px;
padding: 10px 16px;
background: #FEF2F2;
border-radius: 8px;
display: none;
line-height: 1.5;
}
.error-msg.show { display:block; }
.footer-text {
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin-top: 28px;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
padding: 12px 28px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: all 0.3s;
white-space: nowrap;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
}
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); pointer-events:auto; }
.toast.success { background:#10B981; color:#FFF; }
.toast.error { background:var(--danger); color:#FFF; }
.toast.info { background:var(--primary); color:#FFF; }
</style>
</head>
<body>
{% include "icons.html" %}
<div id="toast" class="toast"></div>
<div class="login-card">
<div class="logo-row">
<svg class="icon-logo"><use href="#icon-bolt"/></svg>
<h1>紫微 · 磁场管理</h1>
</div>
<p class="sub">立志不摇,责善不滥,改过不拖,勤学不辍</p>
<form id="login-form" onsubmit="doLogin(event)">
<div class="form-group">
<label for="username">账号</label>
<div class="input-wrap">
<svg class="input-icon"><use href="#icon-user"/></svg>
<input type="text" id="username" placeholder="请输入账号" autocomplete="username" required autofocus>
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-wrap">
<svg class="input-icon"><use href="#icon-lock"/></svg>
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
</div>
<div class="remember-row">
<input type="checkbox" id="remember" checked>
<label for="remember">记住登录30 天有效)</label>
</div>
<button type="submit" class="btn-login" id="btn-login">登 录</button>
</form>
<div class="error-msg" id="error-msg"></div>
<p class="footer-text">紫微 · 天机阁磁场守护系统</p>
</div>
<script>
(function(){
'use strict';
window.showToast = function(msg, type){
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + (type||'info');
setTimeout(function(){ t.classList.add('show'); }, 10);
setTimeout(function(){ t.classList.remove('show'); }, 2500);
};
window.doLogin = function(e){
e.preventDefault();
var btn = document.getElementById('btn-login');
var err = document.getElementById('error-msg');
btn.classList.add('loading');
btn.textContent = '登录中…';
err.classList.remove('show');
var formData = new FormData();
formData.append('username', document.getElementById('username').value);
formData.append('password', document.getElementById('password').value);
if (document.getElementById('remember').checked) formData.append('remember','on');
fetch('/login', { method:'POST', body:formData })
.then(function(r){ return r.json().then(function(d){ return {status:r.status, body:d}; }); })
.then(function(res){
if (res.body.ok) { window.location.href = res.body.next; }
else { err.textContent = res.body.error||'登录失败,请重试'; err.classList.add('show'); }
})
.catch(function(){ err.textContent='网络错误,请检查连接后重试'; err.classList.add('show'); })
.finally(function(){ btn.classList.remove('loading'); btn.textContent='登 录'; });
};
})();
</script>
</body>
</html>