first commit
This commit is contained in:
936
templates/forum.html
Normal file
936
templates/forum.html
Normal file
@@ -0,0 +1,936 @@
|
||||
{% 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('');
|
||||
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>
|
||||
<button onclick="document.getElementById('profile-modal').classList.add('hidden')" class="ml-auto text-slate-400 hover:text-slate-600 text-xl">✕</button>
|
||||
</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 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 %}
|
||||
Reference in New Issue
Block a user