From 7015de0055d7f65f5a3499432fbc8c198d1455ae Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Sat, 13 Dec 2025 18:40:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 105 +++++++++++++++++++++ database.py | 184 +++++++++++++++++++++++++++++++++++- templates/admin.html | 217 ++++++++++++++++++++++++++++++++++++++++++- templates/index.html | 81 +++++++++++++++- 4 files changed, 584 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index aa3daa5..0d55555 100755 --- a/app.py +++ b/app.py @@ -1720,6 +1720,48 @@ def logout(): return jsonify({"success": True}) +# ==================== 公告API ==================== + +@app.route('/api/announcements/active', methods=['GET']) +@login_required +def get_active_announcement(): + """获取当前用户应展示的公告(若无则返回announcement=null)""" + try: + user_id = int(current_user.id) + except Exception: + return jsonify({"announcement": None}) + + announcement = database.get_active_announcement_for_user(user_id) + if not announcement: + return jsonify({"announcement": None}) + + return jsonify({ + "announcement": { + "id": announcement.get("id"), + "title": announcement.get("title", ""), + "content": announcement.get("content", ""), + "created_at": announcement.get("created_at") + } + }) + + +@app.route('/api/announcements//dismiss', methods=['POST']) +@login_required +def dismiss_announcement(announcement_id): + """用户永久关闭某条公告(本次公告不再弹窗)""" + try: + user_id = int(current_user.id) + except Exception: + return jsonify({"error": "请先登录"}), 401 + + announcement = database.get_announcement_by_id(announcement_id) + if not announcement: + return jsonify({"error": "公告不存在"}), 404 + + database.dismiss_announcement_for_user(user_id, announcement_id) + return jsonify({"success": True}) + + # ==================== 管理员认证API ==================== @app.route('/yuyx/api/debug-config', methods=['GET']) @@ -1812,6 +1854,69 @@ def admin_logout(): return jsonify({"success": True}) +# ==================== 公告管理API(管理员) ==================== + +@app.route('/yuyx/api/announcements', methods=['GET']) +@admin_required +def admin_get_announcements(): + """获取公告列表""" + try: + limit = int(request.args.get('limit', 50)) + offset = int(request.args.get('offset', 0)) + except (TypeError, ValueError): + limit, offset = 50, 0 + + limit = max(1, min(200, limit)) + offset = max(0, offset) + return jsonify(database.get_announcements(limit=limit, offset=offset)) + + +@app.route('/yuyx/api/announcements', methods=['POST']) +@admin_required +def admin_create_announcement(): + """创建公告(默认启用并替换旧公告)""" + data = request.json or {} + title = (data.get('title') or '').strip() + content = (data.get('content') or '').strip() + is_active = bool(data.get('is_active', True)) + + announcement_id = database.create_announcement(title, content, is_active=is_active) + if not announcement_id: + return jsonify({"error": "标题和内容不能为空"}), 400 + + return jsonify({"success": True, "id": announcement_id}) + + +@app.route('/yuyx/api/announcements//activate', methods=['POST']) +@admin_required +def admin_activate_announcement(announcement_id): + """启用公告(会自动停用其他公告)""" + if not database.get_announcement_by_id(announcement_id): + return jsonify({"error": "公告不存在"}), 404 + ok = database.set_announcement_active(announcement_id, True) + return jsonify({"success": ok}) + + +@app.route('/yuyx/api/announcements//deactivate', methods=['POST']) +@admin_required +def admin_deactivate_announcement(announcement_id): + """停用公告""" + if not database.get_announcement_by_id(announcement_id): + return jsonify({"error": "公告不存在"}), 404 + ok = database.set_announcement_active(announcement_id, False) + return jsonify({"success": ok}) + + +@app.route('/yuyx/api/announcements/', methods=['DELETE']) +@admin_required +def admin_delete_announcement(announcement_id): + """删除公告""" + if not database.get_announcement_by_id(announcement_id): + return jsonify({"error": "公告不存在"}), 404 + ok = database.delete_announcement(announcement_id) + return jsonify({"success": ok}) + + @app.route('/yuyx/api/users', methods=['GET']) @admin_required def get_all_users(): diff --git a/database.py b/database.py index 1b38dbe..c818bd6 100755 --- a/database.py +++ b/database.py @@ -54,7 +54,7 @@ config = get_config() DB_FILE = config.DB_FILE # 数据库版本 (用于迁移管理) -DB_VERSION = 5 +DB_VERSION = 6 # ==================== 时区处理工具函数 ==================== # Bug fix: 统一时区处理,避免混用导致的问题 @@ -219,6 +219,30 @@ def init_database(): FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ) ''') + + # 公告表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 公告永久关闭记录表(用户维度) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS announcement_dismissals ( + user_id INTEGER NOT NULL, + announcement_id INTEGER NOT NULL, + dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, announcement_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE + ) + ''') # 用户定时任务表 cursor.execute(''' CREATE TABLE IF NOT EXISTS user_schedules ( @@ -263,6 +287,11 @@ def init_database(): cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)') + + # 公告表索引 + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)') # 用户定时任务表索引 cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)') @@ -346,6 +375,10 @@ def migrate_database(): _migrate_to_v5(conn) current_version = 5 + if current_version < 6: + _migrate_to_v6(conn) + current_version = 6 + # 更新版本号 cursor.execute('UPDATE db_version SET version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1', (DB_VERSION,)) @@ -514,6 +547,46 @@ def _migrate_to_v5(conn): conn.commit() +def _migrate_to_v6(conn): + """迁移到版本6 - 添加公告功能相关表""" + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcements'") + if not cursor.fetchone(): + cursor.execute(''' + CREATE TABLE IF NOT EXISTS announcements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + print(" ✓ 创建 announcements 表 (公告)") + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)') + print(" ✓ 创建 announcements 表索引") + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement_dismissals'") + if not cursor.fetchone(): + cursor.execute(''' + CREATE TABLE IF NOT EXISTS announcement_dismissals ( + user_id INTEGER NOT NULL, + announcement_id INTEGER NOT NULL, + dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, announcement_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE + ) + ''') + print(" ✓ 创建 announcement_dismissals 表 (公告永久关闭记录)") + cursor.execute('CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)') + print(" ✓ 创建 announcement_dismissals 表索引") + + conn.commit() + + # ==================== 管理员相关 ==================== def ensure_default_admin(): @@ -1713,6 +1786,115 @@ def get_feedback_stats(): return dict(row) if row else {'total': 0, 'pending': 0, 'replied': 0, 'closed': 0} +# ==================== 公告管理 ==================== + +def create_announcement(title, content, is_active=True): + """创建公告(默认启用;启用时会自动停用其他公告)""" + title = (title or '').strip() + content = (content or '').strip() + if not title or not content: + return None + + with db_pool.get_db() as conn: + cursor = conn.cursor() + cst_time = get_cst_now_str() + + if is_active: + cursor.execute('UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1', (cst_time,)) + + cursor.execute(''' + INSERT INTO announcements (title, content, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ''', (title, content, 1 if is_active else 0, cst_time, cst_time)) + conn.commit() + return cursor.lastrowid + + +def get_announcement_by_id(announcement_id): + """根据ID获取公告""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM announcements WHERE id = ?', (announcement_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def get_announcements(limit=50, offset=0): + """获取公告列表(管理员用)""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM announcements + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ? + ''', (limit, offset)) + return [dict(row) for row in cursor.fetchall()] + + +def set_announcement_active(announcement_id, is_active): + """启用/停用公告;启用时会自动停用其他公告""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cst_time = get_cst_now_str() + + if is_active: + cursor.execute('UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1', (cst_time,)) + cursor.execute(''' + UPDATE announcements + SET is_active = 1, updated_at = ? + WHERE id = ? + ''', (cst_time, announcement_id)) + else: + cursor.execute(''' + UPDATE announcements + SET is_active = 0, updated_at = ? + WHERE id = ? + ''', (cst_time, announcement_id)) + + conn.commit() + return cursor.rowcount > 0 + + +def delete_announcement(announcement_id): + """删除公告(同时清理用户关闭记录)""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM announcement_dismissals WHERE announcement_id = ?', (announcement_id,)) + cursor.execute('DELETE FROM announcements WHERE id = ?', (announcement_id,)) + conn.commit() + return cursor.rowcount > 0 + + +def get_active_announcement_for_user(user_id): + """获取当前用户应展示的启用公告(已永久关闭的不再返回)""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT a.* + FROM announcements a + LEFT JOIN announcement_dismissals d + ON d.announcement_id = a.id AND d.user_id = ? + WHERE a.is_active = 1 AND d.announcement_id IS NULL + ORDER BY a.created_at DESC, a.id DESC + LIMIT 1 + ''', (user_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + +def dismiss_announcement_for_user(user_id, announcement_id): + """用户永久关闭某条公告(幂等)""" + with db_pool.get_db() as conn: + cursor = conn.cursor() + cst_time = get_cst_now_str() + cursor.execute(''' + INSERT OR IGNORE INTO announcement_dismissals (user_id, announcement_id, dismissed_at) + VALUES (?, ?, ?) + ''', (user_id, announcement_id, cst_time)) + conn.commit() + return cursor.rowcount >= 0 + + # ==================== 用户定时任务管理 ==================== def get_user_schedules(user_id): diff --git a/templates/admin.html b/templates/admin.html index 90d8ac4..9a44784 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -264,6 +264,18 @@ font-size: 14px; } + .form-group textarea { + width: 100%; + max-width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + font-size: 14px; + font-family: inherit; + resize: vertical; + min-height: 120px; + } + .empty-message { text-align: center; padding: 30px 15px; @@ -732,6 +744,7 @@ + @@ -1200,6 +1213,38 @@ + +
+

公告管理

+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ 说明:启用公告后,用户登录进入系统将弹窗提示;用户可选择“当次关闭”或“永久关闭本次公告”。 +
+
+ + +
+

公告列表

+ +
+
+
+

邮件功能设置

@@ -1455,12 +1500,23 @@ - \ No newline at end of file + diff --git a/templates/index.html b/templates/index.html index 82ac828..7a22a76 100644 --- a/templates/index.html +++ b/templates/index.html @@ -621,6 +621,22 @@
+ + +