feat: 昵称+头衔+UID 全站显示(系统身份/自定义头衔双层,主要位恒显#id 评论位开关)+ 用户管理大弹窗编辑 + 用户主页视觉修复(Banner/tab/博客/QQ)+ 评论通知回退 admin + 全量评论列表 + 头像 bug 修复 + 站长帖判断改 author_username
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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="确认删除"
|
||||
/>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 全站作者名渲染(前台):
|
||||
* - name 显示名(后端 author_name 已归一 = nickname||username,调用方负责兜底匿名/游客)
|
||||
* - uid 用户 id;showUid 时显示 #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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user