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

@@ -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">