feat: 昵称+头衔+UID 全站显示(系统身份/自定义头衔双层,主要位恒显#id 评论位开关)+ 用户管理大弹窗编辑 + 用户主页视觉修复(Banner/tab/博客/QQ)+ 评论通知回退 admin + 全量评论列表 + 头像 bug 修复 + 站长帖判断改 author_username

This commit is contained in:
2026-08-14 02:36:57 +08:00
parent 4f3a2cf9a8
commit ee0086b252
19 changed files with 875 additions and 132 deletions
+123 -10
View File
@@ -9,24 +9,53 @@ import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import RefreshIcon from '@mui/icons-material/Refresh';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import { listPendingComments, approveComment, rejectComment } from '../../api/blog.js';
import DeleteIcon from '@mui/icons-material/Delete';
import { listPendingComments, listAllComments, approveComment, rejectComment, deleteComment } from '../../api/blog.js';
import { showSnack } from '../snack.jsx';
const PAGE_SIZE = 20;
/** 时间格式化:yyyy-mm-dd hh:mm */
function fmtTime(t) {
if (!t) return '';
return String(t).replace('T', ' ').slice(0, 16);
}
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
/** 内容截断(单行预览):换行折叠 + 超长省略 */
function clip(content, max = 60) {
const s = String(content || '').replace(/\s+/g, ' ').trim();
return s.length > max ? s.slice(0, max) + '…' : s;
}
/** 状态 chip */
function StatusChip({ status }) {
if (status === 'approved') return <Chip size="small" label="已通过" color="success" />;
if (status === 'rejected') return <Chip size="small" label="已拒绝" color="error" variant="outlined" />;
return <Chip size="small" label="待审核" color="warning" />;
}
/** 评论管理:待审核队列 + 全部评论分页列表(status=all */
export default function CommentManage() {
const [tab, setTab] = useState(0);
const [pending, setPending] = useState([]);
const [busy, setBusy] = useState(false);
// 全部评论(分页)
const [allList, setAllList] = useState([]);
const [allPage, setAllPage] = useState(1);
const [allTotal, setAllTotal] = useState(0);
const [allTotalPages, setAllTotalPages] = useState(0);
const [allLoading, setAllLoading] = useState(false);
const load = useCallback(() => {
setBusy(true);
listPendingComments()
@@ -37,6 +66,23 @@ export default function CommentManage() {
useEffect(() => { load(); }, [load]);
const loadAll = useCallback((page) => {
setAllLoading(true);
listAllComments({ status: 'all', page, pageSize: PAGE_SIZE })
.then((res) => {
setAllList((res && res.list) || []);
setAllTotal(res ? res.total || 0 : 0);
setAllTotalPages(res ? res.totalPages || 0 : 0);
setAllPage(page);
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setAllLoading(false));
}, []);
useEffect(() => {
if (tab === 1) loadAll(1);
}, [tab, loadAll]);
const act = async (id, fn, okMsg) => {
setBusy(true);
try {
@@ -49,17 +95,34 @@ export default function CommentManage() {
setBusy(false);
};
const actAll = async (id, fn, okMsg, reloadPage = allPage) => {
setBusy(true);
try {
await fn(id);
showSnack(okMsg);
// 同步待审核徽标 + 刷新当前页;若删除后本页空且非第一页则回退一页
setPending((list) => list.filter((c) => c.id !== id));
const remaining = allList.filter((c) => c.id !== id).length;
loadAll(remaining === 0 && reloadPage > 1 ? reloadPage - 1 : reloadPage);
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const totalPages = Math.max(1, allTotalPages);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5">评论管理</Typography>
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
<IconButton onClick={() => (tab === 0 ? load() : loadAll(allPage))} title="刷新" disabled={busy || allLoading}><RefreshIcon /></IconButton>
</Box>
<Paper sx={{ mb: 2 }}>
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
<Tab label="全部评论" />
<Tab label={`全部评论${allTotal ? ` (${allTotal})` : ''}`} />
</Tabs>
</Paper>
@@ -114,12 +177,62 @@ export default function CommentManage() {
</Paper>
)
) : (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
<Typography variant="body2" color="text.secondary">
当前后端仅提供待审核评论的管理接口已通过 / 已拒绝评论可在对应文章页查看
</Typography>
</Paper>
<>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>作者</TableCell>
<TableCell>文章</TableCell>
<TableCell>内容</TableCell>
<TableCell>状态</TableCell>
<TableCell>时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allLoading && allList.length === 0 ? (
<TableRow><TableCell colSpan={6}>加载中...</TableCell></TableRow>
) : allList.length === 0 ? (
<TableRow><TableCell colSpan={6}>暂无评论</TableCell></TableRow>
) : allList.map((c) => (
<TableRow key={c.id}>
<TableCell sx={{ whiteSpace: 'nowrap', fontWeight: 600 }}>
{c.author_name || '匿名'}
</TableCell>
<TableCell sx={{ maxWidth: 200, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.post_title || `#${c.post_id}`}
</TableCell>
<TableCell sx={{ maxWidth: 320, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{clip(c.content)}
</TableCell>
<TableCell><StatusChip status={c.status} /></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13, whiteSpace: 'nowrap' }}>{fmtTime(c.created_at)}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{c.status === 'pending' && (
<>
<Button size="small" startIcon={<CheckIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, approveComment, '已通过审核')}>通过</Button>
<Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, rejectComment, '已拒绝')}>拒绝</Button>
</>
)}
<IconButton size="small" color="error" title="删除评论" disabled={busy} onClick={() => actAll(c.id, deleteComment, '已删除')}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mt: 2 }}>
<Button size="small" variant="outlined" disabled={allPage <= 1 || allLoading} onClick={() => loadAll(allPage - 1)}>上一页</Button>
<Typography variant="body2" color="text.secondary">
{allPage} / {totalPages} · {allTotal}
</Typography>
<Button size="small" variant="outlined" disabled={allPage >= totalPages || allLoading} onClick={() => loadAll(allPage + 1)}>下一页</Button>
</Box>
</>
)}
</Box>
);
+228 -60
View File
@@ -19,19 +19,31 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import { listUsers, deleteUser, resetUserPassword, setUserRole, registerByAdmin } from '../../api/auth.js';
import Divider from '@mui/material/Divider';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { listUsers, deleteUser, resetUserPassword, registerByAdmin, updateUser, me } from '../../api/auth.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片 */
/** 头衔色板(MD3 常用色相,与论坛板块图标色板一致 */
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
const EMPTY_EDIT = { nickname: '', title: '', title_color: '', website: '', email: '', role: 'user' };
/** 用户管理:列表 / 添加 / 编辑大弹窗(昵称·头衔·权限·改密·删除危险区) */
export default function Users() {
const [users, setUsers] = useState(null);
const [selfId, setSelfId] = useState(null); // 当前管理员自己的 id(自我保护:禁止改权/删除自己)
const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
const [pwDialog, setPwDialog] = useState(null); // { id, username }
// 编辑大弹窗
const [editUser, setEditUser] = useState(null); // 行数据 { id, username, ... }
const [editForm, setEditForm] = useState(EMPTY_EDIT);
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
const [confirm, setConfirm] = useState(null); // { id, username }
const [deleteTarget, setDeleteTarget] = useState(null); // { id, username }
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
@@ -42,6 +54,11 @@ export default function Users() {
useEffect(() => { load(); }, [load]);
// 自我 id(禁止对自己改权/删除)
useEffect(() => {
me().then((u) => setSelfId(u && u.id)).catch(() => setSelfId(null));
}, []);
const addUser = async () => {
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
setBusy(true);
@@ -57,15 +74,52 @@ export default function Users() {
setBusy(false);
};
const openEdit = (u) => {
setEditUser(u);
setEditForm({
nickname: u.nickname || '',
title: u.title || '',
title_color: u.title_color || '',
website: u.website || '',
email: u.email || '',
role: u.role || 'user',
});
setPwForm({ p1: '', p2: '' });
};
const closeEdit = () => setEditUser(null);
const saveProfile = async () => {
if (!editUser) return;
if (editForm.nickname.length > 20) { showSnack('昵称最多 20 字', 'error'); return; }
if (editForm.title.length > 20) { showSnack('头衔最多 20 字', 'error'); return; }
setBusy(true);
try {
await updateUser(editUser.id, {
nickname: editForm.nickname.trim(),
title: editForm.title.trim(),
title_color: editForm.title_color.trim(),
website: editForm.website.trim(),
email: editForm.email.trim(),
role: editForm.role,
});
showSnack('资料已保存');
closeEdit();
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const resetPw = async () => {
if (!pwDialog) return;
if (!editUser) return;
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
setBusy(true);
try {
await resetUserPassword(pwDialog.id, pwForm.p1);
await resetUserPassword(editUser.id, pwForm.p1);
showSnack('密码已重置');
setPwDialog(null);
setPwForm({ p1: '', p2: '' });
} catch (e) {
showSnack(e.message, 'error');
@@ -73,13 +127,14 @@ export default function Users() {
setBusy(false);
};
const changeRole = async () => {
if (!roleDialog) return;
const doDelete = async () => {
if (!deleteTarget) return;
setBusy(true);
try {
await setUserRole(roleDialog.id, roleDialog.role);
showSnack('角色已更新');
setRoleDialog(null);
await deleteUser(deleteTarget.id);
showSnack('已删除');
setDeleteTarget(null);
setEditUser(null); // 删除后关闭编辑弹窗
load();
} catch (e) {
showSnack(e.message, 'error');
@@ -87,19 +142,7 @@ export default function Users() {
setBusy(false);
};
const doDelete = async () => {
if (!confirm) return;
setBusy(true);
try {
await deleteUser(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const isSelf = !!editUser && editUser.id === selfId;
return (
<Box>
@@ -113,7 +156,7 @@ export default function Users() {
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>用户</TableCell>
<TableCell>用户</TableCell>
<TableCell>邮箱</TableCell>
<TableCell>验证</TableCell>
<TableCell>角色</TableCell>
@@ -129,7 +172,12 @@ export default function Users() {
) : users.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></TableCell>
<TableCell>
<Box sx={{ fontWeight: 600 }}>{u.nickname || u.username}</Box>
{u.nickname && u.nickname !== u.username ? (
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>@{u.username}</Box>
) : null}
</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
<TableCell>
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
@@ -139,9 +187,7 @@ export default function Users() {
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Button size="small" onClick={() => { setPwDialog({ id: u.id, username: u.username }); setPwForm({ p1: '', p2: '' }); }}>改密</Button>
<Button size="small" onClick={() => setRoleDialog({ id: u.id, username: u.username, role: u.role })}>改权</Button>
<Button size="small" color="error" onClick={() => setConfirm({ id: u.id, username: u.username })}>删除</Button>
<Button size="small" startIcon={<EditIcon fontSize="small" />} onClick={() => openEdit(u)}>编辑</Button>
</TableCell>
</TableRow>
))}
@@ -169,43 +215,165 @@ export default function Users() {
</DialogActions>
</Dialog>
{/* 重置密码 */}
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>重置密码</DialogTitle>
{/* 编辑用户大弹窗 */}
<Dialog open={!!editUser} onClose={closeEdit} fullWidth maxWidth="sm">
<DialogTitle>编辑用户</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>重置用户 {pwDialog ? pwDialog.username : ''} 的密码</Typography>
<TextField fullWidth label="新密码(至少6位)" type="password" value={pwForm.p1} onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))} margin="normal" />
<TextField fullWidth label="确认新密码" type="password" value={pwForm.p2} onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))} margin="normal" />
</DialogContent>
<DialogActions>
<Button onClick={() => setPwDialog(null)}>取消</Button>
<Button variant="contained" onClick={resetPw} disabled={busy}>确认重置</Button>
</DialogActions>
</Dialog>
{editUser && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
{/* 基本信息 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>基本信息</Typography>
<TextField fullWidth label="用户名(不可修改)" value={editUser.username} margin="normal" disabled />
<TextField
fullWidth
label="对外昵称"
value={editForm.nickname}
onChange={(e) => setEditForm((p) => ({ ...p, nickname: e.target.value }))}
margin="normal"
inputProps={{ maxLength: 20 }}
helperText="显示在帖子 / 评论 / 个人主页的作者名,留空则显示用户名"
/>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<TextField
fullWidth
label="邮箱"
type="email"
value={editForm.email}
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
margin="normal"
placeholder="user@example.com"
/>
<Box sx={{ mt: 3.4, flexShrink: 0 }}>
<Chip size="small" label={editUser.email_verified ? '已验证' : '未验证'} color={editUser.email_verified ? 'primary' : 'default'} variant={editUser.email_verified ? 'filled' : 'outlined'} />
</Box>
</Box>
<TextField
fullWidth
label="个人博客"
type="url"
value={editForm.website}
onChange={(e) => setEditForm((p) => ({ ...p, website: e.target.value }))}
margin="normal"
placeholder="https://example.com"
helperText="显示在个人主页(外链跳确认页)"
/>
</Box>
{/* 修改角色 */}
<Dialog open={!!roleDialog} onClose={() => setRoleDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>修改角色</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>修改用户 {roleDialog ? roleDialog.username : ''} 的角色</Typography>
<FormControl fullWidth>
<InputLabel>角色</InputLabel>
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
<MenuItem value="user">普通用户</MenuItem>
<MenuItem value="admin">管理员</MenuItem>
</Select>
</FormControl>
<Divider />
{/* 头衔 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>头衔</Typography>
<TextField
fullWidth
label="自定义头衔"
value={editForm.title}
onChange={(e) => setEditForm((p) => ({ ...p, title: e.target.value }))}
margin="normal"
inputProps={{ maxLength: 20 }}
placeholder="如:技术宅 / 站长 / 摸鱼大师"
/>
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>头衔颜色点击选择或输入自定义 hex</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
{COLOR_PALETTE.map((col) => (
<Box
key={col}
onClick={() => setEditForm((p) => ({ ...p, title_color: col === p.title_color ? '' : col }))}
sx={{
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', background: col,
border: col === editForm.title_color ? '2px solid #fff' : '2px solid transparent',
outline: col === editForm.title_color ? '2px solid var(--md-sys-color-outline, rgba(0,0,0,0.38))' : 'none',
boxSizing: 'border-box',
}}
title={col}
/>
))}
<TextField
size="small"
label="自定义"
value={editForm.title_color}
onChange={(e) => setEditForm((p) => ({ ...p, title_color: e.target.value }))}
placeholder="#6750a4"
sx={{ width: 130 }}
/>
</Box>
</Box>
</Box>
<Divider />
{/* 权限 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>权限</Typography>
<FormControl fullWidth margin="normal">
<InputLabel>角色</InputLabel>
<Select value={editForm.role} onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))} label="角色" disabled={isSelf}>
<MenuItem value="user">普通用户</MenuItem>
<MenuItem value="admin">管理员</MenuItem>
</Select>
</FormControl>
{isSelf && <Typography variant="caption" color="text.secondary">不能修改自己的角色</Typography>}
</Box>
<Divider />
{/* 账号安全(危险区) */}
<Box sx={{ border: '1px solid', borderColor: 'error.main', borderRadius: 2, p: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'error.main' }}>账号安全</Typography>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-end' }}>
<TextField
size="small"
label="新密码(至少6位)"
type="password"
value={pwForm.p1}
onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))}
margin="normal"
sx={{ flex: 1 }}
/>
<TextField
size="small"
label="确认新密码"
type="password"
value={pwForm.p2}
onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))}
margin="normal"
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={resetPw} disabled={busy} sx={{ mb: 0.5 }}>重置密码</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 13 }}>
删除后将无法恢复帖子 / 评论 / 附件一并处理
</Typography>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
disabled={isSelf || busy}
onClick={() => setDeleteTarget({ id: editUser.id, username: editUser.username })}
title={isSelf ? '不能删除自己' : ''}
>
删除用户
</Button>
</Box>
{isSelf && <Typography variant="caption" color="text.secondary">不能删除自己的账号</Typography>}
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setRoleDialog(null)}>取消</Button>
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
<Button onClick={closeEdit}>取消</Button>
<Button variant="contained" onClick={saveProfile} disabled={busy}>保存资料</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
onClose={() => setConfirm(null)}
open={!!deleteTarget}
message={`确定要删除用户 "${deleteTarget ? deleteTarget.username : ''}" 吗?此操作不可恢复`}
onClose={() => setDeleteTarget(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
+4
View File
@@ -53,3 +53,7 @@ export function resetUserPassword(id, newPassword) {
export function setUserRole(id, role) {
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
}
/** 编辑用户资料(管理员):nickname/title/title_color/website/email/role */
export function updateUser(id, data) {
return request('/auth/users/' + id, { method: 'PUT', body: data });
}
+5
View File
@@ -74,3 +74,8 @@ export function approveComment(id) {
export function rejectComment(id) {
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
}
/** 全量评论列表(仅管理员):status=all|pending|approved|rejected,分页 → { list, total, page, pageSize, totalPages } */
export function listAllComments({ status = 'all', page = 1, pageSize = 20 } = {}) {
return request(`/blog/comments?status=${encodeURIComponent(status)}&page=${page}&pageSize=${pageSize}`);
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
/**
* 全站作者名渲染(前台):
* - name 显示名(后端 author_name 已归一 = nickname||username,调用方负责兜底匿名/游客)
* - uid 用户 idshowUid 时显示 #id 后缀
* - title 自定义头衔文案(非空才渲染 chip)
* - titleColor 头衔 chip 背景色(hex,可空 → 默认中性色)
* - role 'admin' → 系统身份 chip「管理员」(固定系统配色)
* - moderatorLabel 版主 chip 文案(如「版主·闲聊」);接口未提供时留空则不渲染
* - showUid UID 后缀开关:帖子发布者/楼主/博客作者/个人主页恒显;
* 评论作者/回复楼层由 show_uid_in_comments 设置控制
*
* 渲染 = 名字 + 系统身份 chip(管理员/版主)+ 自定义头衔 chip + UID 后缀,
* 样式命名空间 .username-* / .title-chip(见 public/css/style.css)。
*/
export default function UserName({
name = '',
uid,
title = '',
titleColor = '',
role = '',
showUid = false,
moderatorLabel = '',
className = '',
}) {
return (
<span className={'username' + (className ? ' ' + className : '')}>
<span className="username-name">{name || '匿名'}</span>
{role === 'admin' ? <span className="username-chip username-chip-role">管理员</span> : null}
{moderatorLabel ? <span className="username-chip username-chip-moderator">{moderatorLabel}</span> : null}
{title ? (
<span
className={'username-chip title-chip' + (titleColor ? ' title-chip-colored' : '')}
style={titleColor ? { background: titleColor } : undefined}
>
{title}
</span>
) : null}
{showUid && uid != null ? <span className="username-uid">#{uid}</span> : null}
</span>
);
}
+24 -2
View File
@@ -5,6 +5,8 @@ import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
import * as settingsApi from '../api/settings.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
@@ -36,6 +38,8 @@ export default function BlogDetail() {
const [toc, setToc] = useState([]);
const [replyTo, setReplyTo] = useState(null); // {id, name}
const [shareOpen, setShareOpen] = useState(false);
// 评论作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
const [showCommentUid, setShowCommentUid] = useState(true);
// markdown 锁定块:locks 元信息 / 已解锁索引(Set/ password 块内容(Map,仅内存态)/ viewer 判定结果
const [locks, setLocks] = useState([]);
const [unlocked, setUnlocked] = useState(() => new Set());
@@ -104,6 +108,10 @@ export default function BlogDetail() {
useEffect(() => {
load();
// 评论 UID 开关(公开设置;后端未加字段时默认显示)
settingsApi.getPublicSettings()
.then((s) => setShowCommentUid(s.show_uid_in_comments !== '0'))
.catch(() => setShowCommentUid(true));
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
} else {
@@ -173,7 +181,14 @@ export default function BlogDetail() {
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
<div className="reply-meta">
<Avatar src={c.author_avatar} name={c.author_name} size={22} className="avatar-sm" to={c.author_id ? `/u/${c.author_id}` : undefined} />
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
<UserName
name={c.author_name || '游客'}
uid={c.author_id}
title={c.author_title}
titleColor={c.author_title_color}
role={c.author_role}
showUid={showCommentUid && !!c.author_id}
/> · {c.created_at}
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
</div>
<div className="reply-body">{c.content}</div>
@@ -238,7 +253,14 @@ export default function BlogDetail() {
<div className="article-meta">
<Avatar src={post.author_avatar} name={post.author_name} size={24} to={post.author_id ? `/u/${post.author_id}` : undefined} />
{post.author_name || '管理员'} · {post.created_at}
<UserName
name={post.author_name || '管理员'}
uid={post.author_id}
title={post.author_title}
titleColor={post.author_title_color}
role={post.author_role}
showUid={!!post.author_id}
/> · {post.created_at}
<span className="meta-stat" title="阅读量">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
</span>
+9 -1
View File
@@ -7,6 +7,7 @@ import { getToken } from '../api/client.js';
import { showSnackbar, useDialog, normalizePagedList } from '../lib/utils.js';
import ForumIcon from '../components/ForumIcon.jsx';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
const PAGE_SIZE = 20;
@@ -382,7 +383,14 @@ function PostCard({ p }) {
</div>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<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>
<UserName
name={p.author_name || '匿名'}
uid={p.author_id}
title={p.author_title}
titleColor={p.author_title_color}
role={p.author_role}
showUid={!!p.author_id}
/>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
{p.sub_category ? (
+26 -5
View File
@@ -7,6 +7,7 @@ import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import ErrorDialog from '../components/ErrorDialog.jsx';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
import { showSnackbar, useDialog } from '../lib/utils.js';
/** 后端时间 'YYYY-MM-DD HH:MM:SS' → 'YYYY年M月D日 HH:MM'(编辑标记用,与创建时间同源) */
@@ -36,6 +37,8 @@ export default function ForumDetail() {
const [submitting, setSubmitting] = useState(false);
const [guestLocked, setGuestLocked] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
// 回复楼层作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
const [showReplyUid, setShowReplyUid] = useState(true);
const [shareOpen, setShareOpen] = useState(false);
const [adminBusy, setAdminBusy] = useState(false);
const [submitError, setSubmitError] = useState(''); // 403 禁言等错误 → 弹窗
@@ -70,9 +73,13 @@ export default function ForumDetail() {
}, [id]);
useEffect(() => {
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)
// 公开设置:游客是否可预览论坛(默认可见,仅明确 '0' 才锁定)+ 回复 UID 开关
settingsApi.getPublicSettings()
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
.then((s) => {
setGuestLocked(s.forum_guest_visible === '0');
setShowReplyUid(s.show_uid_in_comments !== '0');
setSettingsLoaded(true);
})
.catch(() => { setSettingsLoaded(true); });
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
@@ -92,7 +99,7 @@ export default function ForumDetail() {
const catId = post ? post.category_id : null;
// 站长保护:站长(username='admin')的帖子仅站长本人可操作(版主/其他 admin 由后端 403 兜底)
const isOwnerPost = !!post && (post.author_name || '') === 'admin';
const isOwnerPost = !!post && (post.author_username || '') === 'admin';
const isSelf = !!user && !!post && user.id === post.author_id;
// 管理操作(置顶/加精):admin 或版主;站长帖排除非站长本人
const canManage = !!user && (user.role === 'admin' || (catId != null && moderatedIds.includes(catId)))
@@ -264,7 +271,14 @@ export default function ForumDetail() {
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Avatar src={post.author_avatar} name={post.author_name} size={28} to={post.author_id ? `/u/${post.author_id}` : undefined} />
<span>{post.author_name || '匿名'}</span>
<UserName
name={post.author_name || '匿名'}
uid={post.author_id}
title={post.author_title}
titleColor={post.author_title_color}
role={post.author_role}
showUid={!!post.author_id}
/>
<span className="lz-chip">楼主</span>
<span>{post.created_at}</span>
<span className="floor-num">#1 </span>
@@ -356,7 +370,14 @@ export default function ForumDetail() {
<div className="floor-item" key={r.id}>
<div className="floor-head">
<Avatar src={r.author_avatar} name={r.author_name} size={24} to={r.author_id ? `/u/${r.author_id}` : undefined} />
<strong>{r.author_name || '匿名'}</strong>
<UserName
name={r.author_name || '匿名'}
uid={r.author_id}
title={r.author_title}
titleColor={r.author_title_color}
role={r.author_role}
showUid={showReplyUid && !!r.author_id}
/>
{r.author_id === post.author_id ? <span className="lz-chip">楼主</span> : null}
<span className="floor-num">#{floor} </span>
<span className="floor-time">{r.created_at}</span>
+51
View File
@@ -38,6 +38,10 @@ export default function Profile() {
// 个性签名(公开主页 bio
const [bio, setBio] = useState('');
const [bioBusy, setBioBusy] = useState(false);
// 对外昵称 + 个人博客(全站作者名 / 个人主页展示)
const [nickname, setNickname] = useState('');
const [website, setWebsite] = useState('');
const [infoBusy, setInfoBusy] = useState(false);
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
const [pwDialog, setPwDialog] = useState(false);
@@ -63,6 +67,8 @@ export default function Profile() {
setUser(u);
setQq(u.qq || ''); // 后端提供 qq 字段时预填
setBio(u.bio || ''); // 个性签名
setNickname(u.nickname || ''); // 对外昵称
setWebsite(u.website || ''); // 个人博客
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
const av = await avatarUrl(u.id);
setAvatar(av.url || '');
@@ -140,6 +146,20 @@ export default function Profile() {
setBioBusy(false);
};
// 对外昵称 + 个人博客保存(作者名/头衔之外的公开资料;后端白名单扩展后生效)
const saveInfo = async () => {
if (nickname.trim().length > 20) { showSnackbar('昵称最多 20 字'); return; }
if (website.trim() && !/^https?:\/\//i.test(website.trim())) { showSnackbar('个人博客需以 http(s):// 开头'); return; }
setInfoBusy(true);
try {
await profileApi.updateProfile({ nickname: nickname.trim(), website: website.trim() });
showSnackbar('资料已更新');
} catch (e) {
showSnackbar(e.message);
}
setInfoBusy(false);
};
const sendPwCode = async () => {
setPwBusy(true);
try {
@@ -218,6 +238,37 @@ export default function Profile() {
</div>
</div>
{/* 对外昵称 + 个人博客:显示在帖子/评论/个人主页 */}
<div className="form-group" style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
<label htmlFor="profileNickname">对外昵称</label>
<input
id="profileNickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="显示在帖子 / 评论 / 个人主页的作者名"
maxLength={20}
/>
</div>
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
<label htmlFor="profileWebsite">个人博客</label>
<input
id="profileWebsite"
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://example.com"
/>
</div>
<button className="btn btn-tonal btn-sm" onClick={saveInfo} disabled={infoBusy}>
{infoBusy ? '保存中…' : '保存资料'}
</button>
</div>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
昵称留空则显示用户名博客会以个人博客链接展示在主页外链跳确认页
</div>
{/* QQ 号:rainweb 层字段(后端返回 qq 时显示;用于 QQ 头像渲染优先级) */}
{typeof user.qq !== 'undefined' && (
<div className="form-group" style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
+23 -4
View File
@@ -4,6 +4,7 @@ import * as usersApi from '../api/users.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import Avatar, { hashTone } from '../components/Avatar.jsx';
import { safeOutUrl } from '../lib/outlink.js';
const PAGE_SIZE = 10;
@@ -189,7 +190,8 @@ export default function UserProfile() {
);
}
// ── Banner 渐变(用户名哈希色调) ──
// ── Banner 渐变(用户名哈希色调)+ 首字水印 + 用户名(让纯装饰渐变"有内容感")──
const displayName = user.display_name || user.nickname || user.username || '';
const tone = hashTone(user.username || String(user.id || ''));
const h = tone * 30;
const bannerGrad = `linear-gradient(135deg, hsl(${h} 62% 84%), hsl(${(h + 45) % 360} 65% 70%))`;
@@ -199,22 +201,39 @@ export default function UserProfile() {
return (
<div className="profile-page">
<div className="profile-banner" style={{ background: bannerGrad }} aria-hidden="true" />
<div className="profile-banner" style={{ background: bannerGrad }} aria-hidden="true">
<span className="profile-banner-char">{displayName.charAt(0)}</span>
<span className="profile-banner-name">{displayName}</span>
</div>
<div className="profile-grid">
{/* 左列:身份卡 */}
<aside className="profile-identity">
<div className="card profile-card">
<Avatar src={user.avatar} name={user.username} size={80} />
<Avatar src={user.avatar} name={displayName || user.username} size={80} />
<div className="profile-name-row">
<h1 className="profile-name">{user.username}</h1>
<h1 className="profile-name">{displayName || user.username}</h1>
{user.role === 'admin' ? <span className="profile-role-chip">管理员</span> : null}
{user.title ? (
<span
className={'username-chip title-chip' + (user.title_color ? ' title-chip-colored' : '')}
style={user.title_color ? { background: user.title_color } : undefined}
>
{user.title}
</span>
) : null}
</div>
{user.bio ? <p className="profile-bio">{user.bio}</p> : null}
<div className="profile-meta-line">
<span>UID {user.id}</span>
{user.created_at ? <span>注册于 {regDate(user.created_at)}</span> : null}
{user.last_active_at ? <span>{relativeTime(user.last_active_at)}活跃</span> : null}
{user.qq ? <span>QQ{user.qq}</span> : null}
{user.website ? (
<a href={safeOutUrl(user.website)} target="_blank" rel="noopener noreferrer" title="个人博客">
<span className="material-icons" style={{ fontSize: 13 }}>link</span> 个人博客
</a>
) : null}
</div>
<div className="profile-stats">
<StatCell label="帖子" value={stats.posts} />