feat: 后台侧边栏重构(6组折叠导航 + 设置项搜索命令面板)+ 设置项挪窝(评论→博客、代理→面板链接、论坛私密开关补 UI)
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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..." />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 代码" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }))} />}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user