fix: 个人中心昵称/博客保存修复 + 顶栏搜索框居中(5条限制) + UI 修复(顶栏选中高亮/UID开关/Banner/Tab/登录验证码按钮)
This commit is contained in:
@@ -41,7 +41,10 @@ function waitForId(id, tries = 40) {
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
/** 后台布局:AppBar(返回前台 + 工作台 + 退出)+ Drawer(分组折叠导航 + 设置搜索)+ 内容区 Outlet */
|
||||
/** 顶栏搜索下拉最多展示条数 */
|
||||
const SEARCH_LIMIT = 5;
|
||||
|
||||
/** 后台布局:AppBar(中间设置搜索 + 工作台/返回前台/退出)+ Drawer(分组折叠导航)+ 内容区 Outlet */
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -54,11 +57,11 @@ export default function AdminLayout() {
|
||||
new Set(NAV_GROUPS.filter((g) => g.items.length > 1).map((g) => g.id))
|
||||
));
|
||||
|
||||
// 设置搜索
|
||||
// 设置搜索(顶栏中间;结果最多 SEARCH_LIMIT 条)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const results = useMemo(() => searchNav(searchQuery), [searchQuery]);
|
||||
const results = useMemo(() => searchNav(searchQuery, SEARCH_LIMIT), [searchQuery]);
|
||||
|
||||
// 当前项所在组自动展开
|
||||
useEffect(() => {
|
||||
@@ -117,6 +120,74 @@ export default function AdminLayout() {
|
||||
if (e.key === 'Enter' && results.length === 1) { jump(results[0]); }
|
||||
};
|
||||
|
||||
/** 顶栏搜索框 + 结果下拉(桌面居中 / 移动端全宽第二行) */
|
||||
const searchField = (
|
||||
<>
|
||||
<OutlinedInput
|
||||
inputRef={searchRef}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="搜索设置…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
onFocus={() => { if (searchQuery.trim()) setSearchOpen(true); }}
|
||||
startAdornment={<InputAdornment position="start"><SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} /></InputAdornment>}
|
||||
endAdornment={searchQuery ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" edge="end" onClick={() => onSearchChange('')} title="清空">
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null}
|
||||
sx={{
|
||||
height: 38, borderRadius: 3,
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'divider' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
|
||||
}}
|
||||
/>
|
||||
{/* 结果下拉:最多 5 条,maxHeight 280 ≈ 5 个列表项卡片高度 */}
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
anchorEl={searchRef.current}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 320, maxHeight: 280, mt: 0.5, borderRadius: 2, boxShadow: 8,
|
||||
overflow: 'auto', border: '1px solid', borderColor: 'divider',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<List dense disablePadding sx={{ py: 0.5 }}>
|
||||
{results.length === 0 ? (
|
||||
<ListItem>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: 13 }}>没有匹配的设置</Typography>
|
||||
</ListItem>
|
||||
) : results.map((r) => (
|
||||
<MenuItem key={r.type + r.path + (r.anchor || '')} dense onClick={() => jump(r)}>
|
||||
<ListItemIcon sx={{ minWidth: 34 }}>{r.icon ? <r.icon fontSize="small" /> : <SearchIcon fontSize="small" />}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.label}
|
||||
secondary={r.group}
|
||||
primaryTypographyProps={{ fontSize: 13.5 }}
|
||||
slotProps={{ secondary: { fontSize: 11.5 } }}
|
||||
/>
|
||||
{r.type === 'section' && (
|
||||
<Box component="span" sx={{ ml: 1, fontSize: 11, color: 'text.disabled', flexShrink: 0 }}>设置项</Box>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
/** 单成员组平铺渲染,多成员组显示组头 + Collapse 折叠 */
|
||||
const renderGroup = (g) => {
|
||||
const single = g.items.length === 1;
|
||||
@@ -166,63 +237,6 @@ export default function AdminLayout() {
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Toolbar />
|
||||
<Box sx={{ px: 1.5, pt: 0.5, pb: 0.5 }}>
|
||||
<OutlinedInput
|
||||
inputRef={searchRef}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="搜索设置…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
onFocus={() => { if (searchQuery.trim()) setSearchOpen(true); }}
|
||||
startAdornment={<InputAdornment position="start"><SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} /></InputAdornment>}
|
||||
endAdornment={searchQuery ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" edge="end" onClick={() => onSearchChange('')} title="清空">
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null}
|
||||
sx={{ height: 36, borderRadius: 2.5, bgcolor: 'action.hover', '& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent' } }}
|
||||
/>
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
anchorEl={searchRef.current}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: 320, maxHeight: 400, mt: 0.5, borderRadius: 2, boxShadow: 8,
|
||||
overflow: 'auto', border: '1px solid', borderColor: 'divider',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<List dense disablePadding sx={{ py: 0.5 }}>
|
||||
{results.length === 0 ? (
|
||||
<ListItem>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: 13 }}>没有匹配的设置</Typography>
|
||||
</ListItem>
|
||||
) : results.map((r) => (
|
||||
<MenuItem key={r.type + r.path + (r.anchor || '')} dense onClick={() => jump(r)}>
|
||||
<ListItemIcon sx={{ minWidth: 34 }}>{r.icon ? <r.icon fontSize="small" /> : <SearchIcon fontSize="small" />}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.label}
|
||||
secondary={r.group}
|
||||
primaryTypographyProps={{ fontSize: 13.5 }}
|
||||
slotProps={{ secondary: { fontSize: 11.5 } }}
|
||||
/>
|
||||
{r.type === 'section' && (
|
||||
<Box component="span" sx={{ ml: 1, fontSize: 11, color: 'text.disabled', flexShrink: 0 }}>设置项</Box>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Popover>
|
||||
</Box>
|
||||
<List dense>{NAV_GROUPS.map(renderGroup)}</List>
|
||||
</>
|
||||
);
|
||||
@@ -236,24 +250,40 @@ export default function AdminLayout() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
{/* 顶行:汉堡(移动) + 标题(左) + 搜索(桌面中) + 操作按钮(右) */}
|
||||
<Toolbar sx={{ gap: 1, minHeight: isMobile ? 56 : 64 }}>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
|
||||
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 0.5 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
|
||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" href="/" title="返回前台">
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
|
||||
</Button>
|
||||
<Typography variant="h6" noWrap sx={{ flexShrink: 0, fontSize: { xs: 18, sm: 22 } }}>管理后台</Typography>
|
||||
{!isMobile && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', minWidth: 0, px: 2 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 480 }}>{searchField}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0, sm: 0.5 }, ml: 'auto', flexShrink: 0 }}>
|
||||
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
|
||||
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" href="/" title="返回前台" sx={{ px: { xs: 1, sm: 1.5 } }}>
|
||||
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box>
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout} sx={{ px: { xs: 1, sm: 1.5 } }}>
|
||||
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>退出</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
{/* 移动:第二行全宽搜索(顶栏空间不足,收窄到独立一行不拥挤) */}
|
||||
{isMobile && (
|
||||
<Toolbar variant="dense" sx={{ pt: 0, pb: 1, gap: 1, minHeight: 48 }}>
|
||||
{searchField}
|
||||
</Toolbar>
|
||||
)}
|
||||
</AppBar>
|
||||
|
||||
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
|
||||
@@ -275,7 +305,8 @@ export default function AdminLayout() {
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, p: { xs: 2, md: 3 }, minWidth: 0 }}>
|
||||
<Toolbar />
|
||||
{/* AppBar 占位:桌面 64 / 移动 56 + 搜索行 48 */}
|
||||
<Box sx={{ height: isMobile ? 104 : 64 }} />
|
||||
<Outlet />
|
||||
</Box>
|
||||
<SnackHost />
|
||||
|
||||
@@ -107,7 +107,7 @@ export const NAV_SECTIONS = [
|
||||
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-content', label: '内容设置', keywords: ['全文', '摘要', '条目数'] },
|
||||
// 博客管理 /blog
|
||||
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-blog-sidebar', label: '博客侧栏', keywords: ['侧边栏', '头像', '简介', '显示'] },
|
||||
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-comments', label: '评论设置', keywords: ['评论审核', '评论通知', '审核', '通知', 'moderate'] },
|
||||
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-comments', label: '评论设置', keywords: ['评论审核', '评论通知', '审核', '通知', 'moderate', 'UID', '#id', '编号'] },
|
||||
// 论坛管理 /forum
|
||||
{ page: '/forum', pageLabel: '论坛管理', anchor: 'sec-forum-settings', label: '论坛设置', keywords: ['访客可见', '游客', '私密', 'guest', '权限'] },
|
||||
// 面板链接 /links
|
||||
@@ -163,9 +163,20 @@ function buildIndex() {
|
||||
|
||||
const SEARCH_INDEX = buildIndex();
|
||||
|
||||
/** 前端本地过滤:输入 ≥1 字符返回结果(大小写不敏感、子串匹配) */
|
||||
export function searchNav(query) {
|
||||
/**
|
||||
* 前端本地过滤:输入 ≥1 字符返回结果(大小写不敏感、子串匹配)。
|
||||
* limit 为可选参数:限制返回条数(顶栏搜索传 5,最多显示 5 条)。
|
||||
* 相关性排序:命中 label 的条目排在只命中 keywords 的前面
|
||||
* (同相关度保持构建顺序:菜单项 > 设置区块 > 别名,Array.sort 稳定)。
|
||||
*/
|
||||
export function searchNav(query, limit) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return SEARCH_INDEX.filter((e) => e.keywords.some((k) => String(k).toLowerCase().includes(q)));
|
||||
const matched = SEARCH_INDEX.filter((e) => e.keywords.some((k) => String(k).toLowerCase().includes(q)));
|
||||
const ranked = matched.sort((a, b) => {
|
||||
const aLabel = String(a.label).toLowerCase().includes(q) ? 0 : 1;
|
||||
const bLabel = String(b.label).toLowerCase().includes(q) ? 0 : 1;
|
||||
return aLabel - bLabel;
|
||||
});
|
||||
return typeof limit === 'number' && limit > 0 ? ranked.slice(0, limit) : ranked;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function BlogManage() {
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [commentModerate, setCommentModerate] = useState('0');
|
||||
const [commentNotify, setCommentNotify] = useState('0');
|
||||
const [showUidComments, setShowUidComments] = useState('1');
|
||||
const [confirm, setConfirm] = useState(null); // { id, title }
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -41,6 +42,7 @@ export default function BlogManage() {
|
||||
setShowSidebar(s.blog_show_sidebar !== '0');
|
||||
setCommentModerate(s.comment_moderate === '1' ? '1' : '0');
|
||||
setCommentNotify(s.comment_notify === '1' ? '1' : '0');
|
||||
setShowUidComments(s.show_uid_in_comments !== '0' ? '1' : '0');
|
||||
}).catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
@@ -55,7 +57,11 @@ export default function BlogManage() {
|
||||
|
||||
const saveCommentSettings = async () => {
|
||||
try {
|
||||
await saveSettings({ comment_moderate: commentModerate, comment_notify: commentNotify });
|
||||
await saveSettings({
|
||||
comment_moderate: commentModerate,
|
||||
comment_notify: commentNotify,
|
||||
show_uid_in_comments: showUidComments,
|
||||
});
|
||||
showSnack('评论设置已保存');
|
||||
} catch (e) {
|
||||
showSnack(e.message, 'error');
|
||||
@@ -129,6 +135,15 @@ export default function BlogManage() {
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showUidComments === '1'} onChange={(e) => setShowUidComments(e.target.checked ? '1' : '0')} />}
|
||||
label={(
|
||||
<Box sx={{ py: 0.5 }}>
|
||||
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论显示 UID 编号(#id)</Box>
|
||||
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>在评论作者名旁显示 UID 后缀,关闭后仅帖子/楼主等主作者位显示</Box>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={saveCommentSettings}>保存评论设置</Button>
|
||||
</Box>
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function Login() {
|
||||
height: 44,
|
||||
...(capDone
|
||||
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
|
||||
: { background: '#fff', color: '#333', border: '1px solid #333' }),
|
||||
: { background: 'var(--md-ref-surface-container)', color: 'var(--md-ref-on-surface)', border: '1px solid var(--md-ref-outline-variant)' }),
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function UserProfile() {
|
||||
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%))`;
|
||||
const bannerGrad = `linear-gradient(135deg, hsl(${h} 48% 82%), hsl(${(h + 40) % 360} 58% 68%))`;
|
||||
|
||||
const stats = (user && user.stats) || {};
|
||||
const listEmpty = !listLoading && list.length === 0 && !listError && !private403;
|
||||
|
||||
Reference in New Issue
Block a user