Files
rainblogweb/frontend/src/admin/pages/ForumManage.jsx
T

156 lines
6.8 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, useState } from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import { listCategories, createCategory, updateCategory, deleteCategory, listPosts } from '../../api/forum.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API */
export default function ForumManage() {
const [cats, setCats] = useState([]);
const [counts, setCounts] = useState({});
const [dialog, setDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY);
const [confirm, setConfirm] = useState(null);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
listCategories()
.then((cs) => setCats(cs || []))
.catch((e) => showSnack(e.message, 'error'));
listPosts()
.then((ps) => {
const c = {};
(ps || []).forEach((p) => { c[p.category_id] = (c[p.category_id] || 0) + 1; });
setCounts(c);
})
.catch(() => {});
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (c) => {
setEditingId(c.id);
setForm({
name: c.name,
description: c.description || '',
announcement: c.announcement || '',
sub_categories: c.sub_categories || '',
sort_order: String(c.sort_order || 0),
});
setDialog(true);
};
const save = async () => {
if (!form.name.trim()) { showSnack('名称不能为空', 'error'); return; }
setSaving(true);
const data = {
name: form.name.trim(),
description: form.description.trim(),
announcement: form.announcement.trim(),
sub_categories: form.sub_categories.trim(),
sort_order: parseInt(form.sort_order, 10) || 0,
};
try {
if (editingId) await updateCategory(editingId, data);
else await createCategory(data);
showSnack('保存成功');
setDialog(false);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
const doDelete = async () => {
if (!confirm) return;
try {
await deleteCategory(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">论坛管理</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
</Box>
{cats.length === 0 ? (
<Typography variant="body2" color="text.secondary">暂无板块点击"添加板块"创建</Typography>
) : (
<Grid container spacing={2}>
{cats.map((c) => (
<Grid item xs={12} sm={6} md={4} key={c.id}>
<Card>
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{c.name}</Typography>
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
{c.announcement && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>{counts[c.id] || 0} 个帖子</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
<DialogContent>
<TextField fullWidth label="名称 *" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} margin="normal" />
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
<TextField fullWidth label="板块公告" multiline rows={2} value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} margin="normal" />
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(false)}>取消</Button>
<Button variant="contained" onClick={save} disabled={saving}>保存</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}