From 9eefe24a33cb89e267b2104bb6b5923e1eeaea36 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 28 Aug 2026 02:39:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=8D=95=E9=A1=B5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E3=80=81=E5=88=97=E8=A1=A8=E7=95=99=E7=99=BD?= =?UTF-8?q?=E4=B8=8E=E5=8F=8B=E9=93=BE=E7=94=B3=E8=AF=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。 Co-authored-by: Cursor --- frontend/src/App.tsx | 3 + frontend/src/api/client.ts | 6 + frontend/src/api/types.ts | 2 + frontend/src/components/ArticleEditor.tsx | 140 ++--- .../src/components/FriendLinkApplyDialog.tsx | 56 +- .../src/components/FriendLinkSiteInfo.tsx | 16 +- frontend/src/pages/FavoritesPage.tsx | 18 +- frontend/src/pages/LinksPage.tsx | 361 +++++++------ frontend/src/pages/ProjectsPage.tsx | 24 +- frontend/src/pages/admin/AdminPagesPage.tsx | 243 ++++----- .../src/pages/admin/AdminSitePageEditPage.tsx | 293 +++++++++++ frontend/src/styles/global.css | 484 ++++++++++++++++-- frontend/src/utils/markdownFormat.ts | 15 + handler/special.go | 40 ++ router/router.go | 2 + service/content.go | 12 + service/content_test.go | 18 + service/site_page.go | 15 +- 18 files changed, 1318 insertions(+), 430 deletions(-) create mode 100644 frontend/src/pages/admin/AdminSitePageEditPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13ff3bc..6eb33a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage' const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); +const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage')); const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); @@ -56,6 +57,8 @@ const router = createBrowserRouter( } /> }>} /> }>} /> + }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cd44bc1..a349565 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -397,10 +397,16 @@ export const api = { postLotteryDraw: (id: number) => request<{ message: string; lottery: PostLotteryView }>(`/api/posts/${id}/lottery/draw`, { method: 'POST', body: '{}' }), adminPages: () => request<{ pages: SitePage[] }>('/api/admin/pages'), + adminPage: (id: number) => request<{ page: SitePage }>(`/api/admin/pages/${id}`), adminCreatePage: (data: Partial) => request<{ message: string; page: SitePage }>('/api/admin/pages', { method: 'POST', body: JSON.stringify(data) }), adminUpdatePage: (id: number, data: Partial) => request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + adminSetPagePublished: (id: number, published: boolean) => + request<{ message: string; published: boolean }>(`/api/admin/pages/${id}/published`, { + method: 'PUT', + body: JSON.stringify({ published }), + }), adminDeletePage: (id: number) => request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'DELETE' }), adminFriendLinkApplies: (params?: { page?: number; size?: number; status?: string }) => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c8bf626..aad28e5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -301,6 +301,8 @@ export interface SitePageSummary { export interface SitePage extends SitePageSummary { content: string; published: boolean; + created_at?: string; + updated_at?: string; } export interface PollOptionView { diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 40c8401..ea29d09 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -27,6 +27,7 @@ import { cycleMarkdownHeading, insertMarkdownMembersOnly, insertMarkdownReplyOnly, + insertMarkdownPointsOnly, insertMarkdownLink, } from '../utils/markdownFormat'; import { countWords } from '../utils/text'; @@ -63,6 +64,11 @@ interface Props { value: string; onChange: (v: string) => void; placeholder?: string; + /** + * 是否启用登录/回复/积分可见区块。 + * 发帖默认 true;自定义单页等场景应关闭。 + */ + enableContentGates?: boolean; } type EditorMode = 'rich' | 'markdown'; @@ -211,7 +217,7 @@ function renderToolButtons(tools: ToolBtn[]) { } const ArticleEditor = forwardRef(function ArticleEditor( - { value, onChange, placeholder = '在此撰写正文…' }, + { value, onChange, placeholder = '在此撰写正文…', enableContentGates = true }, ref, ) { const isInternalUpdate = useRef(false); @@ -274,9 +280,7 @@ const ArticleEditor = forwardRef(function ArticleEdi }, includeChildren: true, }), - MembersOnly, - ReplyOnly, - PointsOnly, + ...(enableContentGates ? [MembersOnly, ReplyOnly, PointsOnly] : []), TabIndent, ], content: sanitizeHtml(value) || '', @@ -698,65 +702,81 @@ const ArticleEditor = forwardRef(function ArticleEdi ); } - tools.push( - { - icon: , - title: '登录可见', - hint: '插入或包裹;区块内 Ctrl+Enter 退出', - active: editor.isActive('membersOnly'), - className: 'article-tool-btn--members', - action: wrapMembersOnly, - }, - { - icon: , - title: '回复可见', - hint: '读者回复后才可见;区块内 Ctrl+Enter 退出', - active: editor.isActive('replyOnly'), - className: 'article-tool-btn--reply', - action: wrapReplyOnly, - }, - { - icon: , - title: '积分可见', - hint: '读者花费积分解锁;可设价格', - active: editor.isActive('pointsOnly'), - className: 'article-tool-btn--points', - action: wrapPointsOnly, - }, - ); + if (enableContentGates) { + tools.push( + { + icon: , + title: '登录可见', + hint: '插入或包裹;区块内 Ctrl+Enter 退出', + active: editor.isActive('membersOnly'), + className: 'article-tool-btn--members', + action: wrapMembersOnly, + }, + { + icon: , + title: '回复可见', + hint: '读者回复后才可见;区块内 Ctrl+Enter 退出', + active: editor.isActive('replyOnly'), + className: 'article-tool-btn--reply', + action: wrapReplyOnly, + }, + { + icon: , + title: '积分可见', + hint: '读者花费积分解锁;可设价格', + active: editor.isActive('pointsOnly'), + className: 'article-tool-btn--points', + action: wrapPointsOnly, + }, + ); + } return tools; - }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); - const buildMarkdownTools = useCallback((): ToolBtn[] => [ - { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, - { icon: , title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) }, - { icon: , title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) }, - { icon: , title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '', '', '下划线文字', ch)) }, - { icon: , title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) }, - { icon: , title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) }, - { icon: , title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) }, - { icon: , title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) }, - { icon: , title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) }, - { icon: , title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, - { icon: , title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') }, - { icon: , title: '链接', action: () => openLinkDialog('markdown') }, - { icon: , title: '上传图片', action: insertMarkdownImage }, - { - icon: , - title: '登录可见', - hint: '插入 区块', - className: 'article-tool-btn--members', - action: withMarkdown(insertMarkdownMembersOnly), - }, - { - icon: , - title: '回复可见', - hint: '插入 区块', - className: 'article-tool-btn--reply', - action: withMarkdown(insertMarkdownReplyOnly), - }, - ], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]); + const buildMarkdownTools = useCallback((): ToolBtn[] => { + const tools: ToolBtn[] = [ + { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, + { icon: , title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) }, + { icon: , title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) }, + { icon: , title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '', '', '下划线文字', ch)) }, + { icon: , title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) }, + { icon: , title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) }, + { icon: , title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) }, + { icon: , title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) }, + { icon: , title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) }, + { icon: , title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, + { icon: , title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') }, + { icon: , title: '链接', action: () => openLinkDialog('markdown') }, + { icon: , title: '上传图片', action: insertMarkdownImage }, + ]; + if (enableContentGates) { + tools.push( + { + icon: , + title: '登录可见', + hint: '插入 区块', + className: 'article-tool-btn--members', + action: withMarkdown(insertMarkdownMembersOnly), + }, + { + icon: , + title: '回复可见', + hint: '插入 区块', + className: 'article-tool-btn--reply', + action: withMarkdown(insertMarkdownReplyOnly), + }, + { + icon: , + title: '积分可见', + hint: '插入 区块', + className: 'article-tool-btn--points', + action: withMarkdown(insertMarkdownPointsOnly), + }, + ); + } + return tools; + }, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]); const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools(); const words = mode === 'markdown' diff --git a/frontend/src/components/FriendLinkApplyDialog.tsx b/frontend/src/components/FriendLinkApplyDialog.tsx index c2a4f0c..a46ff23 100644 --- a/frontend/src/components/FriendLinkApplyDialog.tsx +++ b/frontend/src/components/FriendLinkApplyDialog.tsx @@ -188,35 +188,43 @@ export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, o > 友链在我的网站首页 - + +
+
+ + setReciprocalPageURL(e.target.value)} + placeholder="如:https://您的域名/link.htm" + maxLength={512} + tabIndex={linkPlacement === 'custom' ? 0 : -1} + /> +

