feat: 后台侧边栏重构(6组折叠导航 + 设置项搜索命令面板)+ 设置项挪窝(评论→博客、代理→面板链接、论坛私密开关补 UI)

This commit is contained in:
2026-08-14 01:32:05 +08:00
parent 171f1f5893
commit 92381b5c78
12 changed files with 511 additions and 123 deletions
+193 -48
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTheme as useMuiTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
@@ -13,48 +13,35 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListItem from '@mui/material/ListItem';
import ListSubheader from '@mui/material/ListSubheader';
import Collapse from '@mui/material/Collapse';
import MenuItem from '@mui/material/MenuItem';
import Popover from '@mui/material/Popover';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputAdornment from '@mui/material/InputAdornment';
import MenuIcon from '@mui/icons-material/Menu';
import HomeIcon from '@mui/icons-material/Home';
import GridViewIcon from '@mui/icons-material/GridView';
import LogoutIcon from '@mui/icons-material/Logout';
import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import PaletteIcon from '@mui/icons-material/Palette';
import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article';
import ForumIcon from '@mui/icons-material/Forum';
import ListAltIcon from '@mui/icons-material/ListAlt';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import RssFeedIcon from '@mui/icons-material/RssFeed';
import SearchIcon from '@mui/icons-material/Search';
import CloseIcon from '@mui/icons-material/Close';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { logout } from '../api/auth.js';
import SnackHost, { showSnack } from './snack.jsx';
import { NAV_GROUPS, searchNav } from './navConfig.js';
const NAV_ITEMS = [
{ path: '/dashboard', label: '仪表盘', icon: <DashboardIcon /> },
{ path: '/settings', label: '站点设置', icon: <SettingsIcon /> },
{ path: '/captcha', label: '验证码', icon: <VerifiedUserIcon /> },
{ path: '/theme', label: '主题', icon: <PaletteIcon /> },
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
{ path: '/posts', label: '帖子管理', icon: <ListAltIcon /> },
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> },
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
{ path: '/links', label: '面板链接', icon: <LinkIcon /> },
{ path: '/rss', label: 'RSS 订阅', icon: <RssFeedIcon /> },
{ path: '/uploads', label: '附件管理', icon: <AttachFileIcon /> },
{ path: '/import', label: '数据导入', icon: <UploadFileIcon /> },
];
/** 等元素挂载(跨路由跳转后目标页异步渲染,最多轮询 ~2s) */
function waitForId(id, tries = 40) {
return document.getElementById(id)
? Promise.resolve()
: tries > 0
? new Promise((res) => setTimeout(() => res(waitForId(id, tries - 1)), 50))
: Promise.resolve();
}
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
/** 后台布局:AppBar(返回前台 + 工作台 + 退出)+ Drawer(分组折叠导航 + 设置搜索)+ 内容区 Outlet */
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
@@ -62,19 +49,179 @@ export default function AdminLayout() {
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
// 分组折叠状态:默认展开全部(单成员组平铺,不参与折叠)
const [openGroups, setOpenGroups] = useState(() => (
new Set(NAV_GROUPS.filter((g) => g.items.length > 1).map((g) => g.id))
));
// 设置搜索
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const searchRef = useRef(null);
const results = useMemo(() => searchNav(searchQuery), [searchQuery]);
// 当前项所在组自动展开
useEffect(() => {
const current = NAV_GROUPS.find((g) => g.items.some((it) => location.pathname.startsWith(it.path)));
if (current && current.items.length > 1 && !openGroups.has(current.id)) {
setOpenGroups((prev) => new Set(prev).add(current.id));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// Esc 关闭搜索下拉
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') setSearchOpen(false); };
if (searchOpen) window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [searchOpen]);
const toggleGroup = (id) => {
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const goMenu = (path) => {
navigate(path);
setMobileOpen(false);
};
const jump = (r) => {
setSearchQuery('');
setSearchOpen(false);
if (r.type === 'menu') { goMenu(r.path); return; }
// 设置区块:跳转页面后定位 + 高亮对应 Paper
const scroll = () => {
const el = document.getElementById(r.anchor);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
const c = muiTheme.palette.primary.main;
el.style.transition = 'box-shadow 0.5s ease';
el.style.boxShadow = `0 0 0 3px ${c}`;
window.setTimeout(() => { el.style.boxShadow = 'none'; el.style.transition = ''; }, 1800);
};
if (location.pathname === r.path) scroll();
else { navigate(r.path); waitForId(r.anchor).then(scroll); }
};
const onSearchChange = (v) => {
setSearchQuery(v);
setSearchOpen(v.trim().length >= 1);
};
const onSearchKeyDown = (e) => {
if (e.key === 'Escape') { setSearchOpen(false); return; }
if (e.key === 'Enter' && results.length === 1) { jump(results[0]); }
};
/** 单成员组平铺渲染,多成员组显示组头 + Collapse 折叠 */
const renderGroup = (g) => {
const single = g.items.length === 1;
const open = openGroups.has(g.id);
const items = (single ? g.items : (
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{g.items.map((item) => (
<ListItemButton
key={item.path}
selected={location.pathname.startsWith(item.path)}
onClick={() => { goMenu(item.path); }}
sx={{ pl: 4 }}
>
<ListItemIcon>{item.icon ? <item.icon fontSize="small" /> : null}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
))}
</List>
</Collapse>
));
return (
<Box key={g.id}>
{single ? null : (
<ListSubheader
component="div"
disableSticky
onClick={() => toggleGroup(g.id)}
sx={{
cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center',
gap: 0.5, fontSize: 12, fontWeight: 700, letterSpacing: 0.06, lineHeight: 2,
}}
>
<Box sx={{ flex: 1 }}>{g.label}</Box>
{open ? <ExpandLessIcon sx={{ fontSize: 16, color: 'text.disabled' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'text.disabled' }} />}
</ListSubheader>
)}
{items}
</Box>
);
};
const drawerContent = (
<List>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.path}
selected={location.pathname === item.path}
onClick={() => { navigate(item.path); setMobileOpen(false); }}
<>
<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',
},
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
<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>
</>
);
const handleLogout = () => {
@@ -115,12 +262,10 @@ export default function AdminLayout() {
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
>
<Toolbar />
{drawerContent}
</Drawer>
) : (
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
<Toolbar />
{drawerContent}
</Drawer>
)}
+171
View File
@@ -0,0 +1,171 @@
import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import PaletteIcon from '@mui/icons-material/Palette';
import HomeIcon from '@mui/icons-material/Home';
import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import ForumIcon from '@mui/icons-material/Forum';
import ListAltIcon from '@mui/icons-material/ListAlt';
import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link';
import RssFeedIcon from '@mui/icons-material/RssFeed';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import UploadFileIcon from '@mui/icons-material/UploadFile';
/**
* 后台侧边栏导航配置(单一数据源):
* - NAV_GROUPS 分组结构,供侧边栏渲染
* - NAV_SECTIONS 各设置页区块锚点(id 与页面 Paper 的 id 对应),供搜索定位
* - NAV_ALIASES 别名映射,把常用叫法/英文指到现有条目
* - searchNav() 前端本地过滤(无需后端)
*/
export const NAV_GROUPS = [
{
id: 'overview',
label: '概览',
items: [
{ path: '/dashboard', label: '仪表盘', icon: DashboardIcon },
],
},
{
id: 'content',
label: '内容管理',
items: [
{ path: '/blog', label: '博客管理', icon: ArticleIcon },
{ path: '/comments', label: '评论管理', icon: ChatBubbleOutlineOutlinedIcon },
{ path: '/announcements', label: '公告管理', icon: CampaignIcon },
{ path: '/forum', label: '论坛管理', icon: ForumIcon },
{ path: '/posts', label: '帖子管理', icon: ListAltIcon },
],
},
{
id: 'users',
label: '用户',
items: [
{ path: '/users', label: '用户管理', icon: PeopleIcon },
],
},
{
id: 'appearance',
label: '外观',
items: [
{ path: '/theme', label: '主题', icon: PaletteIcon },
{ path: '/homepage', label: '首页设置', icon: HomeIcon },
],
},
{
id: 'system',
label: '系统设置',
items: [
{ path: '/settings', label: '站点设置', icon: SettingsIcon },
{ path: '/captcha', label: '验证码', icon: VerifiedUserIcon },
{ path: '/email', label: '邮件配置', icon: EmailIcon },
{ path: '/rss', label: 'RSS 订阅', icon: RssFeedIcon },
],
},
{
id: 'tools',
label: '工具',
items: [
{ path: '/links', label: '面板链接', icon: LinkIcon },
{ path: '/uploads', label: '附件管理', icon: AttachFileIcon },
{ path: '/import', label: '数据导入', icon: UploadFileIcon },
],
},
];
/** 各设置页区块:anchor 必须与页面 Paper/Box 的 id 一致;keywords 为搜索词 */
export const NAV_SECTIONS = [
// 站点设置 /settings
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-basic', label: '基本设置', keywords: ['基本信息', '网站名称', '网站描述', '网站域名', 'favicon', '图标'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-footer', label: '页脚设置', keywords: ['页脚', 'footer', '版权', '分栏导航', '页脚栏目', 'Powered'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-search-verify', label: '搜索引擎验证', keywords: ['SEO', 'Bing', 'Google', 'Yandex', '站长工具', '验证文件'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-rainid', label: 'RainID 单点登录', keywords: ['SSO', '单点登录', 'OAuth', 'OIDC', 'client', '注册跳转'] },
// 主题 /theme
{ page: '/theme', pageLabel: '主题', anchor: 'sec-primary-color', label: '主题色', keywords: ['颜色', '主色调', '配色', 'primary'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-wallpaper', label: '壁纸背景', keywords: ['wallpaper', '背景图', '背景'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-styles', label: '样式设置', keywords: ['导航栏', '卡片', 'nav', '磨砂'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-glass', label: '玻璃效果', keywords: ['磨砂玻璃', '模糊', '透明度', 'glass', 'blur'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-dark', label: '深色模式', keywords: ['暗色', 'dark', '夜间模式'] },
// 首页设置 /homepage
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-profile', label: '个人信息', keywords: ['头像', '简介', 'bio', '联系链接'] },
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-home-content', label: '主页内容', keywords: ['正文', 'markdown', '内容'] },
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-music', label: '音乐嵌入', keywords: ['播放器', '网易云', 'music', 'embed'] },
// 邮件配置 /email
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-smtp', label: 'SMTP 服务器', keywords: ['smtp', '主机', '端口', '服务器'] },
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-sender', label: '发件人信息', keywords: ['发件人', 'from', '邮箱'] },
// 验证码 /captcha
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-type', label: '验证码类型', keywords: ['captcha', '类型', '内置'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-scope', label: '验证场景', keywords: ['登录验证', '注册验证', '发帖验证'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-recaptcha', label: 'reCAPTCHA 配置', keywords: ['Google', 'recaptcha', 'site key'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-turnstile', label: 'Turnstile 配置', keywords: ['Cloudflare', 'turnstile', 'site key'] },
// RSS /rss
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-sources', label: '订阅源', keywords: ['源', 'feed', '订阅'] },
{ 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'] },
// 论坛管理 /forum
{ page: '/forum', pageLabel: '论坛管理', anchor: 'sec-forum-settings', label: '论坛设置', keywords: ['访客可见', '游客', '私密', 'guest', '权限'] },
// 面板链接 /links
{ page: '/links', pageLabel: '面板链接', anchor: 'sec-proxy', label: '面板代理', keywords: ['代理', 'proxy', '内网', '白名单', 'SSRF', 'iframe'] },
];
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor */
export const NAV_ALIASES = [
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
{ keywords: ['rss', '订阅', 'feed'], path: '/rss' },
{ keywords: ['favicon', '图标'], path: '/settings', anchor: 'sec-basic' },
{ keywords: ['单点登录', 'sso', 'rainid'], path: '/settings', anchor: 'sec-rainid' },
{ keywords: ['壁纸', 'wallpaper'], path: '/theme', anchor: 'sec-wallpaper' },
{ keywords: ['评论', '审核'], path: '/blog', anchor: 'sec-comments' },
{ keywords: ['代理', '内网'], path: '/links', anchor: 'sec-proxy' },
];
/** 搜索索引:菜单项 + 设置区块 + 别名(模块加载时构建一次) */
function buildIndex() {
const index = [];
NAV_GROUPS.forEach((g) => {
g.items.forEach((item) => {
index.push({
type: 'menu',
label: item.label,
group: g.label,
path: item.path,
icon: item.icon,
keywords: [item.label],
});
});
});
NAV_SECTIONS.forEach((sec) => {
const pageItem = index.find((e) => e.type === 'menu' && e.path === sec.page);
index.push({
type: 'section',
label: sec.label,
group: sec.pageLabel,
path: sec.page,
anchor: sec.anchor,
icon: pageItem ? pageItem.icon : null,
keywords: [sec.label, ...(sec.keywords || [])],
});
});
NAV_ALIASES.forEach((a) => {
const target = index.find((e) =>
e.path === a.path && (a.anchor ? e.anchor === a.anchor : !e.anchor));
if (target) target.keywords.push(...a.keywords);
});
return index;
}
const SEARCH_INDEX = buildIndex();
/** 前端本地过滤:输入 ≥1 字符返回结果(大小写不敏感、子串匹配) */
export function searchNav(query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
return SEARCH_INDEX.filter((e) => e.keywords.some((k) => String(k).toLowerCase().includes(q)));
}
+43 -3
View File
@@ -20,10 +20,12 @@ import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
/** 博客管理:博客侧栏开关 + 评论设置 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
export default function BlogManage() {
const [posts, setPosts] = useState(null);
const [showSidebar, setShowSidebar] = useState(true);
const [commentModerate, setCommentModerate] = useState('0');
const [commentNotify, setCommentNotify] = useState('0');
const [confirm, setConfirm] = useState(null); // { id, title }
const [deleting, setDeleting] = useState(false);
@@ -35,7 +37,11 @@ export default function BlogManage() {
useEffect(() => {
load();
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
getSettings().then((s) => {
setShowSidebar(s.blog_show_sidebar !== '0');
setCommentModerate(s.comment_moderate === '1' ? '1' : '0');
setCommentNotify(s.comment_notify === '1' ? '1' : '0');
}).catch(() => {});
}, [load]);
const saveSidebar = async () => {
@@ -47,6 +53,15 @@ export default function BlogManage() {
}
};
const saveCommentSettings = async () => {
try {
await saveSettings({ comment_moderate: commentModerate, comment_notify: commentNotify });
showSnack('评论设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
};
const togglePublish = async (p) => {
try {
await updatePost(p.id, {
@@ -84,7 +99,7 @@ export default function BlogManage() {
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
</Box>
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Paper id="sec-blog-sidebar" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
@@ -94,6 +109,31 @@ export default function BlogManage() {
</Box>
</Paper>
<Paper id="sec-comments" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>评论设置</Typography>
<FormControlLabel
control={<Switch checked={commentModerate === '1'} onChange={(e) => setCommentModerate(e.target.checked ? '1' : '0')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
</Box>
)}
/>
<FormControlLabel
control={<Switch checked={commentNotify === '1'} onChange={(e) => setCommentNotify(e.target.checked ? '1' : '0')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
</Box>
)}
/>
<Box sx={{ mt: 1 }}>
<Button variant="outlined" size="small" onClick={saveCommentSettings}>保存评论设置</Button>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
+4 -4
View File
@@ -73,7 +73,7 @@ export default function CaptchaSettings() {
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
<Paper sx={{ p: 3 }}>
<Paper id="sec-captcha-type" sx={{ p: 3 }}>
<FormControl fullWidth margin="normal">
<InputLabel>验证码类型</InputLabel>
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
@@ -86,7 +86,7 @@ export default function CaptchaSettings() {
</FormControl>
{showScope && (
<FormGroup>
<FormGroup id="sec-captcha-scope">
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
<FormControlLabel control={<Switch checked={form.captcha_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
@@ -94,7 +94,7 @@ export default function CaptchaSettings() {
)}
{showRecaptcha && (
<Box sx={{ mt: 2 }}>
<Box id="sec-captcha-recaptcha" sx={{ mt: 2 }}>
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
@@ -102,7 +102,7 @@ export default function CaptchaSettings() {
)}
{showTurnstile && (
<Box sx={{ mt: 2 }}>
<Box id="sec-captcha-turnstile" sx={{ mt: 2 }}>
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
+2 -2
View File
@@ -66,14 +66,14 @@ export default function EmailSettings() {
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-smtp" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>SMTP 服务器</Typography>
<TextField fullWidth label="SMTP 主机" value={form.smtp_host} onChange={set('smtp_host')} margin="normal" placeholder="smtp.example.com" />
<TextField fullWidth label="端口" type="number" value={form.smtp_port} onChange={set('smtp_port')} margin="normal" placeholder="587" />
<TextField fullWidth label="用户名" value={form.smtp_user} onChange={set('smtp_user')} margin="normal" />
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-sender" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
+38 -1
View File
@@ -23,8 +23,11 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import { listCategories, createCategory, updateCategory, deleteCategory, updateAnnouncement, updateModerators } from '../../api/forum.js';
import { listUsers } from '../../api/auth.js';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
@@ -66,13 +69,31 @@ export default function ForumManage() {
const [annDialog, setAnnDialog] = useState(null); // { id, name, text }
const [annSaving, setAnnSaving] = useState(false);
// 论坛设置(forum_guest_visible):'1'=游客可见(默认)
const [guestVisible, setGuestVisible] = useState(true);
const [settingSaving, setSettingSaving] = useState(false);
const load = useCallback(() => {
listCategories()
.then((cs) => setCats(cs || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
getSettings().then((s) => setGuestVisible(s.forum_guest_visible !== '0')).catch(() => {});
}, [load]);
const saveForumSettings = async () => {
setSettingSaving(true);
try {
await saveSettings({ forum_guest_visible: guestVisible ? '1' : '0' });
showSnack('论坛设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSettingSaving(false);
};
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (c) => {
@@ -176,6 +197,22 @@ export default function ForumManage() {
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
</Box>
<Paper id="sec-forum-settings" sx={{ p: 2.5, mb: 2, maxWidth: 640 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>论坛设置</Typography>
<FormControlLabel
control={<Switch checked={guestVisible} onChange={(e) => setGuestVisible(e.target.checked)} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>访客可见</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后未登录访客可浏览论坛关闭则需登录私密模式SEO 同时隐藏</Box>
</Box>
)}
/>
<Box sx={{ mt: 1 }}>
<Button variant="outlined" size="small" onClick={saveForumSettings} disabled={settingSaving}>保存论坛设置</Button>
</Box>
</Paper>
{cats.length === 0 ? (
<Typography variant="body2" color="text.secondary">暂无板块点击"添加板块"创建</Typography>
) : (
+3 -3
View File
@@ -103,7 +103,7 @@ export default function Homepage() {
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-profile" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>个人信息</Typography>
<TextField fullWidth label="头像 URL" type="url" value={form.homepage_avatar} onChange={set('homepage_avatar')} margin="normal" placeholder="https://example.com/avatar.jpg" />
<TextField fullWidth label="个人简介" multiline rows={2} value={form.homepage_bio} onChange={set('homepage_bio')} margin="normal" placeholder="一段简短的自我介绍" />
@@ -126,7 +126,7 @@ export default function Homepage() {
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-home-content" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
@@ -138,7 +138,7 @@ export default function Homepage() {
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-music" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
+40 -2
View File
@@ -22,6 +22,7 @@ import TextField from '@mui/material/TextField';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
@@ -30,13 +31,15 @@ const EMPTY = {
icon: '', category: '默认', version: '', sort_order: '0',
};
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片) */
/** 面板链接管理:CRUD + 代理嵌入开关 + 面板代理白名单(迁移自 v1 面板链接卡片) */
export default function Links() {
const [links, setLinks] = useState(null);
const [dialog, setDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY);
const [confirm, setConfirm] = useState(null);
const [proxyHosts, setProxyHosts] = useState('');
const [proxySaving, setProxySaving] = useState(false);
const load = useCallback(() => {
listAdminLinks()
@@ -44,7 +47,10 @@ export default function Links() {
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
getSettings().then((s) => setProxyHosts(s.proxy_allowed_hosts || '')).catch(() => {});
}, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (l) => {
@@ -99,6 +105,17 @@ export default function Links() {
}
};
const saveProxy = async () => {
setProxySaving(true);
try {
await saveSettings({ proxy_allowed_hosts: proxyHosts });
showSnack('面板代理设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setProxySaving(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
@@ -144,6 +161,27 @@ export default function Links() {
</Table>
</TableContainer>
<Paper id="sec-proxy" sx={{ p: 2.5, mt: 2, maxWidth: 720 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 0.5 }}>面板代理</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, fontSize: 13 }}>
通过面板代理嵌入面板绕过 X-Frame-Options 限制把可信内网地址/网段加入白名单
</Typography>
<TextField
fullWidth
label="代理允许的内网地址(白名单)"
value={proxyHosts}
onChange={(e) => setProxyHosts(e.target.value)}
margin="normal"
multiline
minRows={3}
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
/>
<Box sx={{ mt: 1 }}>
<Button variant="contained" onClick={saveProxy} disabled={proxySaving}>保存面板代理设置</Button>
</Box>
</Paper>
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
<DialogContent>
+2 -2
View File
@@ -150,7 +150,7 @@ export default function RssManage() {
</Typography>
{/* 源管理 */}
<Paper sx={{ p: 2.5, mb: 2 }}>
<Paper id="sec-rss-sources" sx={{ p: 2.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<RssFeedIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontSize: 16 }}>订阅源</Typography>
@@ -206,7 +206,7 @@ export default function RssManage() {
</Paper>
{/* 内容设置 */}
<Paper sx={{ p: 2.5 }}>
<Paper id="sec-rss-content" sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ mb: 1, fontSize: 16 }}>内容设置</Typography>
<FormControlLabel
+5 -53
View File
@@ -29,7 +29,7 @@ const FOOTER_STYLES = [
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
];
/** 基本设置 + 页脚设置 + 面板代理 + RainID 单点登录(v2 */
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2 */
export default function Settings() {
const [form, setForm] = useState({
site_name: '',
@@ -40,9 +40,6 @@ export default function Settings() {
footer_copyright: '',
footer_powered: '',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
proxy_allowed_hosts: '',
rainid_enabled: '0',
rainid_client_id: '',
rainid_client_secret: '',
@@ -109,9 +106,6 @@ export default function Settings() {
footer_copyright: s.footer_copyright || '',
footer_powered: s.footer_powered || '',
footer_desc: s.footer_desc || '',
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
comment_notify: s.comment_notify === '1' ? '1' : '0',
proxy_allowed_hosts: s.proxy_allowed_hosts || '',
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
rainid_client_id: s.rainid_client_id || '',
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
@@ -162,38 +156,14 @@ export default function Settings() {
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
<Paper sx={{ p: 3 }}>
<Paper id="sec-basic" sx={{ p: 3 }}>
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
<TextField fullWidth label="网站描述(SEO" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>评论设置</Typography>
<FormControlLabel
control={<Switch checked={form.comment_moderate === '1'} onChange={toggle('comment_moderate')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
</Box>
)}
/>
<FormControlLabel
control={<Switch checked={form.comment_notify === '1'} onChange={toggle('comment_notify')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
</Box>
)}
/>
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Paper id="sec-footer" sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
<FormControl component="fieldset" sx={{ mb: 1 }}>
@@ -273,24 +243,7 @@ export default function Settings() {
)}
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>面板代理</Typography>
<TextField
fullWidth
label="代理允许的内网地址(白名单)"
value={form.proxy_allowed_hosts}
onChange={set('proxy_allowed_hosts')}
margin="normal"
multiline
minRows={3}
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
/>
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>搜索引擎验证</Typography>
<Paper id="sec-search-verify" sx={{ p: 3, mt: 2 }}>
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
Bing / Google / Yandex 站长工具要求把验证文件上传到网站根目录上传后即可经根路径访问
</Typography>
@@ -341,8 +294,7 @@ export default function Settings() {
)}
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>RainID 单点登录</Typography>
<Paper id="sec-rainid" sx={{ p: 3, mt: 2 }}>
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
通过 RainID 统一身份认证支持 ROPC 密码登录与授权码 SSO
</Typography>
+5 -5
View File
@@ -85,7 +85,7 @@ export default function ThemeSettings() {
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-primary-color" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
@@ -93,7 +93,7 @@ export default function ThemeSettings() {
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-wallpaper" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
<FormControlLabel
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
@@ -119,7 +119,7 @@ export default function ThemeSettings() {
</TextField>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-styles" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</Typography>
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
@@ -134,7 +134,7 @@ export default function ThemeSettings() {
</ToggleButtonGroup>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-glass" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</Typography>
<Slider min={5} max={40} value={parseInt(form.glass_blur, 10) || 20} onChange={(e, v) => setForm((p) => ({ ...p, glass_blur: String(v) }))} sx={{ maxWidth: 400 }} />
@@ -142,7 +142,7 @@ export default function ThemeSettings() {
<Slider min={0.1} max={0.95} step={0.05} value={parseFloat(form.glass_opacity) || 0.6} onChange={(e, v) => setForm((p) => ({ ...p, glass_opacity: String(v) }))} sx={{ maxWidth: 400 }} />
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-dark" sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
<FormControlLabel
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
+5
View File
@@ -18,6 +18,11 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 设置区块锚点(搜索跳转定位):预留固定 AppBar 高度,避免被遮挡 */
[id^="sec-"] {
scroll-margin-top: 88px;
}
/* 滚动条(对应前台细滚动条) */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }