699 lines
27 KiB
React
699 lines
27 KiB
React
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>
|
||
);
|
||
}
|