初始化技术论坛与技术分享网站
功能模块: - 技术交流: 发帖、评论回复、点赞收藏、标签分类 - 工具分享: 创建主题、子主题分支、问题追问、关注功能 - 用户系统: 用户名+邮箱(必填)+手机(可选)+密码确认 页面: - 首页: 帖子列表、热门标签、工具分享主题 - 登录/注册页 - 发帖页 - 帖子详情页 - 主题详情页 - 用户主页 技术栈: - Flask + Tailwind CSS - JSON文件存储 - JWT认证 - 响应式设计 端口: 19004
This commit is contained in:
156
frontend/create.html
Normal file
156
frontend/create.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发布帖子 - 技术论坛</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white border-b border-gray-100">
|
||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-800 flex items-center gap-1">
|
||||
<i class="ri-arrow-left-line"></i> 返回首页
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">发布新帖子</h1>
|
||||
|
||||
<form id="postForm" class="bg-white rounded-lg border border-gray-100 p-6">
|
||||
<!-- 帖子类型 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">帖子类型</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="discussion" checked class="text-blue-600">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="ri-discuss-line text-blue-500"></i> 技术交流
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="type" value="share" class="text-purple-600">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="ri-tools-line text-purple-500"></i> 工具分享
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">标题</label>
|
||||
<input type="text" id="title" placeholder="请输入帖子标题"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">内容</label>
|
||||
<textarea id="content" rows="12" placeholder="请输入帖子内容,支持Markdown格式..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
标签 <span class="text-gray-400">(用逗号分隔)</span>
|
||||
</label>
|
||||
<input type="text" id="tags" placeholder="如:Python, Django, Web开发"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="px-6 py-3 gradient-bg text-white rounded-lg font-medium hover:opacity-90">
|
||||
发布帖子
|
||||
</button>
|
||||
<button type="button" onclick="saveDraft()" class="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
|
||||
保存草稿
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="errorMsg" class="mt-4 p-3 bg-red-50 text-red-500 rounded-lg text-sm hidden"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// 检查登录
|
||||
if (!localStorage.getItem('token')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
document.getElementById('postForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const title = document.getElementById('title').value.trim();
|
||||
const content = document.getElementById('content').value.trim();
|
||||
const type = document.querySelector('input[name="type"]:checked').value;
|
||||
const tagsInput = document.getElementById('tags').value.trim();
|
||||
const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
|
||||
if (!title || title.length < 5) {
|
||||
showError('标题至少5个字符');
|
||||
return;
|
||||
}
|
||||
if (!content || content.length < 10) {
|
||||
showError('内容至少10个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, content, type, tags })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
window.location.href = `/post/${data.post_id}`;
|
||||
} else {
|
||||
showError(data.error || '发布失败');
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请重试');
|
||||
}
|
||||
});
|
||||
|
||||
function saveDraft() {
|
||||
const draft = {
|
||||
title: document.getElementById('title').value,
|
||||
content: document.getElementById('content').value,
|
||||
type: document.querySelector('input[name="type"]:checked').value,
|
||||
tags: document.getElementById('tags').value
|
||||
};
|
||||
localStorage.setItem('post_draft', JSON.stringify(draft));
|
||||
alert('草稿已保存');
|
||||
}
|
||||
|
||||
// 恢复草稿
|
||||
const savedDraft = localStorage.getItem('post_draft');
|
||||
if (savedDraft) {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
if (draft.title) document.getElementById('title').value = draft.title;
|
||||
if (draft.content) document.getElementById('content').value = draft.content;
|
||||
if (draft.type) document.querySelector(`input[value="${draft.type}"]`).checked = true;
|
||||
if (draft.tags) document.getElementById('tags').value = draft.tags;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
327
frontend/index.html
Normal file
327
frontend/index.html
Normal file
@@ -0,0 +1,327 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>技术论坛 - 首页</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="/" class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<span class="text-2xl">👨💻</span> 技术论坛
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a href="/" class="text-blue-600 font-medium">首页</a>
|
||||
<a href="#topics" class="text-gray-600 hover:text-blue-600">工具分享</a>
|
||||
<a href="#tags" class="text-gray-600 hover:text-blue-600">标签</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative hidden sm:block">
|
||||
<i class="ri-search-line absolute left-3 top-2.5 text-gray-400"></i>
|
||||
<input type="text" id="searchInput" placeholder="搜索帖子..."
|
||||
class="pl-9 pr-4 py-2 border border-gray-200 rounded-lg w-48 focus:w-64 transition-all focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 用户状态 -->
|
||||
<div id="userArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex gap-8">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="flex-1">
|
||||
<!-- Tab切换 -->
|
||||
<div class="bg-white rounded-lg border border-gray-100 mb-6">
|
||||
<div class="flex border-b border-gray-100">
|
||||
<button onclick="loadPosts('all')" id="tab-all" class="px-6 py-4 text-blue-600 border-b-2 border-blue-600 font-medium">
|
||||
全部
|
||||
</button>
|
||||
<button onclick="loadPosts('discussion')" id="tab-discussion" class="px-6 py-4 text-gray-500 hover:text-gray-700">
|
||||
技术交流
|
||||
</button>
|
||||
<button onclick="loadPosts('share')" id="tab-share" class="px-6 py-4 text-gray-500 hover:text-gray-700">
|
||||
工具分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帖子列表 -->
|
||||
<div id="postList" class="space-y-4">
|
||||
<div class="text-center py-12 text-gray-500">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div id="loadMore" class="text-center py-8 hidden">
|
||||
<button onclick="loadMorePosts()" class="px-6 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50">
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="hidden lg:block w-80">
|
||||
<!-- 发帖按钮 -->
|
||||
<a href="/create" class="block w-full py-3 gradient-bg text-white text-center rounded-lg font-medium hover:opacity-90 mb-6">
|
||||
<i class="ri-add-line mr-1"></i> 发布帖子
|
||||
</a>
|
||||
|
||||
<!-- 热门标签 -->
|
||||
<div id="tags" class="bg-white rounded-lg border border-gray-100 p-4 mb-6">
|
||||
<h3 class="font-medium text-gray-800 mb-3 flex items-center gap-2">
|
||||
<i class="ri-price-tag-3-line text-blue-500"></i> 热门标签
|
||||
</h3>
|
||||
<div id="tagList" class="flex flex-wrap gap-2">
|
||||
<span class="text-gray-400 text-sm">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门工具分享主题 -->
|
||||
<div id="topics" class="bg-white rounded-lg border border-gray-100 p-4">
|
||||
<h3 class="font-medium text-gray-800 mb-3 flex items-center gap-2">
|
||||
<i class="ri-tools-line text-purple-500"></i> 工具分享
|
||||
</h3>
|
||||
<div id="topicList" class="space-y-3">
|
||||
<span class="text-gray-400 text-sm">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 搜索结果弹窗 -->
|
||||
<div id="searchModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<div class="p-4 border-b flex justify-between items-center">
|
||||
<h3 class="font-bold">搜索结果</h3>
|
||||
<button onclick="closeSearchModal()" class="text-gray-400"><i class="ri-close-line text-xl"></i></button>
|
||||
</div>
|
||||
<div id="searchResults" class="p-4 overflow-auto max-h-[60vh]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentType = 'all';
|
||||
let currentPage = 1;
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkLogin();
|
||||
loadPosts('all');
|
||||
loadTags();
|
||||
loadTopics();
|
||||
});
|
||||
|
||||
// 检查登录状态
|
||||
async function checkLogin() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
if (token && user) {
|
||||
currentUser = JSON.parse(user);
|
||||
renderUserArea();
|
||||
} else {
|
||||
renderLoginButton();
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserArea() {
|
||||
document.getElementById('userArea').innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/create" class="px-4 py-2 gradient-bg text-white rounded-lg text-sm hidden sm:block">
|
||||
发布帖子
|
||||
</a>
|
||||
<div class="relative group">
|
||||
<button class="flex items-center gap-2">
|
||||
<img src="${currentUser.avatar}" class="w-8 h-8 rounded-full">
|
||||
<span class="hidden sm:inline text-gray-700">${currentUser.username}</span>
|
||||
</button>
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-100 hidden group-hover:block">
|
||||
<a href="/user/${currentUser.id}" class="block px-4 py-2 text-gray-700 hover:bg-gray-50">个人主页</a>
|
||||
<button onclick="logout()" class="block w-full text-left px-4 py-2 text-red-500 hover:bg-gray-50">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLoginButton() {
|
||||
document.getElementById('userArea').innerHTML = `
|
||||
<a href="/login" class="px-4 py-2 text-gray-600 hover:text-blue-600">登录</a>
|
||||
<a href="/register" class="px-4 py-2 gradient-bg text-white rounded-lg">注册</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// 加载帖子
|
||||
async function loadPosts(type) {
|
||||
currentType = type;
|
||||
currentPage = 1;
|
||||
|
||||
// 更新Tab样式
|
||||
document.querySelectorAll('[id^="tab-"]').forEach(tab => {
|
||||
tab.classList.remove('text-blue-600', 'border-b-2', 'border-blue-600', 'font-medium');
|
||||
tab.classList.add('text-gray-500');
|
||||
});
|
||||
document.getElementById(`tab-${type}`).classList.remove('text-gray-500');
|
||||
document.getElementById(`tab-${type}`).classList.add('text-blue-600', 'border-b-2', 'border-blue-600', 'font-medium');
|
||||
|
||||
const url = type === 'all' ? '/api/posts' : `/api/posts?type=${type}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
renderPosts(data.posts);
|
||||
|
||||
if (data.posts.length >= 20) {
|
||||
document.getElementById('loadMore').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('loadMore').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
const container = document.getElementById('postList');
|
||||
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-12 text-gray-500">暂无帖子</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = posts.map(post => `
|
||||
<a href="/post/${post.id}" class="block bg-white rounded-lg border border-gray-100 p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start gap-4">
|
||||
<img src="${post.author.avatar}" class="w-10 h-10 rounded-full">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-2 py-0.5 rounded ${post.type === 'discussion' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'}">
|
||||
${post.type === 'discussion' ? '技术交流' : '工具分享'}
|
||||
</span>
|
||||
${post.is_pinned ? '<span class="text-xs px-2 py-0.5 rounded bg-red-100 text-red-600">置顶</span>' : ''}
|
||||
</div>
|
||||
<h3 class="font-medium text-gray-900 hover:text-blue-600 mb-1">${post.title}</h3>
|
||||
<p class="text-sm text-gray-500 line-clamp-2">${post.content_preview}</p>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-gray-400">
|
||||
<span>${post.author.username}</span>
|
||||
<span>${new Date(post.created_at).toLocaleDateString()}</span>
|
||||
<span><i class="ri-eye-line"></i> ${post.views}</span>
|
||||
<span><i class="ri-heart-line"></i> ${post.likes}</span>
|
||||
<span><i class="ri-chat-3-line"></i> ${post.replies}</span>
|
||||
</div>
|
||||
${post.tags.length > 0 ? `
|
||||
<div class="flex gap-2 mt-2">
|
||||
${post.tags.slice(0, 3).map(tag => `
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">${tag}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 加载标签
|
||||
async function loadTags() {
|
||||
const res = await fetch('/api/tags');
|
||||
const tags = await res.json();
|
||||
|
||||
const container = document.getElementById('tagList');
|
||||
container.innerHTML = tags.slice(0, 10).map(tag => `
|
||||
<a href="?tag=${tag.name}" class="px-3 py-1 bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full text-sm">
|
||||
${tag.name} (${tag.count})
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 加载工具分享主题
|
||||
async function loadTopics() {
|
||||
const res = await fetch('/api/topics');
|
||||
const topics = await res.json();
|
||||
|
||||
const container = document.getElementById('topicList');
|
||||
container.innerHTML = topics.slice(0, 5).map(topic => `
|
||||
<a href="/topic/${topic.id}" class="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg">
|
||||
<span class="text-2xl">${topic.icon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-800 truncate">${topic.name}</p>
|
||||
<p class="text-xs text-gray-500">${topic.questions_count} 问题 · ${topic.followers} 关注</p>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 搜索功能
|
||||
document.getElementById('searchInput').addEventListener('keyup', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const query = e.target.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
|
||||
const container = document.getElementById('searchResults');
|
||||
|
||||
if (data.posts.length === 0 && data.topics.length === 0) {
|
||||
container.innerHTML = '<p class="text-center text-gray-500 py-8">未找到相关内容</p>';
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
${data.posts.length > 0 ? `
|
||||
<div class="mb-6">
|
||||
<h4 class="font-medium text-gray-700 mb-3">帖子 (${data.posts.length})</h4>
|
||||
<div class="space-y-2">
|
||||
${data.posts.map(p => `
|
||||
<a href="/post/${p.id}" class="block p-3 hover:bg-gray-50 rounded-lg">
|
||||
<p class="font-medium text-gray-800">${p.title}</p>
|
||||
<p class="text-sm text-gray-500">${p.author} · ${new Date(p.created_at).toLocaleDateString()}</p>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${data.topics.length > 0 ? `
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-3">工具分享 (${data.topics.length})</h4>
|
||||
<div class="space-y-2">
|
||||
${data.topics.map(t => `
|
||||
<a href="/topic/${t.id}" class="flex items-center gap-2 p-3 hover:bg-gray-50 rounded-lg">
|
||||
<span class="text-xl">${t.icon}</span>
|
||||
<span class="font-medium text-gray-800">${t.name}</span>
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('searchModal').classList.remove('hidden');
|
||||
document.getElementById('searchModal').classList.add('flex');
|
||||
}
|
||||
});
|
||||
|
||||
function closeSearchModal() {
|
||||
document.getElementById('searchModal').classList.add('hidden');
|
||||
document.getElementById('searchModal').classList.remove('flex');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
frontend/login.html
Normal file
124
frontend/login.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>技术论坛 - 登录</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="min-h-screen flex">
|
||||
<!-- 左侧装饰 -->
|
||||
<div class="hidden lg:flex lg:w-1/2 gradient-bg items-center justify-center p-12">
|
||||
<div class="text-white max-w-md">
|
||||
<h1 class="text-4xl font-bold mb-6">👨💻 技术论坛</h1>
|
||||
<p class="text-xl mb-4">技术交流 & 工具分享</p>
|
||||
<ul class="space-y-3 text-white/90">
|
||||
<li class="flex items-center gap-2">
|
||||
<i class="ri-checkbox-circle-line"></i>
|
||||
技术交流讨论
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i class="ri-checkbox-circle-line"></i>
|
||||
工具框架分享
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<i class="ri-checkbox-circle-line"></i>
|
||||
问题追问解答
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800">欢迎回来</h2>
|
||||
<p class="text-gray-500 mt-2">登录您的账号</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">用户名/邮箱</label>
|
||||
<div class="relative">
|
||||
<i class="ri-user-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="text" id="username" placeholder="请输入用户名或邮箱"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<div class="relative">
|
||||
<i class="ri-lock-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="password" id="password" placeholder="请输入密码"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full py-3 gradient-bg text-white rounded-lg font-medium hover:opacity-90 transition-opacity">
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-gray-500">
|
||||
还没有账号?
|
||||
<a href="/register" class="text-blue-500 hover:text-blue-600 font-medium">立即注册</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMsg" class="mt-4 p-3 bg-red-50 text-red-500 rounded-lg text-sm hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 检查是否已登录
|
||||
if (localStorage.getItem('token')) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showError('请填写用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
showError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请重试');
|
||||
}
|
||||
});
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
268
frontend/post.html
Normal file
268
frontend/post.html
Normal file
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>帖子详情 - 技术论坛</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
.prose { max-width: none; }
|
||||
.prose pre { background: #1f2937; color: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
|
||||
.prose code { background: #f3f4f6; padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-800 flex items-center gap-1">
|
||||
<i class="ri-arrow-left-line"></i> 返回首页
|
||||
</a>
|
||||
<div id="userArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 帖子内容 -->
|
||||
<article id="postContent" class="bg-white rounded-lg border border-gray-100">
|
||||
<div class="p-6">
|
||||
<div class="text-center py-12 text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 回复区域 -->
|
||||
<div id="replySection" class="mt-6 bg-white rounded-lg border border-gray-100 p-6">
|
||||
<h3 class="font-medium text-gray-800 mb-4">发表回复</h3>
|
||||
<div id="replyForm" class="hidden">
|
||||
<textarea id="replyContent" rows="4" placeholder="输入你的回复..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"></textarea>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button onclick="submitReply()" class="px-4 py-2 gradient-bg text-white rounded-lg">
|
||||
发送回复
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loginHint" class="text-center py-4 text-gray-500">
|
||||
<a href="/login" class="text-blue-500 hover:text-blue-600">登录</a> 后参与讨论
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复列表 -->
|
||||
<div id="repliesList" class="mt-6 space-y-4"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentPostId = null;
|
||||
let postLiked = false;
|
||||
|
||||
// 获取帖子ID
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
currentPostId = pathParts[2];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkLogin();
|
||||
loadPost();
|
||||
});
|
||||
|
||||
async function checkLogin() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
if (token && user) {
|
||||
currentUser = JSON.parse(user);
|
||||
document.getElementById('replyForm').classList.remove('hidden');
|
||||
document.getElementById('loginHint').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPost() {
|
||||
const res = await fetch(`/api/posts/${currentPostId}`);
|
||||
const post = await res.json();
|
||||
|
||||
if (post.error) {
|
||||
document.getElementById('postContent').innerHTML = `
|
||||
<div class="p-6 text-center text-gray-500">${post.error}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 渲染帖子
|
||||
document.getElementById('postContent').innerHTML = `
|
||||
<div class="p-6">
|
||||
<!-- 类型标签 -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-2 py-1 rounded text-xs ${post.type === 'discussion' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'}">
|
||||
${post.type === 'discussion' ? '技术交流' : '工具分享'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">${post.title}</h1>
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<div class="flex items-center gap-4 mb-6 pb-6 border-b border-gray-100">
|
||||
<img src="${post.author.avatar}" class="w-12 h-12 rounded-full">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">${post.author.username}</p>
|
||||
<p class="text-sm text-gray-500">${new Date(post.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="prose text-gray-700 leading-relaxed mb-6 whitespace-pre-wrap">${post.content}</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
${post.tags.length > 0 ? `
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
${post.tags.map(tag => `
|
||||
<span class="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">${tag}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 统计和操作 -->
|
||||
<div class="flex items-center justify-between pt-6 border-t border-gray-100">
|
||||
<div class="flex items-center gap-6 text-sm text-gray-500">
|
||||
<span><i class="ri-eye-line mr-1"></i> ${post.views} 浏览</span>
|
||||
<span id="likesCount"><i class="ri-heart-line mr-1"></i> ${post.likes} 赞</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="likePost()" id="likeBtn" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-1">
|
||||
<i class="ri-heart-line"></i> 赞
|
||||
</button>
|
||||
<button onclick="sharePost()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 flex items-center gap-1">
|
||||
<i class="ri-share-line"></i> 分享
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 渲染回复
|
||||
renderReplies(post.replies);
|
||||
}
|
||||
|
||||
function renderReplies(replies) {
|
||||
const container = document.getElementById('repliesList');
|
||||
|
||||
if (!replies || replies.length === 0) {
|
||||
container.innerHTML = '<p class="text-center text-gray-500 py-8">暂无回复</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = replies.map(reply => `
|
||||
<div class="bg-white rounded-lg border border-gray-100 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<img src="${reply.author.avatar}" class="w-8 h-8 rounded-full">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-gray-800">${reply.author.username}</span>
|
||||
<span class="text-xs text-gray-400">${new Date(reply.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">${reply.content}</p>
|
||||
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<button onclick="likeReply('${reply.id}')" class="hover:text-blue-500">
|
||||
<i class="ri-heart-line"></i> ${reply.likes}
|
||||
</button>
|
||||
<button onclick="replyTo('${reply.id}')" class="hover:text-blue-500">
|
||||
<i class="ri-reply-line"></i> 回复
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function submitReply() {
|
||||
if (!currentUser) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = document.getElementById('replyContent').value.trim();
|
||||
if (!content) {
|
||||
alert('请输入回复内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${currentPostId}/reply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('replyContent').value = '';
|
||||
loadPost();
|
||||
} else {
|
||||
alert(data.error || '回复失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
async function likePost() {
|
||||
if (!currentUser) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${currentPostId}/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const btn = document.getElementById('likeBtn');
|
||||
const countEl = document.getElementById('likesCount');
|
||||
|
||||
if (data.liked) {
|
||||
btn.innerHTML = '<i class="ri-heart-fill text-red-500"></i> 已赞';
|
||||
btn.classList.add('border-red-300', 'text-red-500');
|
||||
} else {
|
||||
btn.innerHTML = '<i class="ri-heart-line"></i> 赞';
|
||||
btn.classList.remove('border-red-300', 'text-red-500');
|
||||
}
|
||||
|
||||
countEl.innerHTML = `<i class="ri-heart-line mr-1"></i> ${data.likes_count} 赞`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function sharePost() {
|
||||
const url = window.location.href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert('链接已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
function replyTo(replyId) {
|
||||
document.getElementById('replyContent').focus();
|
||||
document.getElementById('replyContent').placeholder = '回复中...';
|
||||
}
|
||||
|
||||
function likeReply(replyId) {
|
||||
// TODO: 实现回复点赞
|
||||
alert('功能开发中');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
171
frontend/register.html
Normal file
171
frontend/register.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>技术论坛 - 注册</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="min-h-screen flex">
|
||||
<!-- 左侧装饰 -->
|
||||
<div class="hidden lg:flex lg:w-1/2 gradient-bg items-center justify-center p-12">
|
||||
<div class="text-white max-w-md">
|
||||
<h1 class="text-4xl font-bold mb-6">🚀 加入我们</h1>
|
||||
<p class="text-xl mb-4">成为技术社区的一员</p>
|
||||
<div class="space-y-4 mt-8">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ri-code-s-slash-line text-2xl"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">技术交流</h3>
|
||||
<p class="text-sm text-white/80">与同行讨论技术问题</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ri-tools-line text-2xl"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">工具分享</h3>
|
||||
<p class="text-sm text-white/80">分享你使用的工具和框架</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ri-team-line text-2xl"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">社区互动</h3>
|
||||
<p class="text-sm text-white/80">互相帮助,共同成长</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800">创建账号</h2>
|
||||
<p class="text-gray-500 mt-2">填写信息加入社区</p>
|
||||
</div>
|
||||
|
||||
<form id="registerForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
用户名 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="ri-user-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="text" id="username" placeholder="至少2个字符"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
邮箱 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="ri-mail-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="email" id="email" placeholder="your@email.com"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
手机号 <span class="text-gray-400">(可选)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="ri-phone-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="tel" id="phone" placeholder="选填"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
密码 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="ri-lock-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="password" id="password" placeholder="至少6个字符"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
确认密码 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="ri-lock-2-line absolute left-3 top-3 text-gray-400"></i>
|
||||
<input type="password" id="confirmPassword" placeholder="再次输入密码"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full py-3 gradient-bg text-white rounded-lg font-medium hover:opacity-90 transition-opacity mt-6">
|
||||
注册
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-gray-500">
|
||||
已有账号?
|
||||
<a href="/login" class="text-blue-500 hover:text-blue-600 font-medium">立即登录</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMsg" class="mt-4 p-3 bg-red-50 text-red-500 rounded-lg text-sm hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 检查是否已登录
|
||||
if (localStorage.getItem('token')) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const phone = document.getElementById('phone').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm_password = document.getElementById('confirmPassword').value;
|
||||
|
||||
// 验证
|
||||
if (!username || !email || !password || !confirm_password) {
|
||||
showError('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, phone, password, confirm_password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
showError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('网络错误,请重试');
|
||||
}
|
||||
});
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
398
frontend/topic.html
Normal file
398
frontend/topic.html
Normal file
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>工具分享主题 - 技术论坛</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-800 flex items-center gap-1">
|
||||
<i class="ri-arrow-left-line"></i> 返回首页
|
||||
</a>
|
||||
<div id="userArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 py-8">
|
||||
<!-- 主题信息 -->
|
||||
<div id="topicHeader" class="bg-white rounded-lg border border-gray-100 p-6 mb-6">
|
||||
<div class="text-center py-8 text-gray-500">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<div class="bg-white rounded-lg border border-gray-100 mb-6">
|
||||
<div class="flex border-b border-gray-100">
|
||||
<button onclick="showTab('questions')" id="tab-questions" class="flex-1 py-3 text-blue-600 border-b-2 border-blue-600 font-medium">
|
||||
问题讨论
|
||||
</button>
|
||||
<button onclick="showTab('subtopics')" id="tab-subtopics" class="flex-1 py-3 text-gray-500 hover:text-gray-700">
|
||||
子主题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 问题列表 -->
|
||||
<div id="questionsPanel">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-medium text-gray-800">问题列表</h3>
|
||||
<button onclick="showAskModal()" class="px-4 py-2 gradient-bg text-white rounded-lg text-sm">
|
||||
<i class="ri-question-line mr-1"></i> 提问
|
||||
</button>
|
||||
</div>
|
||||
<div id="questionsList" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- 子主题列表 -->
|
||||
<div id="subtopicsPanel" class="hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-medium text-gray-800">子主题</h3>
|
||||
<button onclick="showSubtopicModal()" class="px-4 py-2 gradient-bg text-white rounded-lg text-sm">
|
||||
<i class="ri-add-line mr-1"></i> 添加子主题
|
||||
</button>
|
||||
</div>
|
||||
<div id="subtopicsList" class="space-y-4"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 提问弹窗 -->
|
||||
<div id="askModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-lg">
|
||||
<div class="p-4 border-b flex justify-between items-center">
|
||||
<h3 class="font-bold">提出问题</h3>
|
||||
<button onclick="closeAskModal()" class="text-gray-400"><i class="ri-close-line text-xl"></i></button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<input type="text" id="questionTitle" placeholder="问题标题"
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500">
|
||||
<textarea id="questionContent" rows="4" placeholder="详细描述你的问题..."
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
<button onclick="submitQuestion()" class="mt-3 w-full py-2 gradient-bg text-white rounded-lg">
|
||||
提交问题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子主题弹窗 -->
|
||||
<div id="subtopicModal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-lg">
|
||||
<div class="p-4 border-b flex justify-between items-center">
|
||||
<h3 class="font-bold">添加子主题</h3>
|
||||
<button onclick="closeSubtopicModal()" class="text-gray-400"><i class="ri-close-line text-xl"></i></button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<input type="text" id="subtopicTitle" placeholder="子主题标题"
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500">
|
||||
<textarea id="subtopicContent" rows="4" placeholder="详细内容..."
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
<button onclick="submitSubtopic()" class="mt-3 w-full py-2 gradient-bg text-white rounded-lg">
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentTopicId = null;
|
||||
let topicData = null;
|
||||
|
||||
// 获取主题ID
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
currentTopicId = pathParts[2];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkLogin();
|
||||
loadTopic();
|
||||
});
|
||||
|
||||
async function checkLogin() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
if (token && user) {
|
||||
currentUser = JSON.parse(user);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopic() {
|
||||
const res = await fetch(`/api/topics/${currentTopicId}`);
|
||||
topicData = await res.json();
|
||||
|
||||
if (topicData.error) {
|
||||
document.getElementById('topicHeader').innerHTML = `
|
||||
<div class="p-6 text-center text-gray-500">${topicData.error}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 渲染主题头部
|
||||
document.getElementById('topicHeader').innerHTML = `
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-5xl">${topicData.icon}</span>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">${topicData.name}</h1>
|
||||
<p class="text-gray-600 mb-4">${topicData.description || '暂无描述'}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="ri-user-line mr-1"></i> 创建者: ${topicData.author.username}</span>
|
||||
<span><i class="ri-question-line mr-1"></i> ${topicData.questions.length} 问题</span>
|
||||
<span><i class="ri-heart-line mr-1"></i> ${topicData.followers} 关注</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="followTopic()" id="followBtn" class="px-4 py-2 border border-blue-500 text-blue-500 rounded-lg hover:bg-blue-50">
|
||||
<i class="ri-heart-line mr-1"></i> 关注
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderQuestions(topicData.questions);
|
||||
renderSubtopics(topicData.sub_topics);
|
||||
}
|
||||
|
||||
function renderQuestions(questions) {
|
||||
const container = document.getElementById('questionsList');
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
container.innerHTML = '<p class="text-center text-gray-500 py-8">暂无问题</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = questions.map(q => `
|
||||
<div class="bg-white rounded-lg border border-gray-100 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-center min-w-[60px]">
|
||||
<div class="text-lg font-bold text-gray-800">${q.answers.length}</div>
|
||||
<div class="text-xs text-gray-500">回答</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-gray-800 hover:text-blue-600 cursor-pointer" onclick="toggleAnswers('${q.id}')">
|
||||
${q.title}
|
||||
</h4>
|
||||
${q.content ? `<p class="text-sm text-gray-500 mt-1">${q.content.substring(0, 100)}...</p>` : ''}
|
||||
<div class="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<span>${q.author.username}</span>
|
||||
<span>${new Date(q.created_at).toLocaleDateString()}</span>
|
||||
<span><i class="ri-eye-line"></i> ${q.views}</span>
|
||||
</div>
|
||||
|
||||
<!-- 回答列表 -->
|
||||
<div id="answers-${q.id}" class="hidden mt-4 pt-4 border-t border-gray-100 space-y-3">
|
||||
${q.answers.length > 0 ? q.answers.map(a => `
|
||||
<div class="p-3 bg-gray-50 rounded">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img src="${a.author.avatar}" class="w-6 h-6 rounded-full">
|
||||
<span class="text-sm font-medium">${a.author.username}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700">${a.content}</p>
|
||||
</div>
|
||||
`).join('') : '<p class="text-sm text-gray-500">暂无回答</p>'}
|
||||
|
||||
<!-- 回答输入 -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<input type="text" id="answer-${q.id}" placeholder="写下你的回答..."
|
||||
class="flex-1 px-3 py-2 border border-gray-200 rounded text-sm focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="submitAnswer('${q.id}')" class="px-3 py-2 bg-blue-500 text-white rounded text-sm">
|
||||
回答
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSubtopics(subtopics) {
|
||||
const container = document.getElementById('subtopicsList');
|
||||
|
||||
if (!subtopics || subtopics.length === 0) {
|
||||
container.innerHTML = '<p class="text-center text-gray-500 py-8">暂无子主题</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = subtopics.map(st => `
|
||||
<div class="bg-white rounded-lg border border-gray-100 p-4">
|
||||
<h4 class="font-medium text-gray-800 mb-2">${st.title}</h4>
|
||||
<p class="text-sm text-gray-600">${st.content || ''}</p>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-gray-400">
|
||||
<span>${st.author.username}</span>
|
||||
<span>${new Date(st.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
document.getElementById('questionsPanel').classList.toggle('hidden', tab !== 'questions');
|
||||
document.getElementById('subtopicsPanel').classList.toggle('hidden', tab !== 'subtopics');
|
||||
|
||||
document.querySelectorAll('[id^="tab-"]').forEach(t => {
|
||||
t.classList.remove('text-blue-600', 'border-b-2', 'border-blue-600', 'font-medium');
|
||||
t.classList.add('text-gray-500');
|
||||
});
|
||||
document.getElementById(`tab-${tab}`).classList.remove('text-gray-500');
|
||||
document.getElementById(`tab-${tab}`).classList.add('text-blue-600', 'border-b-2', 'border-blue-600', 'font-medium');
|
||||
}
|
||||
|
||||
function toggleAnswers(questionId) {
|
||||
document.getElementById(`answers-${questionId}`).classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function showAskModal() {
|
||||
if (!currentUser) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
document.getElementById('askModal').classList.remove('hidden');
|
||||
document.getElementById('askModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeAskModal() {
|
||||
document.getElementById('askModal').classList.add('hidden');
|
||||
document.getElementById('askModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function submitQuestion() {
|
||||
const title = document.getElementById('questionTitle').value.trim();
|
||||
const content = document.getElementById('questionContent').value.trim();
|
||||
|
||||
if (!title) {
|
||||
alert('请输入问题标题');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/topics/${currentTopicId}/question`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, content })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
closeAskModal();
|
||||
loadTopic();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnswer(questionId) {
|
||||
const input = document.getElementById(`answer-${questionId}`);
|
||||
const content = input.value.trim();
|
||||
|
||||
if (!content) {
|
||||
alert('请输入回答内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/topics/${currentTopicId}/question/${questionId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
loadTopic();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
function showSubtopicModal() {
|
||||
if (!currentUser) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
document.getElementById('subtopicModal').classList.remove('hidden');
|
||||
document.getElementById('subtopicModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeSubtopicModal() {
|
||||
document.getElementById('subtopicModal').classList.add('hidden');
|
||||
document.getElementById('subtopicModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function submitSubtopic() {
|
||||
const title = document.getElementById('subtopicTitle').value.trim();
|
||||
const content = document.getElementById('subtopicContent').value.trim();
|
||||
|
||||
if (!title) {
|
||||
alert('请输入子主题标题');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/topics/${currentTopicId}/subtopic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ title, content })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
closeSubtopicModal();
|
||||
loadTopic();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
async function followTopic() {
|
||||
if (!currentUser) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/topics/${currentTopicId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const btn = document.getElementById('followBtn');
|
||||
if (data.followed) {
|
||||
btn.innerHTML = '<i class="ri-heart-fill mr-1"></i> 已关注';
|
||||
btn.classList.remove('border-blue-500', 'text-blue-500');
|
||||
btn.classList.add('bg-blue-500', 'text-white');
|
||||
} else {
|
||||
btn.innerHTML = '<i class="ri-heart-line mr-1"></i> 关注';
|
||||
btn.classList.add('border-blue-500', 'text-blue-500');
|
||||
btn.classList.remove('bg-blue-500', 'text-white');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
101
frontend/user.html
Normal file
101
frontend/user.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户主页 - 技术论坛</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<style>
|
||||
.gradient-bg { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white border-b border-gray-100 sticky top-0 z-50">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-800 flex items-center gap-1">
|
||||
<i class="ri-arrow-left-line"></i> 返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 用户信息 -->
|
||||
<div id="userProfile" class="bg-white rounded-lg border border-gray-100 p-6 mb-6">
|
||||
<div class="text-center py-8 text-gray-500">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户帖子 -->
|
||||
<h3 class="font-medium text-gray-800 mb-4">发布的帖子</h3>
|
||||
<div id="userPosts" class="space-y-4"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const userId = pathParts[2];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUserProfile();
|
||||
});
|
||||
|
||||
async function loadUserProfile() {
|
||||
const res = await fetch(`/api/user/${userId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('userProfile').innerHTML = `
|
||||
<div class="p-6 text-center text-gray-500">${data.error}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 渲染用户信息
|
||||
document.getElementById('userProfile').innerHTML = `
|
||||
<div class="flex items-center gap-6">
|
||||
<img src="${data.user.avatar}" class="w-24 h-24 rounded-full">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">${data.user.username}</h1>
|
||||
<p class="text-gray-500 mt-1">${data.user.bio || '这个人很懒,什么都没写'}</p>
|
||||
<div class="flex items-center gap-6 mt-4 text-sm text-gray-500">
|
||||
<span><i class="ri-file-text-line mr-1"></i> ${data.user.posts_count} 帖子</span>
|
||||
<span><i class="ri-chat-3-line mr-1"></i> ${data.user.replies_count} 回复</span>
|
||||
<span><i class="ri-calendar-line mr-1"></i> ${new Date(data.user.created_at).toLocaleDateString()} 加入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 渲染帖子列表
|
||||
renderPosts(data.posts);
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
const container = document.getElementById('userPosts');
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
container.innerHTML = '<p class="text-center text-gray-500 py-8">暂无帖子</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = posts.map(post => `
|
||||
<a href="/post/${post.id}" class="block bg-white rounded-lg border border-gray-100 p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded ${post.type === 'discussion' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'}">
|
||||
${post.type === 'discussion' ? '技术交流' : '工具分享'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="font-medium text-gray-800 hover:text-blue-600">${post.title}</h4>
|
||||
<div class="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<span><i class="ri-heart-line"></i> ${post.likes}</span>
|
||||
<span><i class="ri-chat-3-line"></i> ${post.replies}</span>
|
||||
<span>${new Date(post.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user