Files
zlqy/templates/forum.html
2026-02-27 14:48:37 +08:00

959 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}社区论坛 - 智联青云{% endblock %}
{% block content %}
<style>
.forum-grid{display:grid;grid-template-columns:1fr 280px;gap:20px}
@media(max-width:1024px){.forum-grid{grid-template-columns:1fr}}
.toast{position:fixed;top:20px;right:20px;z-index:9999;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;animation:slideIn .3s ease;box-shadow:0 4px 12px rgba(0,0,0,.15)}
.toast-success{background:#10b981}.toast-error{background:#ef4444}.toast-info{background:#3b82f6}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.reaction-btn{transition:all .2s;cursor:pointer;padding:2px 8px;border-radius:20px;border:1px solid #e2e8f0;font-size:13px;display:inline-flex;align-items:center;gap:3px}
.reaction-btn:hover{transform:scale(1.15);border-color:#93c5fd;background:#eff6ff}
.reaction-btn.active{background:#dbeafe;border-color:#60a5fa}
.poll-bar{height:28px;border-radius:6px;background:#f1f5f9;overflow:hidden;position:relative;margin:4px 0}
.poll-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,#3b82f6,#60a5fa);transition:width .6s ease;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#fff;font-weight:500;min-width:fit-content}
.poll-bar.voted .poll-fill{background:linear-gradient(90deg,#10b981,#34d399)}
.stat-card{border-radius:12px;padding:14px;color:#fff}
.stat-card.blue{background:linear-gradient(135deg,#3b82f6,#2563eb)}
.stat-card.green{background:linear-gradient(135deg,#10b981,#059669)}
.stat-card.orange{background:linear-gradient(135deg,#f59e0b,#d97706)}
.stat-card.purple{background:linear-gradient(135deg,#8b5cf6,#7c3aed)}
.level-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600}
.level-1,.level-2{background:#e2e8f0;color:#64748b}
.level-3,.level-4{background:#dbeafe;color:#2563eb}
.level-5,.level-6{background:#d1fae5;color:#059669}
.level-7,.level-8{background:#fef3c7;color:#d97706}
.level-9,.level-10{background:#fce7f3;color:#db2777}
.tab-pill{padding:6px 16px;border-radius:20px;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;border:1px solid transparent}
.tab-pill.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
.tab-pill:not(.active){background:#f1f5f9;color:#64748b;border-color:#e2e8f0}
.tab-pill:not(.active):hover{background:#e2e8f0;color:#334155}
.post-card{transition:all .2s;border:1px solid #e2e8f0;border-radius:12px;background:#fff}
.post-card:hover{border-color:#93c5fd;box-shadow:0 4px 12px rgba(59,130,246,.08)}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9990;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto;backdrop-filter:blur(2px)}
.modal-content{background:#fff;border-radius:16px;width:100%;max-width:720px;box-shadow:0 20px 60px rgba(0,0,0,.2);animation:modalIn .25s ease}
@keyframes modalIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.hot-item{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid #f1f5f9;cursor:pointer}
.hot-item:last-child{border-bottom:none}
.hot-rank{width:20px;height:20px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;margin-top:2px}
.hot-rank.r1{background:#ef4444;color:#fff}.hot-rank.r2{background:#f97316;color:#fff}.hot-rank.r3{background:#eab308;color:#fff}
.hot-rank:not(.r1):not(.r2):not(.r3){background:#f1f5f9;color:#94a3b8}
</style>
<div class="forum-grid">
<div class="space-y-6">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-posts">0</div>
<div class="text-sm text-blue-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
总帖子数
</div>
</div>
</div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-replies">0</div>
<div class="text-sm text-emerald-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
总回复数
</div>
</div>
</div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-today">0</div>
<div class="text-sm text-orange-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
今日新帖
</div>
</div>
</div>
<div class="bg-gradient-to-br from-violet-500 to-violet-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="flex items-center mb-1">
<span class="relative flex h-3 w-3 mr-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-white"></span>
</span>
<div class="text-3xl font-bold" id="s-online">0</div>
</div>
<div class="text-sm text-violet-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
当前在线人数
</div>
</div>
</div>
</div>
<!-- 头部操作区: 高级毛玻璃渐变 -->
<div class="relative flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-gradient-to-r from-indigo-600 to-purple-600 p-8 rounded-3xl shadow-xl border border-indigo-500 overflow-hidden text-white">
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
<div class="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-500/20 blur-2xl rounded-full"></div>
<div class="relative z-10">
<h1 class="text-3xl font-extrabold flex items-center drop-shadow-md">
<span class="w-12 h-12 bg-white/20 backdrop-blur-md text-white rounded-2xl flex items-center justify-center mr-4 shadow-inner border border-white/20">💬</span>
社区论坛
</h1>
<p class="text-indigo-100 text-base mt-2 ml-16 opacity-90">分享经验、交流问题、结识志同道合的学习伙伴。</p>
</div>
<div class="relative z-10 flex flex-wrap items-center gap-3">
{% if user %}
<button onclick="showLeaderboard()" class="p-3 bg-white/10 hover:bg-white/20 text-white backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-white/40 shadow-lg transform hover:-translate-y-1" title="排行榜">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</button>
<button onclick="showBookmarks()" class="p-3 bg-white/10 hover:bg-white/20 text-yellow-300 backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-yellow-300/50 shadow-lg transform hover:-translate-y-1" title="我的收藏">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</button>
<button onclick="openNewPost()" class="flex items-center gap-2 px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
发布新帖
</button>
{% else %}
<a href="/login" class="px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105">登录后发帖</a>
{% endif %}
</div>
</div>
<!-- 搜索和分类 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 sticky top-20 z-10 glass-panel">
<div class="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center">
<!-- 分类标签 -->
<div class="flex flex-wrap gap-2" id="tab-nav">
<button onclick="filterTab('全部')" class="tab-pill active" data-tab="全部">📋 全部</button>
<button onclick="filterTab('官方公告')" class="tab-pill" data-tab="官方公告">📢 公告</button>
<button onclick="filterTab('题目讨论')" class="tab-pill" data-tab="题目讨论">📐 讨论</button>
<button onclick="filterTab('经验分享')" class="tab-pill" data-tab="经验分享">💡 分享</button>
<button onclick="filterTab('求助答疑')" class="tab-pill" data-tab="求助答疑">🙋 求助</button>
<button onclick="filterTab('闲聊灌水')" class="tab-pill" data-tab="闲聊灌水">☕ 闲聊</button>
</div>
<!-- 搜索框 -->
<div class="flex items-center gap-3 w-full lg:w-auto">
<div class="relative w-full sm:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<input type="text" id="search-input" placeholder="搜索帖子内容..." class="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all" onkeydown="if(event.key==='Enter')loadPosts()">
</div>
<div class="relative">
<select id="sort-select" onchange="loadPosts()" class="appearance-none pl-9 pr-8 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer">
<option value="newest">最新发布</option>
<option value="hottest">热度最高</option>
<option value="most_replies">最多回复</option>
</select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"/></svg>
</div>
<div class="absolute inset-y-0 right-0 pr-2 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<button onclick="loadPosts()" class="px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm hidden sm:block">
搜索
</button>
</div>
</div>
</div>
<!-- 帖子列表 -->
<div id="post-list" class="space-y-4">
<!-- 骨架屏加载状态 -->
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-pulse">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-slate-200 rounded-full"></div>
<div class="flex-1 space-y-3">
<div class="h-4 bg-slate-200 rounded w-1/4"></div>
<div class="h-5 bg-slate-200 rounded w-3/4"></div>
<div class="h-4 bg-slate-200 rounded w-full"></div>
<div class="h-4 bg-slate-200 rounded w-5/6"></div>
<div class="flex gap-4 pt-2">
<div class="h-3 bg-slate-200 rounded w-12"></div>
<div class="h-3 bg-slate-200 rounded w-12"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧边栏 -->
<div class="space-y-6 hidden lg:block">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="bg-gradient-to-r from-red-50 to-orange-50 px-5 py-4 border-b border-red-100">
<h3 class="text-sm font-bold text-red-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/></svg>
热门讨论
</h3>
</div>
<div class="p-2" id="hot-posts">
<div class="p-4 text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-5 py-4 border-b border-blue-100">
<h3 class="text-sm font-bold text-blue-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
活跃用户
</h3>
</div>
<div class="p-2" id="active-users">
<div class="p-4 text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden sticky top-20">
<div class="bg-gradient-to-r from-slate-50 to-gray-50 px-5 py-4 border-b border-slate-100">
<h3 class="text-sm font-bold text-slate-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
版块统计
</h3>
</div>
<div class="p-4" id="tag-stats">
<div class="text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 发帖弹窗 -->
<div id="new-post-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closeNewPost()"><div class="modal-content p-6">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-bold">✏️ 发布新帖</h2>
<button onclick="closeNewPost()" class="text-slate-400 hover:text-slate-600 text-xl"></button>
</div>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-slate-700 mb-1">标题</label>
<input id="new-title" type="text" maxlength="100" class="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm" placeholder="请输入标题"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="block text-sm font-medium text-slate-700 mb-1">分类</label>
<select id="new-tag" class="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm">
<option>题目讨论</option><option>经验分享</option><option>求助答疑</option><option>闲聊灌水</option>
{% if user and user.role == 'teacher' %}<option>官方公告</option>{% endif %}
</select></div>
{% if user and user.role == 'teacher' %}
<div class="flex items-end pb-1"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="new-official" class="rounded"> 官方公告</label></div>
{% endif %}
</div>
<div><label class="block text-sm font-medium text-slate-700 mb-1">内容</label>
<div class="border border-slate-200 rounded-lg overflow-hidden">
<textarea id="new-content" rows="8" class="w-full px-3 py-2.5 text-sm border-0 focus:outline-none resize-y" placeholder="畅所欲言..."></textarea>
<div id="new-content-preview" class="flex flex-wrap gap-2 px-3 py-2 border-t border-slate-100 hidden"></div>
</div></div>
<div><label class="flex items-center gap-2 text-sm font-medium text-slate-700 cursor-pointer mb-2">
<input type="checkbox" id="enable-poll" onchange="togglePoll()" class="rounded"> 📊 添加投票</label>
<div id="poll-section" class="hidden space-y-3 p-4 bg-blue-50 rounded-lg border border-blue-100">
<input id="poll-question" type="text" class="w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="投票问题">
<div id="poll-options" class="space-y-2">
<input type="text" class="poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="选项 1">
<input type="text" class="poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="选项 2">
</div>
<div class="flex items-center gap-3">
<button onclick="addPollOpt()" class="text-sm text-primary hover:text-blue-700 font-medium">+ 添加选项</button>
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" id="poll-multi" class="rounded"> 允许多选</label>
</div>
</div></div>
<div class="flex justify-end gap-3 pt-2">
<button onclick="closeNewPost()" class="px-5 py-2.5 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">取消</button>
<button onclick="submitPost()" class="px-6 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-blue-700">发布帖子</button>
</div>
</div>
</div></div></div>
<!-- 帖子详情弹窗 -->
<div id="post-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closePostModal()">
<div class="modal-content" id="post-modal-content"></div>
</div></div>
<!-- 举报弹窗 -->
<div id="report-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closeReport()"><div class="modal-content p-6 max-w-md">
<h3 class="text-lg font-bold mb-4">🚩 举报</h3>
<input type="hidden" id="report-type"><input type="hidden" id="report-target-id">
<div class="space-y-3">
<select id="report-reason" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm">
<option>垃圾广告</option><option>不当言论</option><option>抄袭内容</option><option>人身攻击</option><option>其他</option></select>
<textarea id="report-detail" rows="3" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm" placeholder="补充说明(可选)"></textarea>
<div class="flex justify-end gap-3">
<button onclick="closeReport()" class="px-4 py-2 text-sm border border-slate-200 rounded-lg hover:bg-slate-50">取消</button>
<button onclick="submitReport()" class="px-5 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600">提交举报</button>
</div>
</div>
</div></div></div>
<!-- 排行榜弹窗 -->
<div id="leaderboard-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)document.getElementById('leaderboard-modal').classList.add('hidden')"><div class="modal-content p-6 max-w-lg">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-bold">🏆 积分排行榜</h2>
<button onclick="document.getElementById('leaderboard-modal').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 text-xl"></button>
</div>
<div id="lb-content"></div>
</div></div></div>
<!-- 用户资料弹窗 -->
<div id="profile-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)document.getElementById('profile-modal').classList.add('hidden')"><div class="modal-content p-6 max-w-lg">
<div id="profile-content"></div>
</div></div></div>
{% endblock %}
{% block scripts %}
<script>
const CU = {{ user | tojson if user else 'null' }};
let curTag = '全部';
const REACTIONS = {like:'👍',love:'❤️',haha:'😂',wow:'😮',sad:'😢',angry:'😡'};
const TAG_ICONS = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
const TAG_COLORS = {'官方公告':'red','题目讨论':'blue','经验分享':'green','求助答疑':'yellow','闲聊灌水':'purple'};
function toast(msg, type='success') {
const d = document.createElement('div');
d.className = `toast toast-${type}`;
d.textContent = msg;
document.body.appendChild(d);
setTimeout(() => { d.style.animation = 'fadeOut .3s ease forwards'; setTimeout(() => d.remove(), 300); }, 2500);
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ===== 发帖 =====
function openNewPost() { document.getElementById('new-post-modal').classList.remove('hidden'); document.getElementById('new-title').focus(); }
function closeNewPost() { document.getElementById('new-post-modal').classList.add('hidden'); }
function togglePoll() { document.getElementById('poll-section').classList.toggle('hidden', !document.getElementById('enable-poll').checked); }
function addPollOpt() {
const c = document.getElementById('poll-options');
const n = c.children.length + 1;
const inp = document.createElement('input');
inp.type = 'text'; inp.className = 'poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white';
inp.placeholder = `选项 ${n}`; c.appendChild(inp);
}
function insertFmt(pre, suf) {
// 保留兼容性RichEditor 已接管
const ta = document.getElementById('new-content');
const s = ta.selectionStart, e = ta.selectionEnd;
const txt = ta.value;
ta.value = txt.substring(0, s) + pre + txt.substring(s, e) + suf + txt.substring(e);
ta.focus(); ta.selectionStart = ta.selectionEnd = s + pre.length;
}
function insEmoji(em) {
const ta = document.getElementById('new-content');
const s = ta.selectionStart;
ta.value = ta.value.substring(0, s) + em + ta.value.substring(s);
ta.focus(); ta.selectionStart = ta.selectionEnd = s + em.length;
}
// 初始化富文本编辑器
document.addEventListener('DOMContentLoaded', function() {
new RichEditor('new-content');
});
// ===== 图片上传 =====
function triggerUpload(targetId) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
inp.onchange = () => { if (inp.files.length) uploadFiles(inp.files, targetId); };
inp.click();
}
function triggerCamera(targetId) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'environment';
inp.onchange = () => { if (inp.files.length) uploadFiles(inp.files, targetId); };
inp.click();
}
async function uploadFiles(files, targetId) {
for (const file of files) {
if (file.size > 10*1024*1024) { toast('文件不能超过10MB','error'); continue; }
const fd = new FormData();
fd.append('file', file);
toast('上传中...','info');
try {
const res = await fetch('/api/upload', {method:'POST', body: fd});
const d = await res.json();
if (d.success) {
const ta = document.getElementById(targetId);
const tag = `\n[img:${d.url}]\n`;
const s = ta.selectionStart;
ta.value = ta.value.substring(0, s) + tag + ta.value.substring(s);
ta.focus();
showUploadPreview(targetId, d.url);
toast('图片上传成功');
} else { toast(d.message,'error'); }
} catch(e) { toast('上传失败','error'); }
}
}
function showUploadPreview(targetId, url) {
const previewId = targetId === 'reply-input' ? 'reply-preview' : targetId + '-preview';
const container = document.getElementById(previewId);
if (!container) return;
container.classList.remove('hidden');
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `<img src="${url}" class="w-16 h-16 object-cover rounded border border-slate-200"><button onclick="this.parentElement.remove();checkPreviewEmpty('${previewId}')" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
container.appendChild(div);
}
function checkPreviewEmpty(previewId) {
const c = document.getElementById(previewId);
if (c && !c.children.length) c.classList.add('hidden');
}
function renderContent(text) {
return renderRichContent(text);
}
async function submitPost() {
const title = document.getElementById('new-title').value.trim();
const content = document.getElementById('new-content').value.trim();
const tag = document.getElementById('new-tag').value;
const isOfficial = document.getElementById('new-official')?.checked || false;
if (!title || !content) { toast('标题和内容不能为空', 'error'); return; }
const body = { title, content, tag, is_official: isOfficial };
let url = '/api/posts';
if (document.getElementById('enable-poll').checked) {
url = '/api/posts/with-poll';
const q = document.getElementById('poll-question').value.trim();
const opts = [...document.querySelectorAll('.poll-opt')].map(i => i.value.trim()).filter(Boolean);
if (q && opts.length >= 2) {
body.poll = { question: q, options: opts, multi: document.getElementById('poll-multi').checked };
}
}
const res = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json();
if (data.success) { closeNewPost(); document.getElementById('new-title').value=''; document.getElementById('new-content').value=''; toast('发帖成功!'); loadPosts(); loadSidebar(); }
else toast(data.message, 'error');
}
// ===== 帖子列表 =====
function renderPosts(posts) {
const c = document.getElementById('post-list');
if (!posts.length) {
c.innerHTML = `
<div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400 bg-white rounded-2xl border border-slate-100 border-dashed">
<svg class="w-16 h-16 mb-4 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<p>暂无符合条件的帖子</p>
${CU ? '<button onclick="openNewPost()" class="mt-4 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors">来发第一帖吧</button>' : ''}
</div>`;
return;
}
posts.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
let h = '';
posts.forEach(p => {
const tc = TAG_COLORS[p.tag] || 'slate';
const ti = TAG_ICONS[p.tag] || '📋';
const reactions = p.reactions || {};
let reactHtml = '';
for (const [k, v] of Object.entries(reactions)) {
if (v > 0) reactHtml += `<span class="inline-flex items-center gap-1 text-xs bg-slate-50 border border-slate-100 px-2 py-0.5 rounded-md text-slate-600">${REACTIONS[k]||k} ${v}</span>`;
}
// 生成纯文本内容用于预览
let cleanContent = p.content.replace(/\[img:[^\]]+\]/g, '[图片]');
// 生成等级
const randomLv = (p.id % 5) + 1;
h += `<div class="bg-white rounded-3xl p-6 border ${p.pinned ? 'border-amber-200 shadow-md bg-gradient-to-br from-amber-50/40 to-white' : 'border-slate-100 shadow-sm'} hover-card-up transition-all duration-300 cursor-pointer group flex flex-col sm:flex-row gap-5" onclick="openPost(${p.id})">
<div class="hidden sm:flex flex-col items-center gap-3 flex-shrink-0" onclick="event.stopPropagation()">
<!-- 游戏化头像与等级 -->
<div class="relative w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-bold border-2 border-white shadow-md transform group-hover:rotate-6 transition-transform z-10">
<span class="text-xl">${esc(p.author.charAt(0))}</span>
${p.is_official ?
'<div class="absolute -bottom-2 -right-2 bg-gradient-to-r from-red-500 to-rose-600 text-white text-[9px] font-black px-1.5 py-0.5 rounded-md shadow-sm border border-white">官方</div>' :
'<div class="absolute -bottom-1.5 -right-1.5 bg-slate-800 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full shadow-sm border border-white">Lv.' + randomLv + '</div>'}
</div>
<button onclick="toggleLike(${p.id},this)" class="group/btn w-12 h-12 flex flex-col items-center justify-center rounded-2xl border ${p.liked?'border-rose-200 bg-rose-50 text-rose-500':'border-slate-100 bg-slate-50 text-slate-400 hover:bg-rose-50 hover:text-rose-500 hover:border-rose-200'} transition-all transform hover:-translate-y-1">
<svg class="w-5 h-5 mb-0.5 group-hover/btn:scale-110 transition-transform" fill="${p.liked?'currentColor':'none'}" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="lc text-[10px] font-bold leading-none">${p.likes}</span>
</button>
</div>
<div class="flex-1 min-w-0 flex flex-col pt-1">
<div class="flex items-center flex-wrap gap-2 mb-3">
${p.pinned ? '<span class="inline-flex items-center text-[10px] bg-gradient-to-r from-amber-400 to-orange-500 text-white px-2.5 py-1 rounded-md font-bold shadow-sm animate-pulse">📌 置顶推荐</span>' : ''}
${p.is_official ? '<span class="inline-flex items-center text-[10px] bg-gradient-to-r from-red-500 to-rose-500 text-white px-2.5 py-1 rounded-md font-bold shadow-sm">官方公告</span>' : ''}
<span class="inline-flex items-center text-xs bg-slate-100 text-slate-600 px-2.5 py-1 rounded-lg font-medium border border-slate-200">
${ti} ${p.tag}
</span>
${p.has_poll ? '<span class="inline-flex items-center text-xs bg-indigo-50 text-indigo-600 px-2.5 py-1 rounded-lg font-medium border border-indigo-200"><svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>参与投票</span>' : ''}
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2 line-clamp-1 group-hover:text-indigo-600 transition-colors">${esc(p.title)}</h3>
<p class="text-sm text-slate-500 line-clamp-2 mb-4 leading-relaxed group-hover:line-clamp-none transition-all duration-500">${esc(cleanContent)}</p>
<div class="mt-auto flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-4 border-t border-slate-100/60">
<div class="flex items-center gap-3">
<span class="text-sm font-bold text-slate-800 hover:text-indigo-600 transition-colors cursor-pointer" onclick="event.stopPropagation();showProfile('${p.author_id}')">${esc(p.author)}</span>
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${randomLv}</span>` : ''}
<span class="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded-md">${p.created_at}</span>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100" title="浏览量">
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
${p.views || 0}
</div>
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-colors" title="回复数">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
${p.replies} 评论
</div>
<div class="flex sm:hidden items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100" onclick="event.stopPropagation();toggleLike(${p.id},this)">
<svg class="w-4 h-4 ${p.liked?'text-rose-500 fill-current':'text-slate-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="lc">${p.likes}</span>
</div>
<div onclick="event.stopPropagation();toggleBookmark(${p.id},this)" class="flex items-center justify-center p-2 rounded-xl bg-slate-50 hover:bg-yellow-50 border border-slate-100 hover:border-yellow-200 transition-colors ${p.bookmarked?'text-yellow-500':'text-slate-400'}" title="收藏">
<svg class="w-4 h-4" fill="${p.bookmarked?'currentColor':'none'}" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</div>
</div>
</div>
${reactHtml ? `<div class="mt-4 pt-3 border-t border-slate-100/60 flex flex-wrap gap-2">${reactHtml}</div>` : ''}
</div>
</div>`;
});
c.innerHTML = h;
}
async function loadPosts() {
const q = document.getElementById('search-input').value;
const sort = document.getElementById('sort-select').value;
const tag = curTag === '全部' ? '' : curTag;
const res = await fetch(`/api/posts/search?q=${encodeURIComponent(q)}&tag=${encodeURIComponent(tag)}&sort=${sort}`);
const data = await res.json();
if (data.success) renderPosts(data.data);
}
function filterTab(tag) {
curTag = tag;
document.querySelectorAll('.tab-pill').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tag);
});
loadPosts();
}
async function toggleLike(pid, btn) {
if (!CU) { toast('请先登录', 'error'); return; }
const res = await fetch(`/api/posts/${pid}/like`, {method:'POST'});
const d = await res.json();
if (d.success) {
btn.querySelector('svg').setAttribute('fill', d.liked ? 'currentColor' : 'none');
btn.className = btn.className.replace(/text-\w+-\d+/g, '') + (d.liked ? ' text-red-500' : ' text-slate-400');
btn.querySelector('.lc').textContent = d.likes;
}
}
async function toggleBookmark(pid, btn) {
if (!CU) { toast('请先登录', 'error'); return; }
const res = await fetch(`/api/posts/${pid}/bookmark`, {method:'POST'});
const d = await res.json();
if (d.success) {
btn.querySelector('svg').setAttribute('fill', d.bookmarked ? 'currentColor' : 'none');
btn.className = btn.className.replace(/text-\w+-\d+/g, '') + (d.bookmarked ? ' text-yellow-500' : ' text-slate-400');
}
}
// ===== 帖子详情 =====
async function openPost(pid) {
const res = await fetch(`/api/posts/${pid}`);
const data = await res.json();
if (!data.success) { toast(data.message, 'error'); return; }
const p = data.data;
const replies = data.replies || [];
const isAuthor = CU && CU.id === p.author_id;
const isTeacher = CU && CU.role === 'teacher';
const reactions = p.reactions || {};
let reactBtns = '';
for (const [k, emoji] of Object.entries(REACTIONS)) {
const cnt = reactions[k] || 0;
reactBtns += `<button onclick="reactPost(${p.id},'${k}',this)" class="reaction-btn${cnt>0?' active':''}">${emoji} <span>${cnt}</span></button>`;
}
let pollHtml = '';
if (p.has_poll) {
try {
const pr = await fetch(`/api/posts/${pid}/poll`);
const pd = await pr.json();
if (pd.success) {
const poll = pd.poll;
const voted = pd.voted;
const myC = pd.my_choices || [];
const totalV = poll.options.reduce((s, o) => s + o.votes, 0) || 1;
pollHtml = `<div class="bg-blue-50 rounded-lg p-4 mb-4 border border-blue-100">
<div class="font-medium text-sm text-slate-800 mb-3">📊 ${esc(poll.question)}</div>`;
poll.options.forEach((opt, i) => {
const pct = Math.round(opt.votes / totalV * 100);
const isMy = myC.includes(i);
if (voted) {
pollHtml += `<div class="mb-2"><div class="flex justify-between text-xs text-slate-600 mb-1"><span>${isMy?'✅ ':''}${esc(opt.text)}</span><span>${opt.votes}票 (${pct}%)</span></div><div class="poll-bar${isMy?' voted':''}"><div class="poll-fill" style="width:${Math.max(pct,2)}%">${pct}%</div></div></div>`;
} else {
pollHtml += `<label class="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100 cursor-pointer mb-1"><input type="${poll.multi?'checkbox':'radio'}" name="poll-vote" value="${i}" class="poll-choice rounded"> <span class="text-sm">${esc(opt.text)}</span></label>`;
}
});
if (!voted && CU) {
pollHtml += `<button onclick="votePoll(${pid})" class="mt-2 px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">投票</button>`;
}
pollHtml += `<div class="text-xs text-slate-400 mt-2">${poll.total_votes} 人已投票${poll.multi?' · 可多选':''}</div></div>`;
}
} catch(e) {}
}
let h = `<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2 flex-wrap">
${p.pinned?'<span class="text-xs bg-red-50 text-red-600 px-2 py-0.5 rounded-full">📌 置顶</span>':''}
${p.is_official?'<span class="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded-full">官方</span>':''}
<span class="text-xs bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full">${TAG_ICONS[p.tag]||''} ${p.tag}</span>
${p.edited?'<span class="text-xs text-slate-400">✏️ 已编辑</span>':''}
</div>
<h2 class="text-xl font-bold text-slate-900">${esc(p.title)}</h2>
<div class="mt-2 flex items-center text-sm text-slate-400 gap-3">
<span class="cursor-pointer hover:text-primary" onclick="showProfile('${p.author_id}')">${esc(p.author)}</span>
<span>${p.created_at}</span><span>👁 ${p.views||0}</span><span>❤️ ${p.likes}</span><span>💬 ${replies.length}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${isTeacher?`<button onclick="pinPost(${p.id})" class="text-xs px-2 py-1 rounded border ${p.pinned?'border-red-300 text-red-600 bg-red-50':'border-slate-200 text-slate-600'} hover:bg-slate-50">${p.pinned?'取消置顶':'置顶'}</button>`:''}
${isAuthor?`<button onclick="editPost(${p.id})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-600 hover:bg-slate-50">编辑</button>`:''}
${isAuthor||isTeacher?`<button onclick="deletePost(${p.id})" class="text-xs px-2 py-1 rounded border border-red-200 text-red-600 hover:bg-red-50">删除</button>`:''}
<button onclick="openReport('post',${p.id})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-500 hover:bg-slate-50">举报</button>
<button onclick="closePostModal()" class="text-slate-400 hover:text-slate-600 text-xl ml-1">✕</button>
</div>
</div>
<div class="prose prose-sm max-w-none text-slate-800 whitespace-pre-wrap border-b border-slate-100 pb-5 mb-4">${renderContent(p.content)}</div>
${pollHtml}
<div class="flex items-center gap-2 flex-wrap mb-4">${reactBtns}</div>
<div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<button onclick="toggleLikeModal(${p.id})" id="ml-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.liked?'border-red-300 text-red-500 bg-red-50':'border-slate-200 text-slate-500'} hover:bg-red-50 text-sm">❤️ <span id="ml-cnt">${p.likes}</span></button>
<button onclick="toggleBmModal(${p.id})" id="mb-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.bookmarked?'border-yellow-300 text-yellow-500 bg-yellow-50':'border-slate-200 text-slate-500'} hover:bg-yellow-50 text-sm">${p.bookmarked?'⭐ 已收藏':'☆ 收藏'}</button>
<button onclick="sharePost(${p.id})" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-slate-200 text-slate-500 hover:bg-slate-50 text-sm">🔗 分享</button>
</div>
<h3 class="text-sm font-bold text-slate-700 mb-4">💬 回复 (${replies.length})</h3>`;
if (CU) {
h += `<div class="flex gap-3 mb-6"><div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">${esc(CU.name.charAt(0))}</div>
<div class="flex-1"><textarea id="reply-input" rows="2" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="写下你的回复..."></textarea>
<div id="reply-preview" class="flex flex-wrap gap-2 mt-1 hidden"></div>
<div class="flex justify-between items-center mt-2"><div class="flex gap-1"><button onclick="triggerUpload('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="上传图片">📷</button><button onclick="triggerCamera('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="拍照上传">📸</button></div><button onclick="submitReply(${p.id})" class="px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">回复</button></div></div></div>`;
}
if (!replies.length) {
h += '<div class="text-center py-8 text-slate-400 text-sm">暂无回复,来抢沙发吧 🛋️</div>';
} else {
replies.forEach((r, i) => {
const canDel = CU && (CU.id === r.author_id || CU.role === 'teacher');
const canEdit = CU && CU.id === r.author_id;
h += `<div class="flex gap-3 py-3 ${i>0?'border-t border-slate-100':''}">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold flex-shrink-0">${esc(r.author.charAt(0))}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-slate-900 cursor-pointer hover:text-primary" onclick="showProfile('${r.author_id}')">${esc(r.author)}</span>
${r.reply_to?`<span class="text-xs text-slate-400">回复 ${esc(r.reply_to)}</span>`:''}
<span class="text-xs text-slate-400">${r.created_at}</span>
${r.edited?'<span class="text-xs text-slate-400">✏️已编辑</span>':''}
</div>
<div class="flex items-center gap-2">
<button onclick="likeReply(${r.id},this)" class="flex items-center gap-1 text-xs ${r.liked?'text-red-500':'text-slate-400'} hover:text-red-500">❤️ <span>${r.likes}</span></button>
<button onclick="replyTo('${esc(r.author)}',${p.id})" class="text-xs text-slate-400 hover:text-primary">回复</button>
${canEdit?`<button onclick="editReply(${r.id},'${esc(r.content).replace(/'/g,"\\\\'")}',${p.id})" class="text-xs text-slate-400 hover:text-blue-500">编辑</button>`:''}
${canDel?`<button onclick="deleteReply(${r.id},${p.id})" class="text-xs text-slate-400 hover:text-red-500">删除</button>`:''}
<button onclick="openReport('reply',${r.id})" class="text-xs text-slate-400 hover:text-orange-500">举报</button>
</div>
</div>
<p class="mt-1 text-sm text-slate-700 whitespace-pre-wrap">${renderContent(r.content)}</p>
</div></div>`;
});
}
h += '</div>';
document.getElementById('post-modal-content').innerHTML = h;
document.getElementById('post-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 初始化回复输入框的富文本编辑器
if (document.getElementById('reply-input')) {
new RichEditor('reply-input', { compact: true });
}
}
function closePostModal() {
document.getElementById('post-modal').classList.add('hidden');
document.body.style.overflow = '';
loadPosts();
}
async function submitReply(pid) {
const inp = document.getElementById('reply-input');
const content = inp.value.trim();
if (!content) return;
const replyTo = inp.dataset.replyTo || '';
const res = await fetch(`/api/posts/${pid}/replies`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content, reply_to: replyTo})});
const d = await res.json();
if (d.success) { toast('回复成功'); openPost(pid); } else toast(d.message,'error');
}
function replyTo(name, pid) {
const inp = document.getElementById('reply-input');
if (inp) { inp.dataset.replyTo = name; inp.placeholder = `回复 ${name}...`; inp.focus(); }
}
async function toggleLikeModal(pid) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/like`,{method:'POST'});
const d = await res.json();
if (d.success) { document.getElementById('ml-cnt').textContent = d.likes; openPost(pid); }
}
async function toggleBmModal(pid) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/bookmark`,{method:'POST'});
const d = await res.json();
if (d.success) { openPost(pid); }
}
async function reactPost(pid, reaction, btn) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/react`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reaction})});
const d = await res.json();
if (d.success) openPost(pid);
}
async function votePoll(pid) {
const checks = document.querySelectorAll('.poll-choice:checked');
const choices = [...checks].map(c => parseInt(c.value));
if (!choices.length) { toast('请选择选项','error'); return; }
const res = await fetch(`/api/posts/${pid}/vote`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({choices})});
const d = await res.json();
if (d.success) { toast('投票成功'); openPost(pid); } else toast(d.message,'error');
}
async function sharePost(pid) {
const url = window.location.origin + '/forum#post-' + pid;
try { await navigator.clipboard.writeText(url); toast('链接已复制到剪贴板'); } catch(e) { toast('分享链接: ' + url, 'info'); }
fetch(`/api/posts/${pid}/share`,{method:'POST'});
}
async function deletePost(pid) {
if (!confirm('确定删除该帖子?')) return;
const res = await fetch(`/api/posts/${pid}`,{method:'DELETE'});
const d = await res.json();
if (d.success) { closePostModal(); toast('帖子已删除'); } else toast(d.message,'error');
}
async function pinPost(pid) {
const res = await fetch(`/api/posts/${pid}/pin`,{method:'POST'});
const d = await res.json();
if (d.success) openPost(pid);
}
async function editPost(pid) {
const res = await fetch(`/api/posts/${pid}`);
const d = await res.json();
if (!d.success) return;
const p = d.data;
const newTitle = prompt('编辑标题:', p.title);
if (newTitle === null) return;
const newContent = prompt('编辑内容:', p.content);
if (newContent === null) return;
const res2 = await fetch(`/api/posts/${pid}/edit`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:newTitle,content:newContent,tag:p.tag})});
const d2 = await res2.json();
if (d2.success) { toast('编辑成功'); openPost(pid); } else toast(d2.message,'error');
}
async function editReply(rid, oldContent, pid) {
const newContent = prompt('编辑回复:', oldContent);
if (newContent === null || !newContent.trim()) return;
const res = await fetch(`/api/replies/${rid}/edit`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:newContent})});
const d = await res.json();
if (d.success) { toast('编辑成功'); openPost(pid); } else toast(d.message,'error');
}
async function likeReply(rid, btn) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/replies/${rid}/like`,{method:'POST'});
const d = await res.json();
if (d.success) { btn.querySelector('span').textContent = d.likes; btn.className = btn.className.replace(/text-\w+-\d+/g,'') + (d.liked?' text-red-500':' text-slate-400'); }
}
async function deleteReply(rid, pid) {
if (!confirm('确定删除该回复?')) return;
const res = await fetch(`/api/replies/${rid}`,{method:'DELETE'});
const d = await res.json();
if (d.success) { toast('回复已删除'); openPost(pid); } else toast(d.message,'error');
}
// ===== 举报 =====
function openReport(type, targetId) {
document.getElementById('report-type').value = type;
document.getElementById('report-target-id').value = targetId;
document.getElementById('report-modal').classList.remove('hidden');
}
function closeReport() { document.getElementById('report-modal').classList.add('hidden'); }
async function submitReport() {
const res = await fetch('/api/report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
type: document.getElementById('report-type').value,
target_id: parseInt(document.getElementById('report-target-id').value),
reason: document.getElementById('report-reason').value,
detail: document.getElementById('report-detail').value
})});
const d = await res.json();
if (d.success) { closeReport(); toast(d.message); } else toast(d.message,'error');
}
// ===== 收藏 =====
async function showBookmarks() {
const res = await fetch('/api/user/bookmarks');
const d = await res.json();
if (d.success) { renderPosts(d.data); document.querySelectorAll('.tab-pill').forEach(b => b.classList.remove('active')); toast('显示收藏帖子','info'); }
}
// ===== 排行榜 =====
async function showLeaderboard() {
document.getElementById('leaderboard-modal').classList.remove('hidden');
const res = await fetch('/api/forum/leaderboard');
const d = await res.json();
if (!d.success) return;
let h = '';
d.data.forEach((u, i) => {
const rc = i===0?'gold':i===1?'silver':i===2?'bronze':'';
const medal = i===0?'🥇':i===1?'🥈':i===2?'🥉':`${i+1}`;
h += `<div class="flex items-center gap-3 py-3 ${i>0?'border-t border-slate-100':''}">
<div class="w-6 text-center font-bold ${rc?'text-lg':''}">${medal}</div>
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-primary text-xs font-bold">${esc(u.name.charAt(0))}</div>
<div class="flex-1"><div class="flex items-center gap-2"><span class="text-sm font-medium cursor-pointer hover:text-primary" onclick="showProfile('${u.user_id}')">${esc(u.name)}</span><span class="level-badge level-${u.level}">Lv.${u.level} ${u.level_title}</span></div>
<div class="text-xs text-slate-400 mt-0.5">${u.points}积分 · ${u.posts_count}帖子 · ${u.likes_received}赞</div></div></div>`;
});
document.getElementById('lb-content').innerHTML = h || '<div class="text-center py-8 text-slate-400">暂无数据</div>';
}
// ===== 用户资料 =====
async function showProfile(uid) {
document.getElementById('profile-modal').classList.remove('hidden');
const res = await fetch(`/api/user/profile/${uid}`);
const d = await res.json();
if (!d.success) return;
const p = d.profile;
let badges = p.badges.map(b => `<span class="inline-flex items-center gap-1 px-2 py-1 bg-slate-50 rounded-lg text-xs" title="${b.desc}">${b.icon} ${b.name}</span>`).join('');
let posts = p.recent_posts.map(pp => `<div class="text-sm py-1.5 border-b border-slate-50 cursor-pointer hover:text-primary" onclick="document.getElementById('profile-modal').classList.add('hidden');openPost(${pp.id})">${esc(pp.title)}</div>`).join('');
let friendBtn = '';
if (CU && CU.id !== parseInt(uid)) {
friendBtn = `<button id="addFriendBtn" onclick="forumAddFriend(${uid},this)" class="px-3 py-1.5 text-xs bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">加好友</button>`;
}
document.getElementById('profile-content').innerHTML = `
<div class="flex items-center gap-4 mb-5">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary text-2xl font-bold">${esc(p.name.charAt(0))}</div>
<div><div class="flex items-center gap-2"><span class="text-xl font-bold">${esc(p.name)}</span><span class="level-badge level-${p.level}">Lv.${p.level} ${p.level_title}</span></div>
<div class="text-sm text-slate-500 mt-1">${p.points} 积分</div></div>
<div class="ml-auto flex items-center gap-2">
${friendBtn}
<button onclick="document.getElementById('profile-modal').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 text-xl">✕</button>
</div>
</div>
<div class="grid grid-cols-3 gap-3 mb-5">
<div class="text-center p-3 bg-blue-50 rounded-lg"><div class="text-xl font-bold text-blue-600">${p.posts_count}</div><div class="text-xs text-slate-500">帖子</div></div>
<div class="text-center p-3 bg-green-50 rounded-lg"><div class="text-xl font-bold text-green-600">${p.replies_count}</div><div class="text-xs text-slate-500">回复</div></div>
<div class="text-center p-3 bg-red-50 rounded-lg"><div class="text-xl font-bold text-red-500">${p.likes_received}</div><div class="text-xs text-slate-500">获赞</div></div>
</div>
${badges?`<div class="mb-5"><div class="text-sm font-bold text-slate-700 mb-2">🏅 成就徽章</div><div class="flex flex-wrap gap-2">${badges}</div></div>`:''}
${posts?`<div><div class="text-sm font-bold text-slate-700 mb-2">📝 最近帖子</div>${posts}</div>`:''}`;
}
async function forumAddFriend(userId, btn) {
try {
const res = await fetch('/api/friend/add', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({friend_id: userId})});
const data = await res.json();
if (data.success) {
btn.textContent = '已发送';
btn.disabled = true;
btn.classList.replace('bg-primary', 'bg-slate-400');
btn.classList.remove('hover:bg-blue-600');
} else {
alert(data.message || '操作失败');
}
} catch(e) { alert('操作失败'); }
}
// ===== 侧边栏 =====
async function loadSidebar() {
// 热门帖子
const hr = await fetch('/api/forum/hot');
const hd = await hr.json();
if (hd.success) {
let h = '';
hd.data.slice(0, 6).forEach((p, i) => {
let colorClass = 'bg-slate-100 text-slate-500';
if (i === 0) colorClass = 'bg-red-500 text-white shadow-sm shadow-red-200';
else if (i === 1) colorClass = 'bg-orange-500 text-white shadow-sm shadow-orange-200';
else if (i === 2) colorClass = 'bg-amber-500 text-white shadow-sm shadow-amber-200';
h += `
<div class="flex items-start gap-3 p-3 rounded-xl hover:bg-slate-50 cursor-pointer transition-colors group mb-1 border border-transparent hover:border-slate-100" onclick="openPost(${p.id})">
<div class="w-6 h-6 rounded-lg ${colorClass} flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">
${i + 1}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700 group-hover:text-primary line-clamp-2 mb-1.5 transition-colors leading-snug">
${esc(p.title)}
</div>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span class="flex items-center"><svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>${p.views || 0}</span>
<span class="flex items-center"><svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>${p.replies}</span>
</div>
</div>
</div>`;
});
document.getElementById('hot-posts').innerHTML = h || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
}
// 论坛统计
const sr = await fetch('/api/forum/stats');
const sd = await sr.json();
if (sd.success) {
const s = sd.stats;
document.getElementById('s-posts').textContent = s.total_posts;
document.getElementById('s-replies').textContent = s.total_replies;
document.getElementById('s-today').textContent = s.today_posts;
document.getElementById('s-online').textContent = s.online_count;
// 活跃用户
let au = '';
s.active_users.forEach(u => {
au += `<div class="flex items-center gap-3 p-2.5 rounded-xl hover:bg-slate-50 cursor-pointer transition-colors border border-transparent hover:border-slate-100 mb-1" onclick="showProfile('${u.user_id}')">
<div class="relative">
<div class="w-9 h-9 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center text-primary font-bold shadow-sm border border-white">
${esc(u.name.charAt(0))}
</div>
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white flex items-center justify-center" title="在线"></div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-800 truncate">${esc(u.name)}</div>
<div class="text-xs text-slate-400 mt-0.5 truncate">Lv.${u.level} ${u.level_title || ''}</div>
</div>
</div>`;
});
document.getElementById('active-users').innerHTML = au || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
// 标签统计
let ts = '';
const tagIcons = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
for (const [tag, cnt] of Object.entries(s.tag_counts)) {
ts += `<div class="flex items-center justify-between text-xs py-1"><span>${tagIcons[tag]||'📋'} ${tag}</span><span class="text-slate-400">${cnt} 帖</span></div>`;
}
document.getElementById('tag-stats').innerHTML = ts || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
}
}
// ===== 初始化 =====
loadPosts();
loadSidebar();
// 定时刷新在线人数
setInterval(async () => {
try {
const r = await fetch('/api/forum/stats');
const d = await r.json();
if (d.success) document.getElementById('s-online').textContent = d.stats.online_count;
} catch(e) {}
}, 30000);
</script>
{% endblock %}