diff --git a/app.py b/app.py index 1473a6c..7150caa 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,7 @@ 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 +from database import init_db, get_checkin, save_checkin, delete_checkin, get_all_checkins, get_wishes, save_wish, update_wish, delete_wish, reorder_wishes app = Flask(__name__) # 固定密钥确保 gunicorn 多 worker 下 session 可互通 @@ -115,9 +115,11 @@ def compute_stats(): def index(): import json stats = compute_stats() + wishes = [dict(w) for w in get_wishes()] return render_template('index.html', username=session.get('display_name', session.get('username', '')), - initial_stats=json.dumps(stats, ensure_ascii=False)) + initial_stats=json.dumps(stats, ensure_ascii=False), + initial_wishes=json.dumps(wishes, ensure_ascii=False)) # ── API ────────────────────────────────────────────── @@ -175,6 +177,54 @@ def api_user(): }) +# ── 心愿清单 API ────────────────────────────── + +@app.route('/api/wishes', methods=['GET']) +@login_required +def api_get_wishes(): + wishes = [dict(w) for w in get_wishes()] + return jsonify({'ok': True, 'data': wishes}) + + +@app.route('/api/wishes', methods=['POST']) +@login_required +def api_create_wish(): + body = request.get_json(force=True) + name = body.get('name', '').strip() + if not name: + return jsonify({'ok': False, 'error': '名称不能为空'}), 400 + priority = body.get('priority', '中') + deadline = body.get('deadline', '') + wid = save_wish(name, priority, deadline) + return jsonify({'ok': True, 'id': wid}) + + +@app.route('/api/wishes/', methods=['PUT']) +@login_required +def api_update_wish(wish_id): + body = request.get_json(force=True) + update_wish(wish_id, **body) + return jsonify({'ok': True}) + + +@app.route('/api/wishes/', methods=['DELETE']) +@login_required +def api_delete_wish(wish_id): + delete_wish(wish_id) + return jsonify({'ok': True}) + + +@app.route('/api/wishes/reorder', methods=['PUT']) +@login_required +def api_reorder_wishes(): + body = request.get_json(force=True) + order = body.get('order', []) + if not isinstance(order, list): + return jsonify({'ok': False, 'error': 'order 必须是列表'}), 400 + reorder_wishes(order) + return jsonify({'ok': True}) + + # ── 启动 ────────────────────────────────────────────── if __name__ == '__main__': diff --git a/database.py b/database.py index 9767818..7cbe37f 100644 --- a/database.py +++ b/database.py @@ -11,7 +11,7 @@ os.makedirs(DB_DIR, exist_ok=True) DB_PATH = os.path.join(DB_DIR, 'ziwei_power.db') # 当前数据库 schema 版本 —— 改表结构时必须 +1 并补迁移逻辑 -CURRENT_SCHEMA_VERSION = 1 +CURRENT_SCHEMA_VERSION = 2 def get_conn(): @@ -55,6 +55,20 @@ def init_db(): ) ''') + if current < 2: + # v2: 心愿清单 + conn.execute(''' + CREATE TABLE IF NOT EXISTS wishes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT '中', + deadline TEXT NOT NULL DEFAULT '', + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + ''') + # ── 将来加字段/改表在此扩展 ── # if current < 2: # conn.execute('ALTER TABLE checkins ADD COLUMN tags TEXT DEFAULT ""') @@ -126,3 +140,59 @@ def get_all_checkins(): 'updated_at': row['updated_at'] }) return results + + +# ── 心愿清单 CRUD ────────────────────────────────── + +def get_wishes(): + """获取所有心愿,按 sort_order 排序""" + conn = get_conn() + rows = conn.execute('SELECT * FROM wishes ORDER BY sort_order').fetchall() + conn.close() + return [dict(row) for row in rows] + + +def save_wish(name, priority, deadline): + """新增一条心愿""" + now = datetime.now().isoformat() + conn = get_conn() + max_order = conn.execute('SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM wishes').fetchone()['n'] + conn.execute( + 'INSERT INTO wishes (name, priority, deadline, done, sort_order, created_at) VALUES (?, ?, ?, 0, ?, ?)', + (name, priority, deadline, max_order, now) + ) + conn.commit() + wish_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0] + conn.close() + return wish_id + + +def update_wish(wish_id, **kwargs): + """更新心愿字段""" + allowed = ['name', 'priority', 'deadline', 'done'] + updates = {k: v for k, v in kwargs.items() if k in allowed} + if not updates: + return + conn = get_conn() + sets = ', '.join(f'{k} = ?' for k in updates) + vals = list(updates.values()) + [wish_id] + conn.execute(f'UPDATE wishes SET {sets} WHERE id = ?', vals) + conn.commit() + conn.close() + + +def delete_wish(wish_id): + """删除心愿""" + conn = get_conn() + conn.execute('DELETE FROM wishes WHERE id = ?', (wish_id,)) + conn.commit() + conn.close() + + +def reorder_wishes(order_list): + """批量更新排序:order_list = [id1, id2, ...]""" + conn = get_conn() + for idx, wid in enumerate(order_list): + conn.execute('UPDATE wishes SET sort_order = ? WHERE id = ?', (idx, wid)) + conn.commit() + conn.close() diff --git a/static/app.js b/static/app.js index ee9e7ec..3be54c6 100644 --- a/static/app.js +++ b/static/app.js @@ -634,6 +634,164 @@ }); }; + /* ================================================================ + Wishes — 心愿清单 + ================================================================ */ + var wishes = []; + var dragSourceId = null; + + function loadWishes() { + // 优先使用页面嵌入数据 + if (window.__INITIAL_WISHES__) { + wishes = window.__INITIAL_WISHES__; + renderWishes(); + return; + } + fetch('/api/wishes') + .then(function(r){ return r.json(); }) + .then(function(res){ if (res.ok) { wishes = res.data; renderWishes(); } }); + } + + function renderWishes() { + var list = document.getElementById('wishes-list'); + var empty = document.getElementById('wishes-empty'); + if (!list) return; + if (wishes.length === 0) { + list.innerHTML = ''; + if (empty) empty.style.display = 'block'; + return; + } + if (empty) empty.style.display = 'none'; + var html = ''; + for (var i = 0; i < wishes.length; i++) { + var w = wishes[i]; + var doneCls = w.done ? ' done' : ''; + var priCls = 'pri-' + (w.priority || '中'); + var deadline = w.deadline ? w.deadline : ''; + html += '
' + + '' + + '' + + '' + esc(w.name) + '' + + '' + esc(w.priority) + '' + + (deadline ? '' + esc(deadline) + '' : '') + + '' + + '
'; + } + list.innerHTML = html; + bindDragEvents(); + } + + /* ── Drag & Drop ── */ + + function bindDragEvents() { + var items = document.querySelectorAll('#wishes-list .wish-item'); + items.forEach(function(item) { + item.addEventListener('dragstart', function(e) { + dragSourceId = parseInt(this.dataset.id); + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + item.addEventListener('dragend', function(e) { + this.classList.remove('dragging'); + dragSourceId = null; + // 移除所有 over 状态 + document.querySelectorAll('#wishes-list .wish-item').forEach(function(el){ el.classList.remove('drag-over'); }); + }); + item.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + this.classList.add('drag-over'); + }); + item.addEventListener('dragleave', function() { + this.classList.remove('drag-over'); + }); + item.addEventListener('drop', function(e) { + e.preventDefault(); + this.classList.remove('drag-over'); + var targetId = parseInt(this.dataset.id); + if (dragSourceId === targetId) return; + // 更新本地顺序 + var fromIdx = -1, toIdx = -1; + for (var j = 0; j < wishes.length; j++) { + if (wishes[j].id === dragSourceId) fromIdx = j; + if (wishes[j].id === targetId) toIdx = j; + } + if (fromIdx >= 0 && toIdx >= 0) { + var item = wishes.splice(fromIdx, 1)[0]; + wishes.splice(toIdx, 0, item); + renderWishes(); + saveWishOrder(); + } + }); + }); + } + + function saveWishOrder() { + var order = wishes.map(function(w){ return w.id; }); + fetch('/api/wishes/reorder', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({order: order}) + }); + } + + /* ── CRUD ── */ + + window.showWishForm = function() { + var form = document.getElementById('wish-form'); + if (form) form.style.display = 'block'; + }; + + window.hideWishForm = function() { + var form = document.getElementById('wish-form'); + if (form) form.style.display = 'none'; + document.getElementById('wish-name').value = ''; + document.getElementById('wish-deadline').value = ''; + document.getElementById('wish-priority').value = '中'; + }; + + window.addWish = function() { + var name = document.getElementById('wish-name').value.trim(); + if (!name) { showToast('请输入心愿名称', 'error'); return; } + var priority = document.getElementById('wish-priority').value; + var deadline = document.getElementById('wish-deadline').value; + fetch('/api/wishes', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: name, priority: priority, deadline: deadline}) + }).then(function(r){ return r.json(); }) + .then(function(res){ + if (!res.ok) { showToast(res.error, 'error'); return; } + hideWishForm(); + wishes.push({id: res.id, name: name, priority: priority, deadline: deadline, done: 0}); + renderWishes(); + }); + }; + + window.toggleWish = function(id, done) { + fetch('/api/wishes/' + id, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({done: done ? 1 : 0}) + }); + var w = wishes.find(function(x){ return x.id === id; }); + if (w) w.done = done ? 1 : 0; + renderWishes(); + }; + + window.deleteWishItem = function(id) { + fetch('/api/wishes/' + id, {method: 'DELETE'}); + wishes = wishes.filter(function(w){ return w.id !== id; }); + renderWishes(); + }; + + window.toggleWishesEdit = function(btn) { + var panel = document.getElementById('wishes-panel'); + if (!panel) return; + var editing = panel.classList.toggle('editing'); + btn.classList.toggle('active', editing); + }; + /* ================================================================ Init ================================================================ */ @@ -649,6 +807,7 @@ selectedDate = todayStr; initStudyPresets(); bindAutoSave(); + loadWishes(); lastSavedDate = todayStr; // 从页面嵌入数据获取初始统计(0 延迟) diff --git a/static/style.css b/static/style.css index b1a00a3..f9166f7 100644 --- a/static/style.css +++ b/static/style.css @@ -1080,6 +1080,180 @@ body { .toast.error { background: var(--danger); color: #FFF; } .toast.info { background: var(--primary); color: #FFF; } +/* ═══════════════════════════════════════════ + Wishes Panel + ═══════════════════════════════════════════ */ + +.wishes-panel { + padding: 0 16px 10px; +} +.wishes-panel.editing .edit-only { display: flex; } +.wishes-panel.editing .wish-del { display: inline-flex; } + +.wishes-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; +} + +.wishes-add-btn { + display: none; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + background: var(--primary-light); + color: var(--primary); + border-radius: 6px; + cursor: pointer; + margin-left: auto; + transition: background 0.2s; +} +.wishes-add-btn:hover { background: #DDE3FD; } +.wishes-panel.editing .wishes-add-btn { display: flex; } + +.wish-form { + background: var(--bg); + border-radius: var(--radius-sm); + padding: 8px; + margin-bottom: 8px; +} +.wish-form input[type="text"], +.wish-form input[type="date"], +.wish-form select { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + font-family: inherit; + color: var(--text); + margin-bottom: 6px; + box-sizing: border-box; +} +.wish-form-row { + display: flex; + gap: 6px; +} +.wish-form-row select, +.wish-form-row input { flex: 1; } +.wish-form-actions { + display: flex; + gap: 6px; +} +.btn-wish-save { + flex: 1; + padding: 6px; + border: none; + background: var(--primary); + color: #FFF; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + font-family: inherit; +} +.btn-wish-save:hover { background: var(--primary-dark); } +.btn-wish-cancel { + flex: 1; + padding: 6px; + border: 1px solid var(--border); + background: var(--card); + color: var(--text-dim); + border-radius: 6px; + font-size: 12px; + cursor: pointer; + font-family: inherit; +} + +.wishes-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow-y: auto; +} + +.wishes-empty { + font-size: 11px; + color: var(--text-muted); + text-align: center; + padding: 12px 0; +} + +.wish-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + border-radius: 6px; + transition: background 0.15s; + cursor: default; +} +.wish-item:hover { background: var(--bg); } +.wish-item.dragging { opacity: 0.4; } +.wish-item.drag-over { background: var(--primary-light); box-shadow: inset 0 0 0 1.5px var(--primary); } + +.wish-drag-handle { + color: var(--text-muted); + cursor: grab; + flex-shrink: 0; + display: flex; + align-items: center; +} +.wish-drag-handle:active { cursor: grabbing; } + +.wish-check { + width: 14px; + height: 14px; + accent-color: var(--success); + flex-shrink: 0; + cursor: pointer; +} + +.wish-name { + flex: 1; + font-size: 12px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.wish-item.done .wish-name { + text-decoration: line-through; + color: var(--text-muted); +} + +.wish-pri { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + flex-shrink: 0; +} +.wish-pri.pri-高 { background: var(--danger-light); color: var(--danger); } +.wish-pri.pri-中 { background: var(--warning-light); color: #D97706; } +.wish-pri.pri-低 { background: var(--bg); color: var(--text-muted); } + +.wish-deadline { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.wish-del { + display: none; +} +.wish-del .icon-xs { width: 12px; height: 12px; } + /* ═══════════════════════════════════════════ SVG icons helpers ═══════════════════════════════════════════ */ diff --git a/templates/icons.html b/templates/icons.html index a79d318..0956759 100644 --- a/templates/icons.html +++ b/templates/icons.html @@ -67,4 +67,8 @@ + + + + diff --git a/templates/index.html b/templates/index.html index e18a9c3..96bce94 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,7 @@ 紫微 · 磁场管理 + {% include "icons.html" %} @@ -67,6 +68,39 @@ + +
+
+ + 心愿清单 + + +
+ + + +
+
暂无心愿,点击 + 添加
+
+