+ 请填写实际放置本站友链的页面,提交后将在后台检测该页面 +

+
+
+ - {linkPlacement === 'custom' && ( -
- - setReciprocalPageURL(e.target.value)} - placeholder="如:https://您的域名/link.htm" - maxLength={512} - /> -

- 请填写实际放置本站友链的页面,提交后将在后台检测该页面 -

-
- )}
diff --git a/frontend/src/components/FriendLinkSiteInfo.tsx b/frontend/src/components/FriendLinkSiteInfo.tsx index ec46257..6c80f08 100644 --- a/frontend/src/components/FriendLinkSiteInfo.tsx +++ b/frontend/src/components/FriendLinkSiteInfo.tsx @@ -32,23 +32,11 @@ export default function FriendLinkSiteInfo() {
地址
-
- {siteURL ? ( - {siteURL} - ) : ( - '—' - )} -
+
{siteURL || '—'}
LOGO
-
- {siteLogoURL ? ( - {siteLogoURL} - ) : ( - '—' - )} -
+
{siteLogoURL || '—'}
diff --git a/frontend/src/pages/FavoritesPage.tsx b/frontend/src/pages/FavoritesPage.tsx index a24235e..679aca4 100644 --- a/frontend/src/pages/FavoritesPage.tsx +++ b/frontend/src/pages/FavoritesPage.tsx @@ -44,16 +44,18 @@ export default function FavoritesPage() { return (
-
- -

我的收藏

-

共 {list.length} 篇收藏帖子

+
+
+ +

我的收藏

+

共 {list.length} 篇收藏帖子

+
{list.length === 0 ? ( -
+

还没有收藏任何帖子

diff --git a/frontend/src/pages/LinksPage.tsx b/frontend/src/pages/LinksPage.tsx index c63974c..6fb093a 100644 --- a/frontend/src/pages/LinksPage.tsx +++ b/frontend/src/pages/LinksPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -16,6 +16,12 @@ import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } fr import { InFlowSiteFooter } from '../components/SiteFooter'; import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog'; +const APPLY_STATUS_ORDER: Record = { + pending: 0, + rejected: 1, + approved: 2, +}; + function applyStatusBadge(status: FriendLinkApply['status']) { switch (status) { case 'pending': @@ -57,6 +63,36 @@ export default function LinksPage() { (l: FriendLink) => l.name?.trim() && l.url?.trim(), ); + const sortedApplies = useMemo( + () => [...myApplies].sort((a, b) => { + const byStatus = APPLY_STATUS_ORDER[a.status] - APPLY_STATUS_ORDER[b.status]; + if (byStatus !== 0) return byStatus; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }), + [myApplies], + ); + + const applyCounts = useMemo(() => { + let pending = 0; + let rejected = 0; + let approved = 0; + for (const a of myApplies) { + if (a.status === 'pending') pending += 1; + else if (a.status === 'rejected') rejected += 1; + else if (a.status === 'approved') approved += 1; + } + return { pending, rejected, approved }; + }, [myApplies]); + + const applySummaryText = useMemo(() => { + if (myApplies.length === 0) return ''; + const parts: string[] = []; + if (applyCounts.pending > 0) parts.push(`${applyCounts.pending} 待审`); + if (applyCounts.rejected > 0) parts.push(`${applyCounts.rejected} 已拒绝`); + if (applyCounts.approved > 0) parts.push(`${applyCounts.approved} 已通过`); + return `我的申请 · ${parts.join(' / ')}`; + }, [myApplies.length, applyCounts]); + const loadMyApplies = useCallback(() => { if (!user) { setMyApplies([]); @@ -129,166 +165,189 @@ export default function LinksPage() { setCancelingId(null); } }; + + const friendLinksBlock = friendLinks.length === 0 ? ( +
+ +

暂无友情链接

+

+ 注册登录后可提交申请,审核通过后将展示在此页 +

+
+ ) : ( +
+
+ {friendLinks.map(link => { + const logoURL = resolveFriendLinkLogo(link.logo, branding.site_url); + return ( + +
+ {logoURL ? ( + + ) : ( + {linkInitial(link.name)} + )} +
+ {link.name} +
+ ); + })} +
+
+ ); + + const myAppliesBlock = user ? ( +
+

我的申请

+ {myLoading ? ( +
+ ) : myApplies.length === 0 ? ( +

你还没有提交过友链申请,点击右上角「申请友链」即可提交

+ ) : ( +
+ {sortedApplies.map(apply => ( +
+ {apply.logo?.trim() && ( +
+ +
+ )} +
+
+ {apply.name} + {applyStatusBadge(apply.status)} +
+ + {apply.url} + + {apply.status === 'rejected' && apply.review_note?.trim() && ( +

拒绝原因:{apply.review_note}

+ )} + {apply.status === 'pending' && ( +

+ 回链检测:{reciprocalStatusLabel(apply).text} +

+ )} + {apply.status === 'approved' && ( +

修改后将重新进入审核,友链会暂时从列表移除

+ )} +

{formatTime(apply.created_at)}

+
+ {apply.status === 'pending' && ( +
+ + +
+ )} + {apply.status === 'rejected' && ( + + )} + {apply.status === 'approved' && ( + + )} +
+ ))} +
+ )} +
+ ) : null; + return (
-
- - -
-
-

友情链接

-

- 与本站互链的站点列表 - {friendLinks.length > 0 ? ` · 共 ${friendLinks.length} 个` : ''} -

-
- -
- {friendLinks.length === 0 ? ( -
- -

暂无友情链接

-

- 注册登录后可提交申请,审核通过后将展示在此页 -

-
- ) : ( -
-
- {friendLinks.map(link => { - const logoURL = resolveFriendLinkLogo(link.logo, branding.site_url); - return ( - -
- {logoURL ? ( - - ) : ( - {linkInitial(link.name)} - )} -
- {link.name} -
- ); - })} +
+
+

友情链接

+

+ 与本站互链的站点列表 + {friendLinks.length > 0 ? ` · 共 ${friendLinks.length} 个` : ''} +

+ {user && myApplies.length > 0 && applySummaryText ? ( +

0 ? ' links-page-summary--pending' : applyCounts.rejected > 0 ? ' links-page-summary--rejected' : ''}`} + > + {applySummaryText} +

+ ) : null}
+
- )} + - {user && ( -
-

我的申请

- {myLoading ? ( -
- ) : myApplies.length === 0 ? ( -

你还没有提交过友链申请

- ) : ( -
- {myApplies.map(apply => ( -
- {apply.logo?.trim() && ( -
- -
- )} -
-
- {apply.name} - {applyStatusBadge(apply.status)} -
- - {apply.url} - - {apply.status === 'rejected' && apply.review_note?.trim() && ( -

拒绝原因:{apply.review_note}

- )} - {apply.status === 'pending' && ( -

- 回链检测:{reciprocalStatusLabel(apply).text} -

- )} - {apply.status === 'approved' && ( -

修改后将重新进入审核,友链会暂时从列表移除

- )} -

{formatTime(apply.created_at)}

-
- {apply.status === 'pending' && ( -
- - -
- )} - {apply.status === 'rejected' && ( - - )} - {apply.status === 'approved' && ( - - )} -
- ))} -
- )} -
+ {/* 登录:申请区在上;游客:友链在上 */} + {user ? ( + <> + {myAppliesBlock} + {friendLinksBlock} + + ) : ( + <> + {friendLinksBlock} +

+ 登录 + 或 + 注册 + 后可申请友链并管理自己的申请 +

+ )} - - {!user && ( -

- 登录 - 或 - 注册 - 后可申请友链并管理自己的申请 -

- )} - -
+ + -
- -

开源码桶

-

- 论坛会员在 Gitea 上的公开仓库 - {total > 0 ? ` · 共 ${total} 个` : ''} -

+
+
+ +

开源码桶

+

+ 论坛会员在 Gitea 上的公开仓库 + {total > 0 ? ` · 共 ${total} 个` : ''} +

+
{loading ? (
) : list.length === 0 ? ( -
+

暂无同步到的公开项目

diff --git a/frontend/src/pages/admin/AdminPagesPage.tsx b/frontend/src/pages/admin/AdminPagesPage.tsx index d922007..d7b31bc 100644 --- a/frontend/src/pages/admin/AdminPagesPage.tsx +++ b/frontend/src/pages/admin/AdminPagesPage.tsx @@ -1,14 +1,21 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { FileText, Plus, Pencil, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Spinner } from '@/components/ui/spinner'; import { - Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import ArticleEditor from '../../components/ArticleEditor'; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { notify } from '@/lib/notify'; import { api } from '../../api/client'; import { useAdminGuard } from '../../layouts/AdminLayout'; @@ -16,37 +23,17 @@ import type { SitePage } from '../../api/types'; import { invalidateSitePagesCache } from '../../hooks/useSitePages'; import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList'; import { persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder'; +import { formatTime } from '../../utils/content'; import { cn } from '@/lib/utils'; -const EMPTY: Partial = { - title: '', - slug: '', - content: '', - published: false, - sort_order: 0, - show_in_footer: true, - show_in_nav: false, -}; - -function slugify(title: string): string { - const s = title.trim().toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - return s.slice(0, 64); -} - -/** 后台:自定义单页管理 */ +/** 后台:自定义单页列表 */ export default function AdminPagesPage() { + const nav = useNavigate(); const { ready } = useAdminGuard(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - const [form, setForm] = useState>({ ...EMPTY }); - const [editingId, setEditingId] = useState(null); - const [saving, setSaving] = useState(false); const [reordering, setReordering] = useState(false); + const [togglingId, setTogglingId] = useState(null); const load = () => { setLoading(true); @@ -58,40 +45,7 @@ export default function AdminPagesPage() { useEffect(() => { if (ready) load(); }, [ready]); - const openCreate = () => { - setEditingId(null); - setForm({ ...EMPTY }); - setDialogOpen(true); - }; - - const openEdit = (p: SitePage) => { - setEditingId(p.id); - setForm({ ...p }); - setDialogOpen(true); - }; - - const save = async () => { - setSaving(true); - try { - if (editingId) { - await api.adminUpdatePage(editingId, form); - notify.success('单页已更新'); - } else { - await api.adminCreatePage(form); - notify.success('单页已创建'); - } - invalidateSitePagesCache(); - setDialogOpen(false); - load(); - } catch (e: unknown) { - notify.error(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); - } - }; - const remove = async (id: number) => { - if (!window.confirm('确定删除该单页?')) return; try { await api.adminDeletePage(id); invalidateSitePagesCache(); @@ -102,12 +56,36 @@ export default function AdminPagesPage() { } }; + const togglePublished = async (page: SitePage, next: boolean) => { + const prev = page.published; + setTogglingId(page.id); + setRows(list => list.map(r => (r.id === page.id ? { ...r, published: next } : r))); + try { + await api.adminSetPagePublished(page.id, next); + invalidateSitePagesCache(); + notify.success(next ? '已发布' : '已取消发布'); + } catch (e: unknown) { + setRows(list => list.map(r => (r.id === page.id ? { ...r, published: prev } : r))); + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setTogglingId(null); + } + }; + const handlePageReorder = async (reordered: SitePage[]) => { const before = [...rows]; setReordering(true); try { const after = await persistSortOrderChanges(before, reordered, page => - api.adminUpdatePage(page.id, { sort_order: page.sort_order }), + api.adminUpdatePage(page.id, { + title: page.title, + slug: page.slug, + content: page.content, + published: page.published, + sort_order: page.sort_order, + show_in_footer: page.show_in_footer, + show_in_nav: page.show_in_nav, + }), ); setRows(after); invalidateSitePagesCache(); @@ -121,17 +99,18 @@ export default function AdminPagesPage() { }; const showMoveButtons = shouldShowSortableMoveButtons(rows.length); + const busy = reordering || togglingId != null; if (!ready) return null; return (

-
+

单页管理

创建「关于我们」「版规」等独立页面

- +
{loading ? : ( @@ -145,12 +124,13 @@ export default function AdminPagesPage() { 权重 发布 展示 + 更新时间 操作 {rows.length === 0 ? ( - 暂无单页 + 暂无单页 ) : ( }
- {p.title} + + + /page/{p.slug} {p.sort_order} - {p.published ? '是' : '否'} - {[p.show_in_footer && '页脚', p.show_in_nav && '导航'].filter(Boolean).join('、') || '—'} + + + + + + {p.show_in_footer && 页脚} + {p.show_in_nav && 导航} + {!p.show_in_footer && !p.show_in_nav && '—'} + + + {p.updated_at ? formatTime(p.updated_at) : '—'} - - + + + + + + + + 删除单页「{p.title}」? + + 删除后不可恢复,前台链接 /page/{p.slug} 将失效。 + + + + 取消 + remove(p.id)}>删除 + + + )} @@ -187,65 +229,6 @@ export default function AdminPagesPage() {
)} - - - - - {editingId ? '编辑单页' : '新建单页'} - -
-
- - { - const title = e.target.value; - setForm(f => ({ - ...f, - title, - slug: f.slug || slugify(title), - })); - }} - /> -
-
- - setForm(f => ({ ...f, slug: e.target.value }))} - placeholder="about-us" - /> -
-
- - setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))} - /> -
-
- - - -
-
- - setForm(f => ({ ...f, content: html }))} - /> -
-
- - - - -
-
); } diff --git a/frontend/src/pages/admin/AdminSitePageEditPage.tsx b/frontend/src/pages/admin/AdminSitePageEditPage.tsx new file mode 100644 index 0000000..0eea226 --- /dev/null +++ b/frontend/src/pages/admin/AdminSitePageEditPage.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ArrowLeft, ExternalLink, Save } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Spinner } from '@/components/ui/spinner'; +import ArticleEditor from '../../components/ArticleEditor'; +import UnsavedChangesDialog from '../../components/UnsavedChangesDialog'; +import { notify } from '@/lib/notify'; +import { api } from '../../api/client'; +import { useAdminGuard } from '../../layouts/AdminLayout'; +import { useUnsavedChangesGuard } from '../../hooks/useUnsavedChangesGuard'; +import { useNoIndexSEO } from '../../hooks/usePageSEO'; +import { useForumLimits } from '../../hooks/useForumLimits'; +import { invalidateSitePagesCache } from '../../hooks/useSitePages'; +import { isHtmlEmpty } from '../../utils/postContent'; +import { pagePath } from '../../utils/permalink'; +import { cn } from '@/lib/utils'; + +const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/; + +export type SitePageForm = { + title: string; + slug: string; + content: string; + published: boolean; + sort_order: number; + show_in_footer: boolean; + show_in_nav: boolean; +}; + +const EMPTY_FORM: SitePageForm = { + title: '', + slug: '', + content: '', + published: true, + sort_order: 0, + show_in_footer: true, + show_in_nav: false, +}; + +function slugifyAscii(title: string): string { + const s = title.trim().toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return s.slice(0, 64); +} + +function isValidSlug(slug: string): boolean { + const s = slug.trim(); + if (!s || s.length > 64) return false; + return SLUG_RE.test(s); +} + +function formSnapshot(f: SitePageForm): string { + return JSON.stringify(f); +} + +/** 后台:单页全屏编辑 */ +export default function AdminSitePageEditPage() { + const nav = useNavigate(); + const { id: idParam } = useParams(); + const { ready } = useAdminGuard(); + const { limits } = useForumLimits(); + const pageId = idParam ? Number(idParam) : NaN; + const isNew = !idParam || Number.isNaN(pageId) || pageId <= 0; + + useNoIndexSEO(isNew ? '新建单页' : '编辑单页'); + + const [loading, setLoading] = useState(!isNew); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [baseline, setBaseline] = useState(formSnapshot(EMPTY_FORM)); + const [slugTouched, setSlugTouched] = useState(false); + + const isDirty = formSnapshot(form) !== baseline; + const { dialogOpen, stayOnPage, discardAndLeave, requestLeave, markSaved } = useUnsavedChangesGuard({ isDirty }); + + const slugError = useMemo(() => { + const slug = form.slug.trim(); + if (!slug) return '请填写 URL 路径(slug)'; + if (!isValidSlug(slug)) return '2–64 位小写字母、数字或连字符,且不能以连字符开头/结尾'; + return ''; + }, [form.slug]); + + const canPreview = isValidSlug(form.slug); + + useEffect(() => { + if (!ready || isNew) return; + setLoading(true); + api.adminPage(pageId) + .then(d => { + const p = d.page; + const next: SitePageForm = { + title: p.title ?? '', + slug: p.slug ?? '', + content: p.content ?? '', + published: !!p.published, + sort_order: p.sort_order ?? 0, + show_in_footer: p.show_in_footer ?? true, + show_in_nav: !!p.show_in_nav, + }; + setForm(next); + setBaseline(formSnapshot(next)); + setSlugTouched(true); + }) + .catch(e => { + notify.error(e instanceof Error ? e.message : '加载失败'); + nav('/admin/pages', { replace: true }); + }) + .finally(() => setLoading(false)); + }, [ready, isNew, pageId, nav]); + + const goBack = useCallback(() => { + requestLeave(() => nav('/admin/pages')); + }, [requestLeave, nav]); + + const preview = () => { + if (!canPreview) { + notify.warning('请先填写有效的 slug'); + return; + } + window.open(pagePath(form.slug.trim(), limits), '_blank', 'noopener,noreferrer'); + }; + + const save = async () => { + const title = form.title.trim(); + if (!title) { + notify.warning('标题不能为空'); + return; + } + if (slugError) { + notify.warning(slugError); + return; + } + if (isHtmlEmpty(form.content)) { + notify.warning('正文不能为空'); + return; + } + + const payload = { + title, + slug: form.slug.trim(), + content: form.content, + published: form.published, + sort_order: form.sort_order, + show_in_footer: form.show_in_footer, + show_in_nav: form.show_in_nav, + }; + + setSaving(true); + try { + if (isNew) { + await api.adminCreatePage(payload); + notify.success('单页已创建'); + } else { + await api.adminUpdatePage(pageId, payload); + notify.success('单页已更新'); + } + invalidateSitePagesCache(); + setBaseline(formSnapshot({ ...form, ...payload, title, slug: payload.slug })); + markSaved(); + nav('/admin/pages'); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + if (!ready) return null; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +

{isNew ? '新建单页' : '编辑单页'}

+
+
+ +
+
+ +
+
+
+ + { + const title = e.target.value; + setForm(f => { + const next = { ...f, title }; + if (!slugTouched && !f.slug.trim()) { + const auto = slugifyAscii(title); + if (auto) next.slug = auto; + } + return next; + }); + }} + placeholder="关于我们" + /> +
+
+ + { + setSlugTouched(true); + setForm(f => ({ ...f, slug: e.target.value.trim().toLowerCase() })); + }} + placeholder="about-us" + spellCheck={false} + aria-invalid={!!slugError && !!form.slug.trim()} + /> +

+ {slugError && form.slug.trim() ? slugError : '访问路径:/page/your-slug · 2–64 位小写/数字/连字符'} +

+
+
+ + setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))} + /> +
+
+
+ + + +
+
+ +
+
+ setForm(f => ({ ...f, content: html }))} + placeholder="撰写单页正文…" + enableContentGates={false} + /> +
+
+ +
+ + +
+ + +
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index d1d410d..93f5039 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2342,6 +2342,65 @@ body:has(.admin-topbar) .ptr-indicator { .page-inner { padding: 20px 24px; max-width: 720px; } .page-inner-wide { padding: 20px 24px; } .page-inner-wide--profile { max-width: 820px; } + +/* 列表类独立页:与首页 feed-panel 同卡同留白 */ +.list-page-panel { + overflow: auto; +} + +.list-page-panel__head { + flex-shrink: 0; + padding: 12px 16px 10px; + border-bottom: 1px solid var(--j13-border-light); + background: var(--j13-bg-block); +} + +.list-page-panel__back { + margin: 0 0 6px -6px; +} + +.list-page-panel__head .page-title { + margin: 0 0 4px; +} + +.list-page-panel__head .page-desc { + margin: 0; +} + +.list-page-panel__head .links-page-head { + margin-bottom: 0; +} + +.list-page-panel__head .links-page-head .page-desc { + margin: 0; +} + +.list-page-panel__empty { + margin: 32px 16px; +} + +.list-page-panel .links-page-board { + margin: 16px; + border-radius: 10px; +} + +.list-page-panel .links-page-my { + padding: 16px 16px 8px; + margin-top: 0; + border-top: none; +} + +.list-page-panel .links-page-my--compact { + padding-bottom: 4px; +} + +.list-page-panel .links-page-login-hint { + padding: 0 16px 20px; +} + +.list-page-panel .projects-pager { + padding: 12px 16px 16px; +} .page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .page-desc { font-size: 13px; color: var(--color-text-3); margin: 0 0 20px; } @@ -2932,8 +2991,8 @@ body:has(.admin-topbar) .ptr-indicator { } .post-row--v2 .post-title { - font-size: 14px; - font-weight: 400; + font-size: 15px; + font-weight: 500; line-height: 1.35; } @@ -5227,6 +5286,16 @@ a.post-title:visited { color: #a16207; } +.article-editor-tools .article-tool-btn--points { + color: #b45309; +} + +.article-editor-tools .article-tool-btn--points:hover, +.article-editor-tools .article-tool-btn--points.active { + background: rgba(180, 83, 9, 0.1); + color: #92400e; +} + .dark .article-editor-tools .article-tool-btn--reply { color: #e8b84a; } @@ -7113,8 +7182,8 @@ a.waline-comment-author:hover { .friend-link-site-info { padding: 14px 16px; border-radius: 10px; - background: color-mix(in srgb, var(--j13-green) 6%, #eef6ff 94%); - border: 1px solid color-mix(in srgb, var(--j13-green) 18%, #dbeafe 82%); + background: var(--j13-bg-block-muted, #f8fafc); + border: 1px solid var(--j13-border-light, #e5e7eb); } .friend-link-site-info__title { margin: 0 0 10px; @@ -7145,13 +7214,7 @@ a.waline-comment-author:hover { min-width: 0; word-break: break-all; color: var(--foreground, #334155); -} -.friend-link-site-info__item dd a { - color: var(--j13-green); - text-decoration: none; -} -.friend-link-site-info__item dd a:hover { - text-decoration: underline; + user-select: text; } .friend-link-apply-dialog { @@ -7196,6 +7259,15 @@ a.waline-comment-author:hover { border-radius: 8px; font-size: 13px; } +/* 聚焦:仅细边框,去掉绿色 ring,降低干扰 */ +.friend-link-apply-dialog .friend-link-apply-field input:focus, +.friend-link-apply-dialog .friend-link-apply-field input:focus-visible { + outline: none; + box-shadow: none; + --tw-ring-shadow: 0 0 #0000; + --tw-ring-offset-shadow: 0 0 #0000; + border-color: color-mix(in srgb, var(--j13-green) 55%, var(--j13-border, #cbd5e1)); +} .friend-link-apply-field__hint { margin: 0; font-size: 12px; @@ -7214,18 +7286,67 @@ a.waline-comment-author:hover { border-radius: 8px; background: var(--j13-bg-surface, #fff); font-size: 13px; + font-weight: 400; + color: var(--foreground, #334155); text-align: left; cursor: pointer; transition: border-color 0.15s, background 0.15s; } .friend-link-apply-placement__option:hover { - border-color: color-mix(in srgb, var(--j13-green) 30%, var(--j13-border-light, #e5e7eb)); + border-color: var(--j13-border, #cbd5e1); + background: var(--j13-bg-block-muted, #f8fafc); } .friend-link-apply-placement__option--active { - border-color: var(--j13-green, #2d6a4f); - background: var(--j13-bg-block-accent, #f0faf5); - color: var(--j13-green, #2d6a4f); - font-weight: 600; + border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border, #cbd5e1)); + background: var(--j13-bg-block-muted, #f8fafc); + color: var(--foreground, #1f2937); + font-weight: 400; +} +.friend-link-apply-placement__custom { + border: 1px solid var(--j13-border-light, #e5e7eb); + border-radius: 8px; + background: var(--j13-bg-surface, #fff); + overflow: hidden; + transition: border-color 0.15s, background 0.15s; +} +.friend-link-apply-placement__custom .friend-link-apply-placement__option { + border: none; + border-radius: 0; + background: transparent; +} +.friend-link-apply-placement__custom .friend-link-apply-placement__option:hover, +.friend-link-apply-placement__custom .friend-link-apply-placement__option--active { + background: transparent; +} +.friend-link-apply-placement__custom--open { + border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border, #cbd5e1)); + background: var(--j13-bg-block-muted, #f8fafc); +} +.friend-link-apply-placement__custom-panel { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; +} +.friend-link-apply-placement__custom--open .friend-link-apply-placement__custom-panel { + grid-template-rows: 1fr; +} +.friend-link-apply-placement__custom-inner { + overflow: hidden; + min-height: 0; + display: grid; + gap: 6px; + padding: 0 12px; + opacity: 0; + transition: opacity 0.18s ease, padding 0.22s ease; +} +.friend-link-apply-placement__custom--open .friend-link-apply-placement__custom-inner { + padding: 0 12px 12px; + opacity: 1; +} +.friend-link-apply-placement__custom-inner label { + font-size: 12px; + font-weight: 500; + color: var(--muted-fg, #64748b); } .friend-link-apply-logo-row { @@ -10431,10 +10552,13 @@ button.profile-stat:hover strong { .article-editor--fullscreen .article-editor-bar { position: relative; top: 0; - margin: 0 auto; + width: 100%; + max-width: none; + margin: 0; padding: 6px 8px; z-index: auto; box-shadow: none; + overflow: visible; } .article-editor--fullscreen .article-editor-status { @@ -10443,11 +10567,11 @@ button.profile-stat:hover strong { border-radius: 0; } -/* 全屏富文本:工具栏与编辑区同宽居中 */ +/* 全屏富文本:工具栏铺满,避免窄宽把「积分可见」等按钮挤没 */ .article-editor--fullscreen.article-editor--rich .article-editor-bar { width: 100%; - max-width: var(--j13-article-read-w); - margin: 0 auto; + max-width: none; + margin: 0; } .article-editor--fullscreen .article-editor-body { @@ -13279,7 +13403,8 @@ a.pm-thread-head__name:hover { } } .article-tool-btn--points.is-active, -.article-tool-btn--points[aria-pressed='true'] { +.article-tool-btn--points[aria-pressed='true'], +.article-tool-btn--points.active { color: #a16207; } .profile-badge-wall { @@ -13291,8 +13416,48 @@ a.pm-thread-head__name:hover { font-weight: 600; } -/* 自定义单页 / 友情链接 / 特殊帖 */ -.site-page__head h1 { margin: 0 0 1rem; font-size: 1.5rem; } +/* 自定义单页:与首页帖子列表相同的 12px 留白,卡片铺满主栏 */ +.page-wrap:has(.site-page) { + padding: 12px; + background: var(--j13-bg-surface); +} + +.site-page { + width: 100%; + margin: 0; + padding: 20px 16px 28px; + box-sizing: border-box; + background: var(--j13-bg-block); + border: 1px solid var(--j13-border-light); + border-radius: 12px; + box-shadow: var(--j13-shadow-card); +} + +.site-page__head h1 { + margin: 0 0 1rem; + font-size: 1.5rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +.site-page__body { + min-width: 0; +} + +@media (max-width: 768px) { + .page-wrap:has(.site-page) { + padding: 0; + } + + .site-page { + padding: 16px 14px 24px; + border: none; + border-radius: 0; + box-shadow: none; + } +} + +/* 友情链接 / 特殊帖 */ .links-page__head h1 { margin: 0 0 0.5rem; } .links-page__hint { color: var(--muted-fg, #666); margin: 0 0 1rem; } .links-page__list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; } @@ -14170,6 +14335,247 @@ button.post-poll__option, .admin-page-dialog { max-width: min(920px, 96vw); } +/* ========== 单页编辑(全屏) ========== */ +.admin-main:has(.admin-site-page-edit) { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 16px 20px 20px; + background: var(--j13-bg-workspace, hsl(var(--background))); +} + +.admin-site-page-edit { + flex: 1; + min-height: 0; + width: 100%; + max-width: 920px; + margin: 0 auto; + display: flex; + flex-direction: column; + overflow: hidden; + background: hsl(var(--card)); + border: 1px solid var(--j13-border); + border-radius: 12px; + box-shadow: var(--j13-shadow-card); +} + +.admin-site-page-edit--loading { + align-items: center; + justify-content: center; + min-height: 240px; +} + +.admin-site-page-edit__header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--j13-border-light); + background: var(--j13-bg-surface); +} + +.admin-site-page-edit__header-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.admin-site-page-edit__title { + margin: 0; + font-size: 16px; + font-weight: 600; + white-space: nowrap; +} + +.admin-site-page-edit__header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.admin-site-page-edit__meta { + flex-shrink: 0; + padding: 12px 16px; + border-bottom: 1px solid var(--j13-border-light); + background: var(--j13-bg-block-muted); +} + +.admin-site-page-edit__meta-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) 120px; + gap: 12px 16px; +} + +.admin-site-page-edit__field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.admin-site-page-edit__hint { + margin: 0; + font-size: 11px; + color: hsl(var(--muted-foreground)); + line-height: 1.4; +} + +.admin-site-page-edit__hint--error { + color: hsl(var(--destructive)); +} + +.admin-site-page-edit__switches { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; + margin-top: 12px; +} + +.admin-site-page-edit__switch { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: hsl(var(--foreground)); + cursor: pointer; +} + +.admin-site-page-edit__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 12px 16px; + background: var(--j13-bg-workspace, hsl(var(--background))); +} + +.admin-site-page-edit__shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--j13-bg-surface); + border: 1px solid var(--j13-border-light); + border-radius: 10px; + box-shadow: var(--j13-shadow-soft); +} + +.admin-site-page-edit__shell .article-editor { + flex: 1; + min-height: 0; +} + +.admin-site-page-edit__footer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--j13-border-light); + background: var(--j13-bg-surface); +} + +.admin-table-link { + border: none; + background: transparent; + padding: 0; + font: inherit; + color: var(--j13-green); + cursor: pointer; + text-align: left; +} + +.admin-table-link:hover { + text-decoration: underline; +} + +.admin-table-link:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-table-tags { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.admin-table-muted { + font-size: 12px; + color: hsl(var(--muted-foreground)); + white-space: nowrap; +} + +.admin-page-publish-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.admin-page-publish-toggle__label { + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.admin-page-publish-toggle__label.is-on { + color: var(--j13-green); +} + +.admin-page-publish-toggle__label.is-off { + color: hsl(var(--muted-foreground)); +} + +@media (max-width: 900px) { + .admin-site-page-edit__meta-grid { + grid-template-columns: 1fr; + } + + .admin-site-page-edit__field--narrow { + max-width: 160px; + } +} + +@media (max-width: 768px) { + .admin-main:has(.admin-site-page-edit) { + padding: 12px; + } + + .admin-site-page-edit__header { + flex-wrap: wrap; + padding: 10px 12px; + } + + .admin-site-page-edit__title { + white-space: normal; + } + + .admin-site-page-edit__meta, + .admin-site-page-edit__body { + padding-left: 12px; + padding-right: 12px; + } + + .admin-site-page-edit__footer { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 12px; + } + + .admin-site-page-edit__footer > * { + width: 100%; + } +} + /* 友情链接独立页 */ .links-page-head { display: flex; @@ -14245,23 +14651,41 @@ button.post-poll__option, .links-page-empty { margin: 24px 0; } +.links-page-summary { + display: block; + margin: 8px 0 0; + padding: 0; + font-size: 13px; + font-weight: 500; + color: var(--j13-green); + line-height: 1.4; +} +.links-page-summary--pending { + color: #c27803; +} +.links-page-summary--rejected { + color: var(--muted-fg, #64748b); +} .links-page-my { - margin-top: 36px; - padding-top: 24px; - border-top: 1px solid var(--border, #e5e7eb); + margin-top: 0; + padding-top: 0; + border-top: none; } .links-page-my__title { - margin: 0 0 16px; - font-size: 18px; + margin: 0 0 12px; + font-size: 16px; + font-weight: 600; } .links-page-my__empty { - margin: 0; + margin: 0 0 8px; color: var(--muted-fg, #64748b); - font-size: 14px; + font-size: 13px; + line-height: 1.5; } .links-page-my-list { display: grid; gap: 12px; + margin-bottom: 8px; } .links-page-my-row { display: flex; diff --git a/frontend/src/utils/markdownFormat.ts b/frontend/src/utils/markdownFormat.ts index 29a7a0c..103e8fd 100644 --- a/frontend/src/utils/markdownFormat.ts +++ b/frontend/src/utils/markdownFormat.ts @@ -101,6 +101,21 @@ export function insertMarkdownReplyOnly( applyTextareaChange(textarea, next, cursor, cursor, onChange); } +/** 插入积分可见区块模板 */ +export function insertMarkdownPointsOnly( + textarea: HTMLTextAreaElement, + value: string, + onChange: ChangeHandler, + cost = 10, +) { + const { selectionStart, selectionEnd } = textarea; + const open = `\n\n\n\n`; + const snippet = `${open}\n\n\n`; + const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd); + const cursor = selectionStart + open.length; + applyTextareaChange(textarea, next, cursor, cursor, onChange); +} + /** 在光标处插入链接 Markdown */ export function insertMarkdownLink( textarea: HTMLTextAreaElement, diff --git a/handler/special.go b/handler/special.go index db13520..e21d56a 100644 --- a/handler/special.go +++ b/handler/special.go @@ -34,6 +34,21 @@ func (h *Handlers) APIPageDetail(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"page": page}) } +// APIAdminGetPage 管理端单页详情 +func (h *Handlers) APIAdminGetPage(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"}) + return + } + page, err := h.SitePage.GetByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "单页不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"page": page}) +} + // APIAdminPages 管理端单页列表 func (h *Handlers) APIAdminPages(c *gin.Context) { pages, err := h.SitePage.ListAll() @@ -87,6 +102,31 @@ func (h *Handlers) APIAdminDeletePage(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "单页已删除"}) } +// APIAdminSetPagePublished 切换单页发布状态 +func (h *Handlers) APIAdminSetPagePublished(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"}) + return + } + var body struct { + Published bool `json:"published"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"}) + return + } + if err := h.SitePage.SetPublished(uint(id), body.Published); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + msg := "已取消发布" + if body.Published { + msg = "已发布" + } + c.JSON(http.StatusOK, gin.H{"message": msg, "published": body.Published}) +} + // APIPollVote 投票 func (h *Handlers) APIPollVote(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/router/router.go b/router/router.go index 9cb2e4e..3270bc1 100644 --- a/router/router.go +++ b/router/router.go @@ -224,8 +224,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard) adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard) adminAPI.GET("/pages", h.APIAdminPages) + adminAPI.GET("/pages/:id", h.APIAdminGetPage) adminAPI.POST("/pages", h.APIAdminCreatePage) adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage) + adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished) adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage) adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies) adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings) diff --git a/service/content.go b/service/content.go index 4c8eb6d..7704f9d 100644 --- a/service/content.go +++ b/service/content.go @@ -10,11 +10,23 @@ import ( var ( membersOnlyBlockRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) replyOnlyBlockRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) + pointsOnlyUnwrapRe = regexp.MustCompile(`(?is)]*>([\s\S]*?)`) // style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声 styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?`) htmlTagRe = regexp.MustCompile(`<[^>]+>`) ) +// UnwrapContentGateTags 剥离登录/回复/积分可见外壳,保留内部正文(单页等场景禁用门控) +func UnwrapContentGateTags(html string) string { + if html == "" { + return html + } + html = membersOnlyBlockRe.ReplaceAllString(html, "$1") + html = replyOnlyBlockRe.ReplaceAllString(html, "$1") + html = pointsOnlyUnwrapRe.ReplaceAllString(html, "$1") + return html +} + // RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示 func RedactMembersOnlyHTML(html string) string { return redactGatedBlocks(html, membersOnlyBlockRe, "members-only") diff --git a/service/content_test.go b/service/content_test.go index b9b4c6a..9b40231 100644 --- a/service/content_test.go +++ b/service/content_test.go @@ -26,3 +26,21 @@ func TestRedactGatedPostHTML(t *testing.T) { t.Fatalf("门控正文应被遮盖,得到: %q", out) } } + +func TestUnwrapContentGateTags(t *testing.T) { + in := `

公开

` + + `

登录密

` + + `

回复密

` + + `

积分密

` + out := UnwrapContentGateTags(in) + for _, tag := range []string{"members-only", "reply-only", "points-only"} { + if strings.Contains(out, tag) { + t.Fatalf("应剥离 %s 外壳,得到: %q", tag, out) + } + } + for _, want := range []string{"公开", "登录密", "回复密", "积分密"} { + if !strings.Contains(out, want) { + t.Fatalf("应保留内部正文 %q,得到: %q", want, out) + } + } +} diff --git a/service/site_page.go b/service/site_page.go index 054d82b..b5777a5 100644 --- a/service/site_page.go +++ b/service/site_page.go @@ -71,7 +71,7 @@ func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model. if err := q.First(&page).Error; err != nil { return nil, ErrSitePageNotFound } - page.Content = SanitizePostHTML(page.Content) + page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content)) return &page, nil } @@ -80,6 +80,7 @@ func (s *SitePageService) GetByID(id uint) (*model.SitePage, error) { if err := model.DB.First(&page, id).Error; err != nil { return nil, ErrSitePageNotFound } + page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content)) return &page, nil } @@ -145,6 +146,15 @@ func (s *SitePageService) Delete(id uint) error { return nil } +// SetPublished 仅切换发布状态(列表快捷操作) +func (s *SitePageService) SetPublished(id uint, published bool) error { + page, err := s.GetByID(id) + if err != nil { + return err + } + return model.DB.Model(page).Update("published", published).Error +} + func (s *SitePageService) ListSitemap(limit int) ([]model.SitePage, error) { if limit <= 0 { limit = 500 @@ -161,7 +171,8 @@ func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, err if !ok { return nil, errors.New("slug 格式无效(2-64 位小写字母、数字、连字符)") } - content := s.filter.Filter(SanitizePostHTML(in.Content)) + // 单页不支持登录/回复/积分可见:保存前剥离外壳,保留内部正文 + content := s.filter.Filter(SanitizePostHTML(UnwrapContentGateTags(in.Content))) if title == "" { return nil, errors.New("标题不能为空") }