feat: 添加公告功能
This commit is contained in:
105
app.py
105
app.py
@@ -1720,6 +1720,48 @@ def logout():
|
|||||||
return jsonify({"success": True})
|
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/<int:announcement_id>/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 ====================
|
# ==================== 管理员认证API ====================
|
||||||
|
|
||||||
@app.route('/yuyx/api/debug-config', methods=['GET'])
|
@app.route('/yuyx/api/debug-config', methods=['GET'])
|
||||||
@@ -1812,6 +1854,69 @@ def admin_logout():
|
|||||||
return jsonify({"success": True})
|
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/<int:announcement_id>/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/<int:announcement_id>/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/<int:announcement_id>', 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'])
|
@app.route('/yuyx/api/users', methods=['GET'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_all_users():
|
def get_all_users():
|
||||||
|
|||||||
184
database.py
184
database.py
@@ -54,7 +54,7 @@ config = get_config()
|
|||||||
DB_FILE = config.DB_FILE
|
DB_FILE = config.DB_FILE
|
||||||
|
|
||||||
# 数据库版本 (用于迁移管理)
|
# 数据库版本 (用于迁移管理)
|
||||||
DB_VERSION = 5
|
DB_VERSION = 6
|
||||||
|
|
||||||
# ==================== 时区处理工具函数 ====================
|
# ==================== 时区处理工具函数 ====================
|
||||||
# Bug fix: 统一时区处理,避免混用导致的问题
|
# Bug fix: 统一时区处理,避免混用导致的问题
|
||||||
@@ -219,6 +219,30 @@ def init_database():
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
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('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS user_schedules (
|
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_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_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_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_user_id ON user_schedules(user_id)')
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)')
|
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)
|
_migrate_to_v5(conn)
|
||||||
current_version = 5
|
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',
|
cursor.execute('UPDATE db_version SET version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1',
|
||||||
(DB_VERSION,))
|
(DB_VERSION,))
|
||||||
@@ -514,6 +547,46 @@ def _migrate_to_v5(conn):
|
|||||||
conn.commit()
|
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():
|
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}
|
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):
|
def get_user_schedules(user_id):
|
||||||
|
|||||||
@@ -264,6 +264,18 @@
|
|||||||
font-size: 14px;
|
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 {
|
.empty-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 15px;
|
padding: 30px 15px;
|
||||||
@@ -732,6 +744,7 @@
|
|||||||
<button class="tab" onclick="switchTab('feedbacks')">反馈管理 <span id="feedbackBadge" style="background:#e74c3c; color:white; padding:2px 6px; border-radius:10px; font-size:11px; margin-left:3px; display:none;">0</span></button>
|
<button class="tab" onclick="switchTab('feedbacks')">反馈管理 <span id="feedbackBadge" style="background:#e74c3c; color:white; padding:2px 6px; border-radius:10px; font-size:11px; margin-left:3px; display:none;">0</span></button>
|
||||||
<button class="tab" onclick="switchTab('stats')">统计</button>
|
<button class="tab" onclick="switchTab('stats')">统计</button>
|
||||||
<button class="tab" onclick="switchTab('logs')">任务日志</button>
|
<button class="tab" onclick="switchTab('logs')">任务日志</button>
|
||||||
|
<button class="tab" onclick="switchTab('announcements')">公告管理</button>
|
||||||
<button class="tab" onclick="switchTab('email')">邮件配置</button>
|
<button class="tab" onclick="switchTab('email')">邮件配置</button>
|
||||||
<button class="tab" onclick="switchTab('system')">系统配置</button>
|
<button class="tab" onclick="switchTab('system')">系统配置</button>
|
||||||
<button class="tab" onclick="switchTab('settings')">设置</button>
|
<button class="tab" onclick="switchTab('settings')">设置</button>
|
||||||
@@ -1200,6 +1213,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 公告管理 -->
|
||||||
|
<div id="tab-announcements" class="tab-content">
|
||||||
|
<h3 style="margin-bottom: 15px; font-size: 16px;">公告管理</h3>
|
||||||
|
|
||||||
|
<!-- 创建公告 -->
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>公告标题</label>
|
||||||
|
<input type="text" id="announcementTitle" placeholder="请输入公告标题">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>公告内容</label>
|
||||||
|
<textarea id="announcementContent" rows="5" placeholder="请输入公告内容(将以弹窗形式展示)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="btn btn-primary" onclick="createAnnouncement(true)">发布并启用</button>
|
||||||
|
<button class="btn btn-secondary" onclick="createAnnouncement(false)">保存但不启用</button>
|
||||||
|
<button class="btn" onclick="clearAnnouncementForm()" style="background: #eee;">清空</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #666; margin-top: 10px;">
|
||||||
|
说明:启用公告后,用户登录进入系统将弹窗提示;用户可选择“当次关闭”或“永久关闭本次公告”。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 公告列表 -->
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; flex-wrap: wrap; gap: 10px;">
|
||||||
|
<h4 style="font-size: 14px; margin: 0;">公告列表</h4>
|
||||||
|
<button class="btn btn-primary" onclick="loadAnnouncements()" style="padding:8px 15px;">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div id="announcementsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 邮件配置 -->
|
<!-- 邮件配置 -->
|
||||||
<div id="tab-email" class="tab-content">
|
<div id="tab-email" class="tab-content">
|
||||||
<h3 style="margin-bottom: 15px; font-size: 16px;">邮件功能设置</h3>
|
<h3 style="margin-bottom: 15px; font-size: 16px;">邮件功能设置</h3>
|
||||||
@@ -1455,12 +1500,23 @@
|
|||||||
<script>
|
<script>
|
||||||
let allUsers = [];
|
let allUsers = [];
|
||||||
let pendingUsers = [];
|
let pendingUsers = [];
|
||||||
|
let announcements = [];
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
// 页面加载时初始化
|
// 页面加载时初始化
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
loadStats();
|
loadStats();
|
||||||
loadPendingUsers();
|
loadPendingUsers();
|
||||||
loadAllUsers();
|
loadAllUsers();
|
||||||
|
loadAnnouncements();
|
||||||
loadSystemConfig();
|
loadSystemConfig();
|
||||||
loadProxyConfig();
|
loadProxyConfig();
|
||||||
loadPasswordResets(); // 修复: 初始化时也加载密码重置申请
|
loadPasswordResets(); // 修复: 初始化时也加载密码重置申请
|
||||||
@@ -1506,6 +1562,11 @@
|
|||||||
loadFeedbacks();
|
loadFeedbacks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换到公告管理标签时加载公告
|
||||||
|
if (tabName === 'announcements') {
|
||||||
|
loadAnnouncements();
|
||||||
|
}
|
||||||
|
|
||||||
// 切换到邮件配置标签时加载邮件相关数据
|
// 切换到邮件配置标签时加载邮件相关数据
|
||||||
if (tabName === 'email') {
|
if (tabName === 'email') {
|
||||||
loadEmailSettings();
|
loadEmailSettings();
|
||||||
@@ -1515,6 +1576,160 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 公告管理 ====================
|
||||||
|
|
||||||
|
async function loadAnnouncements() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/yuyx/api/announcements');
|
||||||
|
if (!response.ok) {
|
||||||
|
showNotification('加载公告失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announcements = await response.json();
|
||||||
|
renderAnnouncements();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('加载公告失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnnouncements() {
|
||||||
|
const container = document.getElementById('announcementsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!announcements || announcements.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-message">暂无公告</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70px;">ID</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th style="width: 90px;">状态</th>
|
||||||
|
<th style="width: 170px;">创建时间</th>
|
||||||
|
<th style="width: 220px;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${announcements.map(a => `
|
||||||
|
<tr>
|
||||||
|
<td>${a.id}</td>
|
||||||
|
<td>${escapeHtml(a.title || '')}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge ${a.is_active ? 'status-approved' : 'status-rejected'}">
|
||||||
|
${a.is_active ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${a.created_at || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-small btn-secondary" onclick="viewAnnouncement(${a.id})">查看</button>
|
||||||
|
${a.is_active
|
||||||
|
? `<button class="btn btn-small btn-secondary" onclick="deactivateAnnouncement(${a.id})">停用</button>`
|
||||||
|
: `<button class="btn btn-small btn-success" onclick="activateAnnouncement(${a.id})">启用</button>`
|
||||||
|
}
|
||||||
|
<button class="btn btn-small btn-danger" onclick="deleteAnnouncement(${a.id})">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAnnouncementForm() {
|
||||||
|
const title = document.getElementById('announcementTitle');
|
||||||
|
const content = document.getElementById('announcementContent');
|
||||||
|
if (title) title.value = '';
|
||||||
|
if (content) content.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewAnnouncement(id) {
|
||||||
|
const announcement = announcements.find(a => a.id === id);
|
||||||
|
if (!announcement) return;
|
||||||
|
alert(`标题:${announcement.title || ''}\n\n内容:\n${announcement.content || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAnnouncement(isActive) {
|
||||||
|
const title = (document.getElementById('announcementTitle')?.value || '').trim();
|
||||||
|
const content = (document.getElementById('announcementContent')?.value || '').trim();
|
||||||
|
if (!title || !content) {
|
||||||
|
showNotification('标题和内容不能为空', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/yuyx/api/announcements', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, content, is_active: !!isActive })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
showNotification(data.error || '发布失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showNotification('保存成功', 'success');
|
||||||
|
clearAnnouncementForm();
|
||||||
|
await loadAnnouncements();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('发布失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateAnnouncement(id) {
|
||||||
|
if (!confirm('确定启用该公告吗?启用后将自动停用其他公告。')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/yuyx/api/announcements/${id}/activate`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
showNotification(data.error || '启用失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showNotification('已启用', 'success');
|
||||||
|
await loadAnnouncements();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('启用失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivateAnnouncement(id) {
|
||||||
|
if (!confirm('确定停用该公告吗?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/yuyx/api/announcements/${id}/deactivate`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
showNotification(data.error || '停用失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showNotification('已停用', 'success');
|
||||||
|
await loadAnnouncements();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('停用失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAnnouncement(id) {
|
||||||
|
if (!confirm('确定删除该公告吗?删除后无法恢复。')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/yuyx/api/announcements/${id}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
showNotification(data.error || '删除失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showNotification('已删除', 'success');
|
||||||
|
await loadAnnouncements();
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('删除失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VIP functions
|
// VIP functions
|
||||||
function isVip(user) {
|
function isVip(user) {
|
||||||
if (!user.vip_expire_time) return false;
|
if (!user.vip_expire_time) return false;
|
||||||
@@ -3250,4 +3465,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -621,6 +621,22 @@
|
|||||||
<button class="fab" onclick="openAddAccountModal()" title="添加账号"><span class="fab-icon">+</span><span class="fab-text">添加账号</span></button>
|
<button class="fab" onclick="openAddAccountModal()" title="添加账号"><span class="fab-icon">+</span><span class="fab-text">添加账号</span></button>
|
||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<!-- 公告弹窗 -->
|
||||||
|
<div class="modal-overlay" id="announcementModal" onclick="if(event.target===this)closeAnnouncementOnce()">
|
||||||
|
<div class="modal" style="max-width: 560px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title" id="announcementModalTitle">系统公告</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="announcementModalContent" style="white-space: pre-wrap;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-text" onclick="closeAnnouncementOnce()">当次关闭</button>
|
||||||
|
<button class="btn btn-primary" onclick="dismissAnnouncementPermanently()">永久关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 添加账号弹窗 -->
|
<!-- 添加账号弹窗 -->
|
||||||
<div class="modal-overlay" id="addAccountModal">
|
<div class="modal-overlay" id="addAccountModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -1040,10 +1056,63 @@
|
|||||||
updateAccountLimitDisplay();
|
updateAccountLimitDisplay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentAnnouncementId = null;
|
||||||
|
|
||||||
|
async function checkAnnouncement() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/announcements/active');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
const announcement = data?.announcement;
|
||||||
|
if (!announcement || !announcement.id) return;
|
||||||
|
|
||||||
|
const sessionKey = `announcement_closed_${announcement.id}`;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(sessionKey)) return;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAnnouncementId = announcement.id;
|
||||||
|
const titleEl = document.getElementById('announcementModalTitle');
|
||||||
|
const contentEl = document.getElementById('announcementModalContent');
|
||||||
|
if (titleEl) titleEl.textContent = announcement.title || '系统公告';
|
||||||
|
if (contentEl) contentEl.textContent = announcement.content || '';
|
||||||
|
openModal('announcementModal');
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAnnouncementOnce() {
|
||||||
|
if (currentAnnouncementId) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`announcement_closed_${currentAnnouncementId}`, '1');
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeModal('announcementModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissAnnouncementPermanently() {
|
||||||
|
if (!currentAnnouncementId) {
|
||||||
|
closeModal('announcementModal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetch(`/api/announcements/${currentAnnouncementId}/dismiss`, { method: 'POST' });
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
closeAnnouncementOnce();
|
||||||
|
}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initTabs();
|
initTabs();
|
||||||
loadVipStatus();
|
loadVipStatus();
|
||||||
loadAccounts(); // 主动加载账号列表
|
loadAccounts(); // 主动加载账号列表
|
||||||
|
checkAnnouncement();
|
||||||
loadStats();
|
loadStats();
|
||||||
loadSchedules();
|
loadSchedules();
|
||||||
loadScreenshots();
|
loadScreenshots();
|
||||||
@@ -2086,6 +2155,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
try {
|
||||||
|
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
if (key && key.startsWith('announcement_closed_')) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
fetch('/api/logout', {method: 'POST'})
|
fetch('/api/logout', {method: 'POST'})
|
||||||
.then(() => { window.location.href = '/login'; })
|
.then(() => { window.location.href = '/login'; })
|
||||||
.catch(() => { window.location.href = '/login'; });
|
.catch(() => { window.location.href = '/login'; });
|
||||||
@@ -2298,4 +2377,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user