283 lines
11 KiB
React
283 lines
11 KiB
React
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import * as forumApi from '../api/forum.js';
|
||
import { me } from '../api/auth.js';
|
||
import { getToken } from '../api/client.js';
|
||
import { uploadFile } from '../api/upload.js';
|
||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||
|
||
/** 板块的子分类列表 */
|
||
function subCatsOf(cat) {
|
||
if (!cat || !cat.sub_categories) return [];
|
||
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
|
||
}
|
||
|
||
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
|
||
export default function Forum() {
|
||
const navigate = useNavigate();
|
||
const [categories, setCategories] = useState([]);
|
||
const [currentCatId, setCurrentCatId] = useState(null); // null=全部最新
|
||
const [filterSub, setFilterSub] = useState('');
|
||
const [posts, setPosts] = useState(null); // null=加载中
|
||
const [loadError, setLoadError] = useState('');
|
||
const [user, setUser] = useState(null);
|
||
|
||
// 发帖弹窗状态
|
||
const [showNewPost, setShowNewPost] = useState(false);
|
||
const [npCategory, setNpCategory] = useState('');
|
||
const [npSub, setNpSub] = useState('');
|
||
const [npTitle, setNpTitle] = useState('');
|
||
const [npContent, setNpContent] = useState('');
|
||
const [npStatus, setNpStatus] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const fileRef = useRef(null);
|
||
|
||
const load = useCallback(async (catId, sub) => {
|
||
setPosts(null);
|
||
setLoadError('');
|
||
try {
|
||
const ps = catId ? await forumApi.listPosts(catId) : await forumApi.listPosts();
|
||
const filtered = sub ? (ps || []).filter((p) => p.sub_category === sub) : (ps || []);
|
||
setPosts(filtered);
|
||
} catch (e) {
|
||
setLoadError(e.message || '加载失败');
|
||
setPosts([]);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
forumApi.listCategories().then((cs) => setCategories(cs || [])).catch(() => {});
|
||
if (getToken()) me().then(setUser).catch(() => setUser(null));
|
||
else setUser(null);
|
||
load(null, '');
|
||
}, [load]);
|
||
|
||
const selectCategory = (catId) => {
|
||
setCurrentCatId(catId);
|
||
setFilterSub('');
|
||
load(catId, '');
|
||
};
|
||
|
||
const showAllPosts = () => {
|
||
setCurrentCatId(null);
|
||
setFilterSub('');
|
||
load(null, '');
|
||
};
|
||
|
||
// 发帖按钮:未登录显示"登录发帖"并跳登录页(照 forum.js)
|
||
const handleNewPostBtn = () => {
|
||
if (user) {
|
||
setNpCategory(categories.length ? String(categories[0].id) : '');
|
||
setNpSub('');
|
||
setNpTitle('');
|
||
setNpContent('');
|
||
setNpStatus('');
|
||
setShowNewPost(true);
|
||
} else {
|
||
navigate('/login.html');
|
||
}
|
||
};
|
||
|
||
const handleCategoryChange = (e) => {
|
||
setNpCategory(e.target.value);
|
||
setNpSub(''); // 切换板块后重置子分类
|
||
};
|
||
|
||
const doUpload = async (file) => {
|
||
try {
|
||
const data = await uploadFile(file);
|
||
setNpContent((c) => c + '\n' + data.tag + '\n');
|
||
setNpStatus('已插入: ' + data.tag);
|
||
} catch (e) {
|
||
showSnackbar(e.message);
|
||
}
|
||
};
|
||
|
||
const handleFileSelect = (e) => {
|
||
const f = e.target.files && e.target.files[0];
|
||
if (f) doUpload(f);
|
||
e.target.value = '';
|
||
};
|
||
|
||
const submitPost = async () => {
|
||
if (!npTitle.trim() || !npContent.trim()) { showSnackbar('标题和内容不能为空'); return; }
|
||
setSubmitting(true);
|
||
try {
|
||
const base = {
|
||
category_id: parseInt(npCategory, 10),
|
||
title: npTitle.trim(),
|
||
content: npContent.trim(),
|
||
sub_category: npSub,
|
||
use_markdown: 1,
|
||
};
|
||
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
|
||
const cap = await captchaRequired('forum');
|
||
if (cap && cap.required) {
|
||
const result = await showCaptcha('forum');
|
||
if (result === null) { setSubmitting(false); return; } // 取消
|
||
applyCaptchaResult(base, result);
|
||
}
|
||
await forumApi.createPost(base);
|
||
showSnackbar('发布成功');
|
||
setShowNewPost(false);
|
||
if (currentCatId) selectCategory(currentCatId);
|
||
else showAllPosts();
|
||
} catch (e) {
|
||
showSnackbar(e.message);
|
||
}
|
||
setSubmitting(false);
|
||
};
|
||
|
||
const currentCat = categories.find((c) => c.id === currentCatId);
|
||
const subCats = subCatsOf(currentCat);
|
||
|
||
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
||
|
||
return (
|
||
<div className="forum-layout">
|
||
<h1 className="sr-only">论坛</h1>
|
||
<aside className="forum-sidebar">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
|
||
{user ? '发帖' : '登录发帖'}
|
||
</button>
|
||
</div>
|
||
<div className="forum-cat-list">
|
||
{categories.map((c) => (
|
||
<button
|
||
type="button"
|
||
key={c.id}
|
||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
||
onClick={() => selectCategory(c.id)}
|
||
>
|
||
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
|
||
{c.announcement ? (
|
||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
||
<button
|
||
type="button"
|
||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
||
onClick={showAllPosts}
|
||
style={{ fontWeight: 500 }}
|
||
>
|
||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||
</button>
|
||
</aside>
|
||
|
||
<div className="forum-content">
|
||
{currentCat && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
||
<span className="chip chip-static">板块</span>
|
||
</div>
|
||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
||
{currentCat.announcement && (
|
||
<div className="announcement-bar">
|
||
<span className="material-icons ann-icon">campaign</span>
|
||
<span className="ann-content">{currentCat.announcement}</span>
|
||
</div>
|
||
)}
|
||
{subCats.length > 0 && (
|
||
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
||
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
|
||
{subCats.map((s) => (
|
||
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
|
||
{loadError && <div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败</p></div>}
|
||
{posts !== null && !loadError && posts.length === 0 && (
|
||
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
|
||
)}
|
||
{posts && posts.length > 0 && (
|
||
<div className="forum-post-list">
|
||
{posts.map((p) => {
|
||
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
|
||
return (
|
||
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
|
||
<div className="post-title">{p.title}</div>
|
||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span>{p.author_name || '匿名'}</span>
|
||
<span>{p.created_at}</span>
|
||
<span>{p.reply_count || 0} 回复</span>
|
||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
||
<span className="chip chip-static">{catName}</span>
|
||
{p.sub_category ? (
|
||
<span className="chip chip-tonal">{p.sub_category}</span>
|
||
) : null}
|
||
</div>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 发帖弹窗 */}
|
||
{showNewPost && (
|
||
<div
|
||
ref={npDialogRef}
|
||
className="dialog-overlay active"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="发布新帖"
|
||
style={{ display: 'flex', zIndex: 9999 }}
|
||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||
onKeyDown={npDialogKey}
|
||
>
|
||
<div className="dialog">
|
||
<h3>发布新帖</h3>
|
||
<div className="form-group">
|
||
<label>板块</label>
|
||
<select value={npCategory} onChange={handleCategoryChange}>
|
||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>帖子分类(可选)</label>
|
||
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
|
||
<option value="">无</option>
|
||
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
|
||
<option key={s} value={s}>{s}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>标题 *</label>
|
||
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>内容 *</label>
|
||
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
|
||
<span className="material-icons">upload</span> 上传附件
|
||
</button>
|
||
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
|
||
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|