feat: 友链入口迁至站点区并支持左右页脚独立开关

修复左侧栏/页脚开关无法关闭的问题,改为专用设置接口可靠写入。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 03:04:22 +08:00
parent 9eefe24a33
commit cce9fb9ed8
10 changed files with 364 additions and 21 deletions

View File

@@ -423,8 +423,19 @@ export const api = {
reciprocal_check_enabled?: boolean;
}>(`/api/admin/friend-link-applies${qs ? `?${qs}` : ''}`);
},
adminUpdateFriendLinkSettings: (body: { reciprocal_check_enabled: boolean }) =>
request<{ message: string; reciprocal_check_enabled: boolean }>('/api/admin/friend-link-settings', {
adminUpdateFriendLinkSettings: (body: {
reciprocal_check_enabled?: boolean;
nav_show_friend_links?: boolean;
footer_show_friend_links?: boolean;
aside_show_friend_links?: boolean;
}) =>
request<{
message: string;
reciprocal_check_enabled: boolean;
nav_show_friend_links: boolean;
footer_show_friend_links: boolean;
aside_show_friend_links: boolean;
}>('/api/admin/friend-link-settings', {
method: 'PUT',
body: JSON.stringify(body),
}),

View File

@@ -233,6 +233,10 @@ export interface ForumLimits {
aside_show_friend_links: boolean;
/** 右侧栏可选组件顺序与开关 */
aside_widgets: AsideWidget[];
/** 左侧栏「站点」展示友情链接入口 */
nav_show_friend_links: boolean;
/** 页脚展示友情链接入口 */
footer_show_friend_links: boolean;
/** 首页列表样式title 仅标题 / thumbnail 缩略图 */
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
/** 伪静态(固定链接)开关 */
@@ -259,6 +263,8 @@ export interface ForumLimitsPublic {
aside_show_recent_comments: boolean;
aside_show_friend_links: boolean;
aside_widgets: AsideWidget[];
nav_show_friend_links: boolean;
footer_show_friend_links: boolean;
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
permalink_enabled: boolean;
permalink_ext: string;

View File

@@ -66,6 +66,8 @@ export default function Sidebar({
const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages();
const { limits } = useForumLimits();
const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showSiteSection = navPages.length > 0 || showFriendLinksNav;
const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
@@ -135,7 +137,6 @@ export default function Sidebar({
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
{navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
</nav>
{(boardsLoading && boards.length === 0) ? (
@@ -191,10 +192,11 @@ export default function Sidebar({
</>
) : null}
{(navPages.length > 0) && (
{showSiteSection && (
<>
<div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
{navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
))}

View File

@@ -34,18 +34,20 @@ export default function SiteFooter() {
</div>
<nav className="site-footer__nav" aria-label="站点链接">
<span className="site-footer__friend">
<Link to="/links"></Link>
</span>
{footerPages.map(p => (
{limits.footer_show_friend_links !== false && (
<span className="site-footer__friend">
<Link to="/links"></Link>
</span>
)}
{footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend">
<FooterSep />
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
<FooterSep />
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />}
<a
href={icpURL}
target="_blank"

View File

@@ -21,6 +21,8 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
aside_show_recent_comments: false,
aside_show_friend_links: true,
aside_widgets: DEFAULT_ASIDE_WIDGETS,
nav_show_friend_links: true,
footer_show_friend_links: true,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',

View File

@@ -21,10 +21,16 @@ import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { FriendLink, FriendLinkApply, SiteBranding } from '../../api/types';
import type { ForumLimits, FriendLink, FriendLinkApply, SiteBranding } from '../../api/types';
import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import { formatTime } from '../../utils/content';
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../../utils/friendLink';
import {
mergeForumLimitsWithAsideWidgets,
normalizeAsideWidgets,
resolveAsideWidgets,
} from '../../utils/asideWidgets';
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
import { shouldShowSortableMoveButtons } from '../../utils/sortOrder';
@@ -126,6 +132,8 @@ export default function AdminLinksPage() {
const [handlingId, setHandlingId] = useState<number | null>(null);
const [reciprocalCheckEnabled, setReciprocalCheckEnabled] = useState(false);
const [reciprocalCheckSaving, setReciprocalCheckSaving] = useState(false);
const [forumLimits, setForumLimits] = useState<ForumLimits | null>(null);
const [entrySaving, setEntrySaving] = useState(false);
const baselineLinks = branding.friend_links ?? [];
const siteURL = branding.site_url;
@@ -134,10 +142,55 @@ export default function AdminLinksPage() {
[links, baselineLinks],
);
const entryFlags = useMemo(() => {
const widgets = resolveAsideWidgets({
aside_widgets: forumLimits?.aside_widgets,
aside_show_tag_cloud: forumLimits?.aside_show_tag_cloud ?? false,
aside_show_recent_comments: forumLimits?.aside_show_recent_comments ?? false,
aside_show_friend_links: forumLimits?.aside_show_friend_links ?? true,
});
return {
nav: forumLimits?.nav_show_friend_links !== false,
footer: forumLimits?.footer_show_friend_links !== false,
aside: widgets.find(w => w.id === 'friend_links')?.enabled ?? true,
};
}, [forumLimits]);
useEffect(() => {
setLinks(toLinkRows(baselineLinks));
}, [baselineLinks]);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(s => {
const loadedAsideWidgets = normalizeAsideWidgets(
resolveAsideWidgets({
aside_widgets: s.limits?.aside_widgets,
aside_show_tag_cloud: s.limits?.aside_show_tag_cloud ?? false,
aside_show_recent_comments: s.limits?.aside_show_recent_comments ?? false,
aside_show_friend_links: s.limits?.aside_show_friend_links ?? true,
}),
);
const base: ForumLimits = {
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: true,
aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true,
footer_show_friend_links: true,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
...s.limits,
};
setForumLimits(mergeForumLimitsWithAsideWidgets(base, loadedAsideWidgets));
})
.catch(() => { /* 入口开关稍后仍可按默认展示 */ });
}, [ready]);
const loadApplies = useCallback(async (status: ApplyStatusTab = applyStatus, page = 1) => {
setAppliesLoading(true);
try {
@@ -272,6 +325,114 @@ export default function AdminLinksPage() {
}
};
const patchEntryVisibility = async (patch: {
nav?: boolean;
footer?: boolean;
aside?: boolean;
}) => {
if (entrySaving) return;
const prev = {
nav: entryFlags.nav,
footer: entryFlags.footer,
aside: entryFlags.aside,
};
const nextFlags = {
nav: patch.nav ?? prev.nav,
footer: patch.footer ?? prev.footer,
aside: patch.aside ?? prev.aside,
};
// 乐观更新本地 limits避免开关回弹
setForumLimits(fl => {
if (!fl) {
return {
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: nextFlags.aside,
aside_widgets: normalizeAsideWidgets([
{ id: 'tag_cloud', enabled: false },
{ id: 'recent_comments', enabled: false },
{ id: 'recent_users', enabled: false },
{ id: 'friend_links', enabled: nextFlags.aside },
]),
nav_show_friend_links: nextFlags.nav,
footer_show_friend_links: nextFlags.footer,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
post_edit_window_hours: 24,
comment_edit_window_minutes: 3,
rate_limit_post: 10,
rate_limit_comment: 10,
rate_limit_register: 10,
rate_limit_login: 10,
rate_limit_window_sec: 60,
post_title_max: 128,
post_tags_max: 256,
post_content_max: 50000,
comment_max: 5000,
search_keyword_min: 1,
search_keyword_max: 50,
page_size_default: 30,
password_min_len: 6,
avatar_max_mb: 2,
signature_max: 200,
};
}
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
w.id === 'friend_links' ? { ...w, enabled: nextFlags.aside } : w
));
return mergeForumLimitsWithAsideWidgets({
...fl,
nav_show_friend_links: nextFlags.nav,
footer_show_friend_links: nextFlags.footer,
}, widgets);
});
setEntrySaving(true);
try {
const body: {
nav_show_friend_links?: boolean;
footer_show_friend_links?: boolean;
aside_show_friend_links?: boolean;
} = {};
if (patch.nav !== undefined) body.nav_show_friend_links = patch.nav;
if (patch.footer !== undefined) body.footer_show_friend_links = patch.footer;
if (patch.aside !== undefined) body.aside_show_friend_links = patch.aside;
const r = await api.adminUpdateFriendLinkSettings(body);
setForumLimits(fl => {
const base = fl;
if (!base) return fl;
const widgets = normalizeAsideWidgets(base.aside_widgets).map(w => (
w.id === 'friend_links' ? { ...w, enabled: r.aside_show_friend_links } : w
));
return mergeForumLimitsWithAsideWidgets({
...base,
nav_show_friend_links: r.nav_show_friend_links,
footer_show_friend_links: r.footer_show_friend_links,
aside_show_friend_links: r.aside_show_friend_links,
}, widgets);
});
invalidateForumLimitsCache();
notify.success(r.message);
} catch (e: unknown) {
setForumLimits(fl => {
if (!fl) return fl;
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
w.id === 'friend_links' ? { ...w, enabled: prev.aside } : w
));
return mergeForumLimitsWithAsideWidgets({
...fl,
nav_show_friend_links: prev.nav,
footer_show_friend_links: prev.footer,
}, widgets);
});
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setEntrySaving(false);
}
};
const submitReject = async () => {
if (!rejectTarget) return;
setRejecting(true);
@@ -412,6 +573,37 @@ export default function AdminLinksPage() {
</div>
</header>
<div className="admin-card admin-links-entry-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
访 /links
</p>
<div className="admin-card-body admin-links-entry-body">
{(
[
{ key: 'nav' as const, label: '左侧栏(站点)', on: entryFlags.nav },
{ key: 'footer' as const, label: '页脚', on: entryFlags.footer },
{ key: 'aside' as const, label: '右侧栏', on: entryFlags.aside },
]
).map(item => (
<div key={item.key} className="admin-links-entry-row">
<span id={`admin-links-entry-${item.key}`}>{item.label}</span>
<button
type="button"
role="switch"
aria-checked={item.on}
aria-labelledby={`admin-links-entry-${item.key}`}
disabled={entrySaving}
className={cn('admin-settings-switch', item.on && 'is-on')}
onClick={() => patchEntryVisibility({ [item.key]: !item.on })}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
))}
</div>
</div>
<div className="admin-links-columns">
<div className="admin-card admin-links-apply-card">
<div className="admin-card-head">

View File

@@ -299,6 +299,8 @@ export default function AdminSettingsPage() {
aside_show_recent_comments: false,
aside_show_friend_links: true,
aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true,
footer_show_friend_links: true,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',

View File

@@ -13826,6 +13826,25 @@ button.post-poll__option,
gap: 8px;
align-items: center;
}
.admin-links-entry-card {
margin-bottom: 16px;
}
.admin-links-entry-body {
display: flex;
flex-wrap: wrap;
gap: 12px 28px;
}
.admin-links-entry-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--foreground, #334155);
}
.admin-links-entry-row .admin-settings-switch:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-links-columns {
display: flex;
flex-direction: column;