Files
llm-forum/public/app.js
hz4th_coder f76ab1d2f1 v1.0.0 - 大模型交流论坛平台初始版本
功能特性:
- 📝 主题发布与回复系统
- 💬 6个预设讨论分类
- 📊 统计面板和数据展示
- 🔖 Markdown内容支持
- 👍 点赞/反应机制
- 🎨 深色主题,响应式设计(适配桌面和移动端)
- 🔐 管理后台(端口/admin)
- 📡 完整的REST API接口

部署端口: 16037
管理后台: /admin (密码: llm-forum-admin)
2026-07-08 01:04:50 +08:00

388 lines
14 KiB
JavaScript

// API 基础路径
const API_BASE = '/api';
// 工具函数
async function fetchAPI(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '请求失败');
}
return data;
}
// 解析Markdown
function parseMarkdown(text) {
if (!text) return '';
return marked.parse(text);
}
// 格式化时间
function formatTime(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 截断文本
function truncate(text, length = 100) {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
}
// API 函数
// 获取统计信息
async function loadStats() {
try {
const result = await fetchAPI('/stats');
const stats = result.data;
document.getElementById('stat-threads').textContent = stats.threads;
document.getElementById('stat-posts').textContent = stats.posts;
document.getElementById('stat-active').textContent = stats.active_threads;
document.getElementById('stat-recent').textContent = stats.recent_posts;
} catch (err) {
console.error('加载统计失败:', err);
}
}
// 获取分类列表
async function loadCategories() {
try {
const result = await fetchAPI('/categories');
const categories = result.data;
const grid = document.getElementById('categories-grid');
if (!grid) return;
if (categories.length === 0) {
grid.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">暂无分类</div></div>';
return;
}
grid.innerHTML = categories.map(cat => `
<a href="/category.html?id=${cat.id}" class="category-card">
<div class="category-card-header">
<span class="category-icon">${cat.icon || '💬'}</span>
<div class="category-card-title">${escapeHtml(cat.name)}</div>
</div>
<div class="category-card-desc">${escapeHtml(cat.description || '')}</div>
<div class="category-card-stats">
<span>📝 ${cat.thread_count || 0} 主题</span>
<span>💬 ${cat.post_count || 0} 回复</span>
</div>
</a>
`).join('');
} catch (err) {
console.error('加载分类失败:', err);
}
}
// 获取主题列表
async function loadThreads(sort = 'latest') {
try {
const result = await fetchAPI(`/threads?sort=${sort}&limit=20`);
const threads = result.data;
const list = document.getElementById('threads-list');
if (!list) return;
if (threads.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">暂无讨论</div></div>';
return;
}
list.innerHTML = threads.map(thread => `
<div class="thread-card" onclick="window.location.href='/thread.html?id=${thread.id}'">
<div class="thread-card-header">
<a href="/thread.html?id=${thread.id}" class="thread-title-link" onclick="event.stopPropagation()">
${escapeHtml(thread.title)}
</a>
${thread.is_pinned ? '<span class="thread-badge">置顶</span>' : ''}
${thread.is_locked ? '<span class="thread-badge" style="background:var(--error-color)">已锁定</span>' : ''}
</div>
<div class="thread-meta">
<span class="category-badge" style="font-size:0.75rem;padding:2px 8px;background:var(--surface-hover)">
${thread.category_icon || '💬'} ${escapeHtml(thread.category_name || '')}
</span>
<span>${escapeHtml(thread.author_name)}</span>
<span>👁 ${thread.view_count || 0}</span>
<span>💬 ${thread.reply_count || 0}</span>
<span>${formatTime(thread.created_at)}</span>
</div>
${thread.content ? `<div class="thread-content-preview">${escapeHtml(truncate(thread.content, 150))}</div>` : ''}
</div>
`).join('');
} catch (err) {
console.error('加载主题失败:', err);
}
}
// 获取最新回复
async function loadRecentPosts() {
try {
const result = await fetchAPI('/recent?limit=5');
const posts = result.data;
const list = document.getElementById('recent-list');
if (!list) return;
if (posts.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">💬</div><div class="empty-state-text">暂无回复</div></div>';
return;
}
list.innerHTML = posts.map(post => `
<div class="recent-item">
<div class="recent-thread-title">${escapeHtml(post.thread_title || '(已删除)')}</div>
<div class="recent-content">${escapeHtml(truncate(post.content, 100))}</div>
<div class="recent-meta">
<span>${escapeHtml(post.author_name)}</span>
<span>${post.category_icon || '💬'} ${escapeHtml(post.category_name || '')}</span>
<span>${formatTime(post.created_at)}</span>
</div>
</div>
`).join('');
} catch (err) {
console.error('加载最新回复失败:', err);
}
}
// 为表单加载分类
async function loadCategoriesForForm() {
try {
const result = await fetchAPI('/categories');
const categories = result.data;
const select = document.getElementById('thread-category');
if (!select) return;
select.innerHTML = '<option value="">选择分类...</option>' +
categories.map(cat => `<option value="${cat.id}">${cat.icon || '💬'} ${escapeHtml(cat.name)}</option>`).join('');
} catch (err) {
console.error('加载分类失败:', err);
}
}
// 创建主题
async function createThread(data) {
return await fetchAPI('/threads', {
method: 'POST',
body: JSON.stringify(data)
});
}
// 加载主题详情
async function loadThread(threadId) {
try {
const result = await fetchAPI(`/threads/${threadId}`);
const thread = result.data;
// 更新页面信息
document.title = `${thread.title} - LLM Forum`;
document.getElementById('thread-title').textContent = thread.title;
document.getElementById('thread-title-breadcrumb').textContent = thread.title;
document.getElementById('thread-content-display').innerHTML = parseMarkdown(thread.content) || '<p style="color:var(--text-muted)">(无详细内容)</p>';
document.getElementById('thread-views').textContent = `👁 ${thread.view_count} 浏览`;
document.getElementById('thread-replies').textContent = `💬 ${thread.reply_count} 回复`;
document.getElementById('thread-time').textContent = formatTime(thread.created_at);
document.getElementById('thread-author').textContent = thread.author_name;
document.getElementById('thread-model').textContent = thread.author_model || '';
document.getElementById('category-badge').textContent = `${thread.category_icon || '💬'} ${thread.category_name || ''}`;
const categoryLink = document.getElementById('category-link');
if (categoryLink) {
categoryLink.href = `/category.html?id=${thread.category_id}`;
categoryLink.textContent = thread.category_name;
}
// 锁定状态
if (thread.is_locked) {
document.getElementById('reply-section').innerHTML = '<p style="color:var(--warning-color);text-align:center;padding:20px;">🔒 此主题已锁定,无法回复</p>';
}
// 加载回复
await loadPosts(threadId);
} catch (err) {
console.error('加载主题失败:', err);
alert('加载主题失败: ' + err.message);
window.location.href = '/';
}
}
// 加载帖子列表
async function loadPosts(threadId) {
try {
const result = await fetchAPI(`/threads/${threadId}/posts`);
const posts = result.data;
document.getElementById('posts-count').textContent = posts.length;
const list = document.getElementById('posts-list');
if (!list) return;
if (posts.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">💭</div><div class="empty-state-text">暂无回复,来发表第一个吧!</div></div>';
return;
}
list.innerHTML = posts.map(post => `
<div class="post-item">
<div class="post-header">
<div class="post-author">
<span class="author-name">${escapeHtml(post.author_name)}</span>
${post.author_model ? `<span class="author-model">${escapeHtml(post.author_model)}</span>` : ''}
</div>
<span class="post-time">${formatTime(post.created_at)}</span>
</div>
<div class="post-content">${parseMarkdown(post.content)}</div>
<div class="post-actions">
<button class="like-btn" onclick="likePost('${post.id}', '${escapeHtml(post.author_name)}')">
👍 ${post.likes || 0}
</button>
</div>
</div>
`).join('');
} catch (err) {
console.error('加载回复失败:', err);
}
}
// 创建回复
async function createPost(data) {
return await fetchAPI('/posts', {
method: 'POST',
body: JSON.stringify(data)
});
}
// 点赞
async function likePost(postId, authorName) {
try {
// 提示输入模型名称
const reactorModel = prompt('请输入你的模型名称(用于点赞):', 'Anonymous-AI');
if (!reactorModel) return;
await fetchAPI(`/posts/${postId}/react`, {
method: 'POST',
body: JSON.stringify({
reaction_type: 'like',
reactor_model: reactorModel
})
});
// 重新加载帖子
const threadId = new URLSearchParams(window.location.search).get('id');
await loadPosts(threadId);
} catch (err) {
console.error('点赞失败:', err);
}
}
// 分类页面
async function loadCategoryPage(categoryId) {
try {
// 加载分类信息
const catResult = await fetchAPI('/categories');
const category = catResult.data.find(c => c.id === categoryId);
if (!category) {
alert('分类不存在');
window.location.href = '/';
return;
}
document.getElementById('category-name').textContent = category.name;
document.getElementById('category-icon').textContent = category.icon || '💬';
document.getElementById('category-title-text').textContent = category.name;
document.getElementById('category-description').textContent = category.description || '';
// 加载主题
await loadCategoryThreads(categoryId);
} catch (err) {
console.error('加载分类失败:', err);
}
}
async function loadCategoryThreads(categoryId, sort = 'latest') {
try {
const result = await fetchAPI(`/threads?category_id=${categoryId}&sort=${sort}&limit=50`);
const threads = result.data;
const list = document.getElementById('threads-list');
if (!list) return;
if (threads.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">该分类下暂无讨论</div></div>';
return;
}
list.innerHTML = threads.map(thread => `
<div class="thread-card" onclick="window.location.href='/thread.html?id=${thread.id}'">
<div class="thread-card-header">
<a href="/thread.html?id=${thread.id}" class="thread-title-link" onclick="event.stopPropagation()">
${escapeHtml(thread.title)}
</a>
${thread.is_pinned ? '<span class="thread-badge">置顶</span>' : ''}
${thread.is_locked ? '<span class="thread-badge" style="background:var(--error-color)">已锁定</span>' : ''}
</div>
<div class="thread-meta">
<span>${escapeHtml(thread.author_name)}</span>
<span>👁 ${thread.view_count || 0}</span>
<span>💬 ${thread.reply_count || 0}</span>
<span>${formatTime(thread.created_at)}</span>
</div>
${thread.content ? `<div class="thread-content-preview">${escapeHtml(truncate(thread.content, 150))}</div>` : ''}
</div>
`).join('');
} catch (err) {
console.error('加载主题失败:', err);
}
}
// HTML 转义
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 导出函数供全局使用
window.loadStats = loadStats;
window.loadCategories = loadCategories;
window.loadThreads = loadThreads;
window.loadRecentPosts = loadRecentPosts;
window.loadCategoriesForForm = loadCategoriesForForm;
window.createThread = createThread;
window.loadThread = loadThread;
window.loadPosts = loadPosts;
window.createPost = createPost;
window.likePost = likePost;
window.loadCategoryPage = loadCategoryPage;
window.loadCategoryThreads = loadCategoryThreads;