Files
rainblogweb/frontend/src/pages/ForumManageCategory.jsx
T

699 lines
27 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div className="loading"><div className="spinner"></div></div>;
}
// 无权限
if (permission === 'denied') {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>没有管理权限</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>你不是该版块的版主无法进入管理台</p>
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回论坛</Link>
</div>
);
}
// 版块不存在
if (catMissing || !cat) {
return (
<div className="empty-state">
<div className="empty-icon">⚠️</div>
<p>版块不存在</p>
<Link to="/forum/manage" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回我的版块</Link>
</div>
);
}
return (
<div className="forum-manage-cat">
{/* 面包屑:论坛 / 管理 / 版块名 */}
<nav className="breadcrumbs" aria-label="面包屑">
<Link to="/forum.html">论坛</Link>
<span className="sep">/</span>
<Link to="/forum/manage">管理</Link>
<span className="sep">/</span>
<span>{cat.name}</span>
</nav>
{/* 版块信息 */}
<div className="card category-head">
<ForumIcon icon={cat.icon} name={cat.name} iconColor={cat.icon_color} size={48} />
<div className="category-head-main">
<h1>{cat.name} · 管理台</h1>
<p className="category-head-desc">{cat.description || '暂无描述'}</p>
<div className="category-head-stats">
<span>{typeof cat.post_count === 'number' ? `${cat.post_count} 帖子` : ''}</span>
{cat.today_count ? <span>今日 {cat.today_count}</span> : null}
{moderatorNames.length > 0 ? <span>版主{moderatorNames.join('、')}</span> : null}
</div>
</div>
<div className="category-head-actions">
<Link to={'/forum/c/' + catId} className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>visibility</span> 查看版块
</Link>
<Link to="/forum/manage" className="btn btn-text btn-sm">全部版块</Link>
</div>
</div>
{/* Tab 栏 */}
<div className="manage-tabs" role="tablist" aria-label="管理功能">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={'manage-tab' + (tab === t.key ? ' active' : '')}
onClick={() => setTab(t.key)}
>
<span className="material-icons" aria-hidden="true">{t.icon}</span> {t.label}
{t.key === 'mutes' && mutes.length > 0 ? <span className="manage-tab-count">{mutes.length}</span> : null}
</button>
))}
</div>
{/* Tab 1:帖子管理 */}
{tab === 'posts' && (
<div className="card manage-card">
<div className="manage-search">
<input
type="text"
placeholder="搜索帖子标题…"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search(); }}
aria-label="搜索帖子标题"
/>
<button className="btn btn-tonal btn-sm" onClick={search}>搜索</button>
</div>
{postsBusy && <div className="loading" style={{ padding: 24 }}><div className="spinner"></div></div>}
{!postsBusy && items.length === 0 && (
<div className="empty-state" style={{ padding: '24px 0' }}>
<div className="empty-icon">📝</div><p>暂无帖子</p>
</div>
)}
{!postsBusy && items.map((p) => {
// 站长保护:站长(username='admin')的帖子仅站长本人可操作(列表带作者信息 → 前端隐藏操作)
const isOwnerPost = (p.author_name || '') === 'admin';
const canActOnPost = !isOwnerPost || (user && p.author_id === user.id);
return (
<div className="manage-post-row" key={p.id}>
<div className="manage-post-main">
<div className="manage-post-title">
<span className="manage-post-title-text">{p.title}</span>
{p.is_pinned ? <span className="post-badge pin">📌</span> : null}
{p.is_essence ? <span className="post-badge essence"></span> : null}
</div>
<div className="manage-post-meta">
<Avatar src={p.author_avatar} name={p.author_name} size={22} className="avatar-sm" to={p.author_id ? `/u/${p.author_id}` : undefined} />
<span>{p.author_name || '匿名'}</span>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
{p.sub_category ? <span className="chip chip-tonal">{p.sub_category}</span> : null}
</div>
</div>
{canActOnPost ? (
<div className="manage-post-actions">
<button
type="button"
className={'manage-action' + (p.is_pinned ? ' on' : '')}
title={p.is_pinned ? '取消置顶' : '置顶'}
aria-label={p.is_pinned ? '取消置顶' : '置顶'}
aria-pressed={!!p.is_pinned}
disabled={acting}
onClick={() => togglePin(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>push_pin</span>
</button>
<button
type="button"
className={'manage-action' + (p.is_essence ? ' on' : '')}
title={p.is_essence ? '取消加精' : '加精'}
aria-label={p.is_essence ? '取消加精' : '加精'}
aria-pressed={!!p.is_essence}
disabled={acting}
onClick={() => toggleEssence(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>star</span>
</button>
<button
type="button"
className="manage-action danger"
title="删除帖子"
aria-label="删除帖子"
disabled={acting}
onClick={() => confirmDeletePost(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>delete</span>
</button>
</div>
) : (
<span className="chip chip-static" title="站长帖子仅站长本人可操作">🔒 站长帖</span>
)}
</div>
);
})}
{totalPages > 1 && (
<div className="pagination">
<button type="button" className="btn btn-tonal btn-sm" disabled={page <= 1} onClick={() => goPage(page - 1)}>
<span className="material-icons" style={{ fontSize: 16 }}>chevron_left</span> 上一页
</button>
<span className="page-info"> {page} / {totalPages} </span>
<button type="button" className="btn btn-tonal btn-sm" disabled={page >= totalPages} onClick={() => goPage(page + 1)}>
下一页 <span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>
</button>
</div>
)}
</div>
)}
{/* Tab 2:公告编辑 */}
{tab === 'announcement' && (
<div className="card manage-card" style={{ maxWidth: 640 }}>
<h3 className="manage-card-title">板块公告</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
展示在版块顶部留空可清除当前公告
</p>
<div className="form-group">
<label htmlFor="annText">公告内容</label>
<textarea
id="annText"
value={annText}
onChange={(e) => setAnnText(e.target.value)}
style={{ minHeight: 100 }}
/>
</div>
<button className="btn btn-filled" onClick={saveAnnouncement} disabled={annSaving}>
{annSaving ? '保存中…' : '保存公告'}
</button>
</div>
)}
{/* Tab 3:用户禁言 */}
{tab === 'mutes' && (
<>
<div className="card manage-card" style={{ maxWidth: 640 }}>
<h3 className="manage-card-title">添加禁言</h3>
<div className="mute-form">
<input
type="text"
placeholder="输入要禁言的用户名"
value={muteUsername}
onChange={(e) => setMuteUsername(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMute(); }}
aria-label="被禁言用户名"
/>
<select
value={muteDuration}
onChange={(e) => {
const v = e.target.value;
setMuteDuration(v === 'forever' ? v : parseInt(v, 10));
}}
aria-label="禁言时长"
>
{DURATIONS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<button className="btn btn-filled btn-sm" onClick={addMute} disabled={muteBusy || !muteUsername.trim()}>
{muteBusy ? '提交中…' : '禁言'}
</button>
</div>
<p className="text-muted" style={{ fontSize: 12 }}>被禁用户在本版块发帖 / 回复将被拒绝到期自动解除</p>
</div>
<div className="card manage-card">
<h3 className="manage-card-title">禁言列表 ({mutes.length})</h3>
{mutes.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无禁言</p>
) : (
mutes.map((m) => {
const permanent = m.permanent || m.muted_until == null || m.muted_until === '永久';
return (
<div className="mute-row" key={m.user_id}>
<div className="mute-main">
<div className="mute-user">{m.username || '用户#' + m.user_id}</div>
<div className={'mute-expiry' + (permanent ? ' permanent' : '')}>
{permanent ? '永久禁言' : `到期:${m.muted_until}`}
</div>
</div>
<button
className="btn btn-text btn-sm"
style={{ color: 'var(--md-ref-error)' }}
onClick={() => unmute(m)}
>
解除
</button>
</div>
);
})
)}
</div>
{moderatorNames.length > 0 && (
<div className="card manage-card">
<h3 className="manage-card-title">版主</h3>
<p className="text-muted" style={{ fontSize: 13 }}>
当前版主仅管理员可更改):{moderatorNames.join('、')}
</p>
</div>
)}
</>
)}
{/* Tab 4:版块设置 */}
{tab === 'profile' && (
<div className="card manage-card profile-form">
<h3 className="manage-card-title">版块设置</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16 }}>
修改版块的名称描述与图标保存后前台版块索引与版块页即时生效
</p>
{/* 实时预览 */}
<div className="profile-preview">
<ForumIcon icon={profile.icon} name={profile.name} iconColor={profile.icon_color} size={48} />
<div className="profile-preview-hint">
预览{profile.name || '(未填写名称)'}
{!profile.icon && !profile.icon_color ? ' · 图标与底色按名称自动生成' : ''}
</div>
</div>
<div className="form-group">
<label htmlFor="profileName">名称 *</label>
<input
id="profileName"
type="text"
value={profile.name}
onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
placeholder="版块名称(≤50 字)"
maxLength={50}
/>
</div>
<div className="form-group">
<label htmlFor="profileDesc">描述</label>
<textarea
id="profileDesc"
value={profile.description}
onChange={(e) => setProfile((p) => ({ ...p, description: e.target.value }))}
placeholder="版块简介,展示在版块头与卡片上"
style={{ minHeight: 80 }}
maxLength={500}
/>
</div>
<div className="form-group">
<label htmlFor="profileIcon">图标</label>
<div className="icon-input-row">
<input
id="profileIcon"
type="text"
value={profile.icon}
onChange={(e) => setProfile((p) => ({ ...p, icon: e.target.value }))}
placeholder="支持上传 ≤1MB 图片,或输入 emoji / 图片 URL"
maxLength={100}
/>
<button
type="button"
className="btn btn-tonal btn-sm icon-upload-btn"
onClick={() => iconFileRef.current && iconFileRef.current.click()}
disabled={iconUploading}
aria-label="上传版块图标图片"
>
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
{iconUploading ? '上传中…' : '上传图片'}
</button>
</div>
<input
ref={iconFileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
aria-label="选择版块图标图片(png/jpg/jpeg/gif/webp,≤1MB"
style={{ display: 'none' }}
onChange={handleIconFile}
/>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
支持上传 1MB 图片png/jpg/jpeg/gif/webp),或输入单个 emoji / 图片链接留空取名称首字
</div>
</div>
<div className="form-group">
<label>图标底色</label>
<div className="profile-palette" role="group" aria-label="选择图标底色">
<button
type="button"
className={'profile-swatch clear' + (!profile.icon_color ? ' selected' : '')}
aria-pressed={!profile.icon_color}
aria-label="自动配色(留空,按名称哈希)"
title="自动配色(留空)"
onClick={() => setProfile((p) => ({ ...p, icon_color: '' }))}
>
A
</button>
{COLOR_PALETTE.map((col) => (
<button
type="button"
key={col}
className={'profile-swatch' + (profile.icon_color === col ? ' selected' : '')}
style={{ background: col }}
aria-pressed={profile.icon_color === col}
aria-label={'底色 ' + col}
title={col}
onClick={() => setProfile((p) => ({ ...p, icon_color: p.icon_color === col ? '' : col }))}
/>
))}
</div>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
留空时按版块名称哈希自动配色深浅色主题自适应
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-filled" onClick={saveProfile} disabled={profileSaving}>
{profileSaving ? '保存中…' : '保存设置'}
</button>
<button
className="btn btn-text"
onClick={() => setProfile({
name: cat.name || '',
description: cat.description || '',
icon: cat.icon || '',
icon_color: cat.icon_color || '',
})}
disabled={profileSaving}
>
撤销修改
</button>
</div>
</div>
)}
</div>
);
}