import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as forumApi from '../api/forum.js'; import { uploadIcon } from '../api/upload.js'; import { me } from '../api/auth.js'; import { getToken } from '../api/client.js'; import { showSnackbar, normalizePagedList } from '../lib/utils.js'; import ForumIcon from '../components/ForumIcon.jsx'; import Avatar from '../components/Avatar.jsx'; const PAGE_SIZE = 20; const DURATIONS = [ { value: 1, label: '1 天' }, { value: 7, label: '7 天' }, { value: 30, label: '30 天' }, { value: 'forever', label: '永久' }, ]; const TABS = [ { key: 'posts', label: '帖子管理', icon: 'forum' }, { key: 'announcement', label: '公告编辑', icon: 'campaign' }, { key: 'mutes', label: '用户禁言', icon: 'block' }, { key: 'profile', label: '版块设置', icon: 'tune' }, ]; // 图标底色色板(与后台 ForumManage 同源 12 色;留空 = 按名称哈希自动配色) const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e', '#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8']; /** 版主名提取:兼容数组 / 逗号字符串 */ function moderatorNamesOf(cat) { const m = cat && cat.moderators; if (Array.isArray(m)) { return m.map((x) => (typeof x === 'string' ? x : (x && x.username) || '')).filter(Boolean); } if (typeof m === 'string') return m.split(',').map((s) => s.trim()).filter(Boolean); return []; } /** * 单版块管理台(路由 /forum/manage/:id,版主/管理员): * Tab 1 帖子管理(搜索 + 分页 + 置顶/加精切换/删除) * Tab 2 公告编辑(PUT /categories/:id/announcement) * Tab 3 用户禁言(列表 / 添加固定时长 1·7·30天·永久 / 解除;版主只读展示) * Tab 4 版块设置(名称/描述/图标/图标底色 → PUT /categories/:id/profile 部分更新) * 权限:非版主且非 admin → 无权限提示。 */ export default function ForumManageCategory() { const { id } = useParams(); const navigate = useNavigate(); const catId = parseInt(id, 10); const [user, setUser] = useState(null); const [managedIds, setManagedIds] = useState([]); const [permission, setPermission] = useState('checking'); // checking | ok | denied const [cat, setCat] = useState(null); // 版块详情(含聚合/公告) const [catMissing, setCatMissing] = useState(false); const [tab, setTab] = useState('posts'); // ── Tab1 帖子管理 ── const [qInput, setQInput] = useState(''); // 输入框(回车/点按钮才生效) const [q, setQ] = useState(''); // 已应用的关键词 const [page, setPage] = useState(1); const [pageData, setPageData] = useState(null); const [postsBusy, setPostsBusy] = useState(false); const [acting, setActing] = useState(false); // ── Tab2 公告编辑 ── const [annText, setAnnText] = useState(''); const [annSaving, setAnnSaving] = useState(false); // ── Tab3 用户禁言 ── const [mutes, setMutes] = useState([]); const [muteUsername, setMuteUsername] = useState(''); const [muteDuration, setMuteDuration] = useState(7); const [muteBusy, setMuteBusy] = useState(false); // ── Tab4 版块设置 ── const [profile, setProfile] = useState({ name: '', description: '', icon: '', icon_color: '' }); const [profileSaving, setProfileSaving] = useState(false); const [iconUploading, setIconUploading] = useState(false); const iconFileRef = useRef(null); const canManage = !!user && (user.role === 'admin' || managedIds.includes(catId)); // 权限 + 版块信息加载 useEffect(() => { if (!getToken()) { navigate('/login.html'); return; } let mounted = true; (async () => { try { const u = await me(); const ids = await forumApi.listModerated(); if (!mounted) return; setUser(u); setManagedIds(ids); if (u.role === 'admin' || ids.includes(catId)) { setPermission('ok'); const c = await forumApi.getCategory(catId).catch(() => null); if (!mounted) return; if (c) { setCat(c); setAnnText(c.announcement || ''); setProfile({ name: c.name || '', description: c.description || '', icon: c.icon || '', icon_color: c.icon_color || '', }); } else setCatMissing(true); } else { setPermission('denied'); } } catch (e) { if (mounted) setPermission('denied'); } })(); return () => { mounted = false; }; }, [catId, navigate]); const loadPosts = useCallback(async () => { if (!catId) return; setPostsBusy(true); try { const data = await forumApi.listPosts({ categoryId: catId, page, q: q || undefined }); setPageData(normalizePagedList(data)); } catch (e) { showSnackbar(e.message); setPageData({ items: [], total: 0, page, pageSize: PAGE_SIZE }); } finally { setPostsBusy(false); } }, [catId, page, q]); const loadMutes = useCallback(async () => { if (!catId) return; try { const ms = await forumApi.listMutes(catId); setMutes(Array.isArray(ms) ? ms : []); } catch (e) { showSnackbar(e.message); setMutes([]); } }, [catId]); // 帖子列表加载(权限通过后) useEffect(() => { if (permission === 'ok') loadPosts(); }, [permission, loadPosts]); // 切到禁言 Tab 时拉取 useEffect(() => { if (permission === 'ok' && tab === 'mutes') loadMutes(); }, [permission, tab, loadMutes]); const items = (pageData && pageData.items) || []; const totalPages = pageData ? Math.max(1, Math.ceil(pageData.total / (pageData.pageSize || PAGE_SIZE))) : 1; const moderatorNames = useMemo(() => (cat ? moderatorNamesOf(cat) : []), [cat]); const goPage = (p) => { if (p < 1 || p > totalPages || p === page) return; setPage(p); }; const search = () => { setQ(qInput.trim()); setPage(1); }; // ── 帖子操作 ── const togglePin = async (p) => { if (acting) return; setActing(true); try { await forumApi.setPinned(p.id, !p.is_pinned); showSnackbar(p.is_pinned ? '已取消置顶' : '已置顶'); await loadPosts(); } catch (e) { showSnackbar(e.message); } setActing(false); }; const toggleEssence = async (p) => { if (acting) return; setActing(true); try { await forumApi.setEssence(p.id, !p.is_essence); showSnackbar(p.is_essence ? '已取消加精' : '已加精'); await loadPosts(); } catch (e) { showSnackbar(e.message); } setActing(false); }; const confirmDeletePost = async (p) => { if (!window.confirm(`确定删除帖子「${p.title}」?回复将一并删除`)) return; if (acting) return; setActing(true); try { await forumApi.deletePost(p.id); showSnackbar('已删除'); if (items.length === 1 && page > 1) setPage((cur) => cur - 1); else await loadPosts(); } catch (e) { showSnackbar(e.message); } setActing(false); }; // ── 公告 ── const saveAnnouncement = async () => { setAnnSaving(true); try { await forumApi.updateAnnouncement(catId, annText.trim()); showSnackbar('公告已更新'); const c = await forumApi.getCategory(catId).catch(() => null); if (c) setCat(c); } catch (e) { showSnackbar(e.message); } setAnnSaving(false); }; // ── 版块设置 ── // 图标上传:前端先拦类型(png/jpg/jpeg/gif/webp,不含 svg)与 ≤1MB,成功后把返回 url 填入 icon 字段 const handleIconFile = async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; // 允许连续选择同一文件 if (!f) return; const IMG_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; if (!IMG_TYPES.includes(f.type)) { showSnackbar('仅支持 png/jpg/jpeg/gif/webp 图片'); return; } if (f.size > 1024 * 1024) { showSnackbar('图片不能超过 1MB'); return; } setIconUploading(true); try { const data = await uploadIcon(f); if (data && data.url) { setProfile((p) => ({ ...p, icon: data.url })); // 实时预览自动生效 showSnackbar('图标已上传,记得保存设置'); } else { showSnackbar('上传失败,请重试'); } } catch (err) { showSnackbar(err.message); } setIconUploading(false); }; const saveProfile = async () => { const name = profile.name.trim(); if (!name) { showSnackbar('名称不能为空'); return; } // 部分更新:只提交与当前值不同的字段(icon_color 留空 = 清除 → 按名称哈希配色) const fields = {}; if (name !== (cat.name || '')) fields.name = name; if (profile.description.trim() !== (cat.description || '')) fields.description = profile.description.trim(); if (profile.icon.trim() !== (cat.icon || '')) fields.icon = profile.icon.trim(); if ((profile.icon_color || '') !== (cat.icon_color || '')) fields.icon_color = profile.icon_color.trim(); if (Object.keys(fields).length === 0) { showSnackbar('没有改动'); return; } setProfileSaving(true); try { const res = await forumApi.updateCategoryProfile(catId, fields); showSnackbar((res && res.message) || '已保存'); // 用后端返回的已更新字段合并进 cat,避免整页刷新 if (res && res.category) { setCat((prev) => (prev ? { ...prev, ...res.category } : prev)); setProfile((p) => ({ ...p, name: res.category.name, description: res.category.description || '', icon: res.category.icon || '', icon_color: res.category.icon_color || '', })); } } catch (e) { showSnackbar(e.message); } setProfileSaving(false); }; // ── 禁言 ── const addMute = async () => { const name = muteUsername.trim(); if (!name) { showSnackbar('请输入用户名'); return; } setMuteBusy(true); try { await forumApi.createMute(catId, name, muteDuration); showSnackbar('已禁言'); setMuteUsername(''); loadMutes(); } catch (e) { showSnackbar(e.message); } setMuteBusy(false); }; const unmute = async (m) => { if (!window.confirm(`确定解除「${m.username || '用户#' + m.user_id}」的禁言?`)) return; try { await forumApi.deleteMute(catId, m.user_id); showSnackbar('已解除'); loadMutes(); } catch (e) { showSnackbar(e.message); } }; // 加载中 if (permission === 'checking') { return
没有管理权限
你不是该版块的版主,无法进入管理台
返回论坛版块不存在
返回我的版块{cat.description || '暂无描述'}
暂无帖子
展示在版块顶部,留空可清除当前公告
被禁用户在本版块发帖 / 回复将被拒绝,到期自动解除
暂无禁言
) : ( mutes.map((m) => { const permanent = m.permanent || m.muted_until == null || m.muted_until === '永久'; return (当前版主(仅管理员可更改):{moderatorNames.join('、')}
修改版块的名称、描述与图标,保存后前台版块索引与版块页即时生效
{/* 实时预览 */}