feat: 优化单页编辑、列表留白与友链申请体验
单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,7 @@ const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'
|
|||||||
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
||||||
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||||
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
|
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
|
||||||
|
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
|
||||||
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
|
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
|
||||||
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
|
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
|
||||||
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
||||||
@@ -56,6 +57,8 @@ const router = createBrowserRouter(
|
|||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
|
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
|
||||||
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
|
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
|
||||||
|
<Route path="pages/new" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
|
||||||
|
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
|
||||||
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
|
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
|
||||||
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
|
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
|
||||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||||
|
|||||||
@@ -397,10 +397,16 @@ export const api = {
|
|||||||
postLotteryDraw: (id: number) =>
|
postLotteryDraw: (id: number) =>
|
||||||
request<{ message: string; lottery: PostLotteryView }>(`/api/posts/${id}/lottery/draw`, { method: 'POST', body: '{}' }),
|
request<{ message: string; lottery: PostLotteryView }>(`/api/posts/${id}/lottery/draw`, { method: 'POST', body: '{}' }),
|
||||||
adminPages: () => request<{ pages: SitePage[] }>('/api/admin/pages'),
|
adminPages: () => request<{ pages: SitePage[] }>('/api/admin/pages'),
|
||||||
|
adminPage: (id: number) => request<{ page: SitePage }>(`/api/admin/pages/${id}`),
|
||||||
adminCreatePage: (data: Partial<SitePage>) =>
|
adminCreatePage: (data: Partial<SitePage>) =>
|
||||||
request<{ message: string; page: SitePage }>('/api/admin/pages', { method: 'POST', body: JSON.stringify(data) }),
|
request<{ message: string; page: SitePage }>('/api/admin/pages', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
adminUpdatePage: (id: number, data: Partial<SitePage>) =>
|
adminUpdatePage: (id: number, data: Partial<SitePage>) =>
|
||||||
request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
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) =>
|
adminDeletePage: (id: number) =>
|
||||||
request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'DELETE' }),
|
request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'DELETE' }),
|
||||||
adminFriendLinkApplies: (params?: { page?: number; size?: number; status?: string }) => {
|
adminFriendLinkApplies: (params?: { page?: number; size?: number; status?: string }) => {
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ export interface SitePageSummary {
|
|||||||
export interface SitePage extends SitePageSummary {
|
export interface SitePage extends SitePageSummary {
|
||||||
content: string;
|
content: string;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollOptionView {
|
export interface PollOptionView {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
cycleMarkdownHeading,
|
cycleMarkdownHeading,
|
||||||
insertMarkdownMembersOnly,
|
insertMarkdownMembersOnly,
|
||||||
insertMarkdownReplyOnly,
|
insertMarkdownReplyOnly,
|
||||||
|
insertMarkdownPointsOnly,
|
||||||
insertMarkdownLink,
|
insertMarkdownLink,
|
||||||
} from '../utils/markdownFormat';
|
} from '../utils/markdownFormat';
|
||||||
import { countWords } from '../utils/text';
|
import { countWords } from '../utils/text';
|
||||||
@@ -63,6 +64,11 @@ interface Props {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/**
|
||||||
|
* 是否启用登录/回复/积分可见区块。
|
||||||
|
* 发帖默认 true;自定义单页等场景应关闭。
|
||||||
|
*/
|
||||||
|
enableContentGates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditorMode = 'rich' | 'markdown';
|
type EditorMode = 'rich' | 'markdown';
|
||||||
@@ -211,7 +217,7 @@ function renderToolButtons(tools: ToolBtn[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
{ value, onChange, placeholder = '在此撰写正文…', enableContentGates = true },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const isInternalUpdate = useRef(false);
|
const isInternalUpdate = useRef(false);
|
||||||
@@ -274,9 +280,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
MembersOnly,
|
...(enableContentGates ? [MembersOnly, ReplyOnly, PointsOnly] : []),
|
||||||
ReplyOnly,
|
|
||||||
PointsOnly,
|
|
||||||
TabIndent,
|
TabIndent,
|
||||||
],
|
],
|
||||||
content: sanitizeHtml(value) || '',
|
content: sanitizeHtml(value) || '',
|
||||||
@@ -698,6 +702,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (enableContentGates) {
|
||||||
tools.push(
|
tools.push(
|
||||||
{
|
{
|
||||||
icon: <LockKeyhole size={15} />,
|
icon: <LockKeyhole size={15} />,
|
||||||
@@ -724,11 +729,13 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
action: wrapPointsOnly,
|
action: wrapPointsOnly,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return tools;
|
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[] => [
|
const buildMarkdownTools = useCallback((): ToolBtn[] => {
|
||||||
|
const tools: ToolBtn[] = [
|
||||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||||
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
||||||
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||||
@@ -742,6 +749,9 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
||||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||||
|
];
|
||||||
|
if (enableContentGates) {
|
||||||
|
tools.push(
|
||||||
{
|
{
|
||||||
icon: <LockKeyhole size={15} />,
|
icon: <LockKeyhole size={15} />,
|
||||||
title: '登录可见',
|
title: '登录可见',
|
||||||
@@ -756,7 +766,17 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
className: 'article-tool-btn--reply',
|
className: 'article-tool-btn--reply',
|
||||||
action: withMarkdown(insertMarkdownReplyOnly),
|
action: withMarkdown(insertMarkdownReplyOnly),
|
||||||
},
|
},
|
||||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
{
|
||||||
|
icon: <Coins size={15} />,
|
||||||
|
title: '积分可见',
|
||||||
|
hint: '插入 <points-only> 区块',
|
||||||
|
className: 'article-tool-btn--points',
|
||||||
|
action: withMarkdown(insertMarkdownPointsOnly),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||||
|
|
||||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||||
const words = mode === 'markdown'
|
const words = mode === 'markdown'
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, o
|
|||||||
>
|
>
|
||||||
友链在我的网站首页
|
友链在我的网站首页
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'friend-link-apply-placement__custom',
|
||||||
|
linkPlacement === 'custom' && 'friend-link-apply-placement__custom--open',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
@@ -200,10 +206,8 @@ export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, o
|
|||||||
>
|
>
|
||||||
友链在其它页面
|
友链在其它页面
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div className="friend-link-apply-placement__custom-panel" aria-hidden={linkPlacement !== 'custom'}>
|
||||||
</div>
|
<div className="friend-link-apply-placement__custom-inner">
|
||||||
{linkPlacement === 'custom' && (
|
|
||||||
<div className="friend-link-apply-field">
|
|
||||||
<Label htmlFor="friend-link-reciprocal">添加我方链接的页面地址</Label>
|
<Label htmlFor="friend-link-reciprocal">添加我方链接的页面地址</Label>
|
||||||
<Input
|
<Input
|
||||||
id="friend-link-reciprocal"
|
id="friend-link-reciprocal"
|
||||||
@@ -211,12 +215,16 @@ export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, o
|
|||||||
onChange={e => setReciprocalPageURL(e.target.value)}
|
onChange={e => setReciprocalPageURL(e.target.value)}
|
||||||
placeholder="如:https://您的域名/link.htm"
|
placeholder="如:https://您的域名/link.htm"
|
||||||
maxLength={512}
|
maxLength={512}
|
||||||
|
tabIndex={linkPlacement === 'custom' ? 0 : -1}
|
||||||
/>
|
/>
|
||||||
<p className="friend-link-apply-field__hint">
|
<p className="friend-link-apply-field__hint">
|
||||||
请填写实际放置本站友链的页面,提交后将在后台检测该页面
|
请填写实际放置本站友链的页面,提交后将在后台检测该页面
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="friend-link-apply-field">
|
<div className="friend-link-apply-field">
|
||||||
<Label htmlFor="friend-link-logo">填写或上传网站 LOGO</Label>
|
<Label htmlFor="friend-link-logo">填写或上传网站 LOGO</Label>
|
||||||
<div className="friend-link-apply-logo-row">
|
<div className="friend-link-apply-logo-row">
|
||||||
|
|||||||
@@ -32,23 +32,11 @@ export default function FriendLinkSiteInfo() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="friend-link-site-info__item">
|
<div className="friend-link-site-info__item">
|
||||||
<dt>地址</dt>
|
<dt>地址</dt>
|
||||||
<dd>
|
<dd>{siteURL || '—'}</dd>
|
||||||
{siteURL ? (
|
|
||||||
<a href={siteURL} target="_blank" rel="noopener noreferrer">{siteURL}</a>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="friend-link-site-info__item">
|
<div className="friend-link-site-info__item">
|
||||||
<dt>LOGO</dt>
|
<dt>LOGO</dt>
|
||||||
<dd>
|
<dd>{siteLogoURL || '—'}</dd>
|
||||||
{siteLogoURL ? (
|
|
||||||
<a href={siteLogoURL} target="_blank" rel="noopener noreferrer">{siteLogoURL}</a>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -44,16 +44,18 @@ export default function FavoritesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrap">
|
<div className="page-wrap">
|
||||||
<div className="page-inner-wide">
|
<div className="feed-panel list-page-panel">
|
||||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
<header className="list-page-panel__head">
|
||||||
|
<Button variant="ghost" size="sm" className="list-page-panel__back" onClick={() => nav('/')}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="page-title">我的收藏</h1>
|
<h1 className="page-title">我的收藏</h1>
|
||||||
<p className="page-desc">共 {list.length} 篇收藏帖子</p>
|
<p className="page-desc">共 {list.length} 篇收藏帖子</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
{list.length === 0 ? (
|
{list.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state list-page-panel__empty">
|
||||||
<Star className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
<Star className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
<p>还没有收藏任何帖子</p>
|
<p>还没有收藏任何帖子</p>
|
||||||
<Button onClick={() => nav('/')}>去逛逛</Button>
|
<Button onClick={() => nav('/')}>去逛逛</Button>
|
||||||
|
|||||||
@@ -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 { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -16,6 +16,12 @@ import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } fr
|
|||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
|
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
|
||||||
|
|
||||||
|
const APPLY_STATUS_ORDER: Record<FriendLinkApply['status'], number> = {
|
||||||
|
pending: 0,
|
||||||
|
rejected: 1,
|
||||||
|
approved: 2,
|
||||||
|
};
|
||||||
|
|
||||||
function applyStatusBadge(status: FriendLinkApply['status']) {
|
function applyStatusBadge(status: FriendLinkApply['status']) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
@@ -57,6 +63,36 @@ export default function LinksPage() {
|
|||||||
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
|
(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(() => {
|
const loadMyApplies = useCallback(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setMyApplies([]);
|
setMyApplies([]);
|
||||||
@@ -129,30 +165,9 @@ export default function LinksPage() {
|
|||||||
setCancelingId(null);
|
setCancelingId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
|
||||||
<div className="page-wrap">
|
|
||||||
<div className="page-inner-wide">
|
|
||||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
|
||||||
<ArrowLeft />
|
|
||||||
返回
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="links-page-head">
|
const friendLinksBlock = friendLinks.length === 0 ? (
|
||||||
<div>
|
<div className="empty-state links-page-empty list-page-panel__empty">
|
||||||
<h1 className="page-title">友情链接</h1>
|
|
||||||
<p className="page-desc">
|
|
||||||
与本站互链的站点列表
|
|
||||||
{friendLinks.length > 0 ? ` · 共 ${friendLinks.length} 个` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" onClick={openApply}>
|
|
||||||
<Plus size={16} aria-hidden />
|
|
||||||
申请友链
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{friendLinks.length === 0 ? (
|
|
||||||
<div className="empty-state links-page-empty">
|
|
||||||
<Link2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
<Link2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
<p>暂无友情链接</p>
|
<p>暂无友情链接</p>
|
||||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||||
@@ -186,18 +201,22 @@ export default function LinksPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
|
||||||
{user && (
|
const myAppliesBlock = user ? (
|
||||||
<section className="links-page-my" aria-label="我的友链申请">
|
<section
|
||||||
|
id="my-applies"
|
||||||
|
className={`links-page-my${myApplies.length === 0 && !myLoading ? ' links-page-my--compact' : ''}`}
|
||||||
|
aria-label="我的友链申请"
|
||||||
|
>
|
||||||
<h2 className="links-page-my__title">我的申请</h2>
|
<h2 className="links-page-my__title">我的申请</h2>
|
||||||
{myLoading ? (
|
{myLoading ? (
|
||||||
<div className="flex justify-center py-10"><Spinner /></div>
|
<div className="flex justify-center py-10"><Spinner /></div>
|
||||||
) : myApplies.length === 0 ? (
|
) : myApplies.length === 0 ? (
|
||||||
<p className="links-page-my__empty">你还没有提交过友链申请</p>
|
<p className="links-page-my__empty">你还没有提交过友链申请,点击右上角「申请友链」即可提交</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="links-page-my-list">
|
<div className="links-page-my-list">
|
||||||
{myApplies.map(apply => (
|
{sortedApplies.map(apply => (
|
||||||
<div key={apply.id} className="links-page-my-row">
|
<div key={apply.id} className="links-page-my-row">
|
||||||
{apply.logo?.trim() && (
|
{apply.logo?.trim() && (
|
||||||
<div className="links-page-my-row__logo">
|
<div className="links-page-my-row__logo">
|
||||||
@@ -275,19 +294,59 @@ export default function LinksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
) : null;
|
||||||
|
|
||||||
{!user && (
|
return (
|
||||||
|
<div className="page-wrap">
|
||||||
|
<div className="feed-panel list-page-panel">
|
||||||
|
<header className="list-page-panel__head">
|
||||||
|
<Button variant="ghost" size="sm" className="list-page-panel__back" onClick={() => nav('/')}>
|
||||||
|
<ArrowLeft />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="links-page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">友情链接</h1>
|
||||||
|
<p className="page-desc">
|
||||||
|
与本站互链的站点列表
|
||||||
|
{friendLinks.length > 0 ? ` · 共 ${friendLinks.length} 个` : ''}
|
||||||
|
</p>
|
||||||
|
{user && myApplies.length > 0 && applySummaryText ? (
|
||||||
|
<p
|
||||||
|
className={`links-page-summary${applyCounts.pending > 0 ? ' links-page-summary--pending' : applyCounts.rejected > 0 ? ' links-page-summary--rejected' : ''}`}
|
||||||
|
>
|
||||||
|
{applySummaryText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={openApply}>
|
||||||
|
<Plus size={16} aria-hidden />
|
||||||
|
申请友链
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 登录:申请区在上;游客:友链在上 */}
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
{myAppliesBlock}
|
||||||
|
{friendLinksBlock}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{friendLinksBlock}
|
||||||
<p className="links-page-login-hint">
|
<p className="links-page-login-hint">
|
||||||
<Link to={loginPath('/links')}>登录</Link>
|
<Link to={loginPath('/links')}>登录</Link>
|
||||||
或
|
或
|
||||||
<Link to="/register">注册</Link>
|
<Link to="/register">注册</Link>
|
||||||
后可申请友链并管理自己的申请
|
后可申请友链并管理自己的申请
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<InFlowSiteFooter />
|
<InFlowSiteFooter />
|
||||||
</div>
|
|
||||||
|
|
||||||
<FriendLinkApplyDialog
|
<FriendLinkApplyDialog
|
||||||
open={applyOpen}
|
open={applyOpen}
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrap">
|
<div className="page-wrap">
|
||||||
<div className="page-inner-wide">
|
<div className="feed-panel list-page-panel">
|
||||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
<header className="list-page-panel__head">
|
||||||
|
<Button variant="ghost" size="sm" className="list-page-panel__back" onClick={() => nav('/')}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
@@ -61,11 +62,12 @@ export default function ProjectsPage() {
|
|||||||
论坛会员在 Gitea 上的公开仓库
|
论坛会员在 Gitea 上的公开仓库
|
||||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state list-page-panel__empty">
|
||||||
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
<p>暂无同步到的公开项目</p>
|
<p>暂无同步到的公开项目</p>
|
||||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
AlertDialog,
|
||||||
} from '@/components/ui/dialog';
|
AlertDialogAction,
|
||||||
import ArticleEditor from '../../components/ArticleEditor';
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||||
@@ -16,37 +23,17 @@ import type { SitePage } from '../../api/types';
|
|||||||
import { invalidateSitePagesCache } from '../../hooks/useSitePages';
|
import { invalidateSitePagesCache } from '../../hooks/useSitePages';
|
||||||
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
|
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
|
||||||
import { persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
|
import { persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
|
||||||
|
import { formatTime } from '../../utils/content';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const EMPTY: Partial<SitePage> = {
|
/** 后台:自定义单页列表 */
|
||||||
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() {
|
export default function AdminPagesPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
const { ready } = useAdminGuard();
|
const { ready } = useAdminGuard();
|
||||||
const [rows, setRows] = useState<SitePage[]>([]);
|
const [rows, setRows] = useState<SitePage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [form, setForm] = useState<Partial<SitePage>>({ ...EMPTY });
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [reordering, setReordering] = useState(false);
|
const [reordering, setReordering] = useState(false);
|
||||||
|
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -58,40 +45,7 @@ export default function AdminPagesPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (ready) load(); }, [ready]);
|
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) => {
|
const remove = async (id: number) => {
|
||||||
if (!window.confirm('确定删除该单页?')) return;
|
|
||||||
try {
|
try {
|
||||||
await api.adminDeletePage(id);
|
await api.adminDeletePage(id);
|
||||||
invalidateSitePagesCache();
|
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 handlePageReorder = async (reordered: SitePage[]) => {
|
||||||
const before = [...rows];
|
const before = [...rows];
|
||||||
setReordering(true);
|
setReordering(true);
|
||||||
try {
|
try {
|
||||||
const after = await persistSortOrderChanges(before, reordered, page =>
|
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);
|
setRows(after);
|
||||||
invalidateSitePagesCache();
|
invalidateSitePagesCache();
|
||||||
@@ -121,17 +99,18 @@ export default function AdminPagesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showMoveButtons = shouldShowSortableMoveButtons(rows.length);
|
const showMoveButtons = shouldShowSortableMoveButtons(rows.length);
|
||||||
|
const busy = reordering || togglingId != null;
|
||||||
|
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<header className="admin-page-head">
|
<header className="admin-page-head admin-page-head-row">
|
||||||
<div>
|
<div>
|
||||||
<h1><FileText size={20} aria-hidden /> 单页管理</h1>
|
<h1><FileText size={20} aria-hidden /> 单页管理</h1>
|
||||||
<p>创建「关于我们」「版规」等独立页面</p>
|
<p>创建「关于我们」「版规」等独立页面</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate}><Plus size={16} /> 新建单页</Button>
|
<Button onClick={() => nav('/admin/pages/new')}><Plus size={16} /> 新建单页</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading ? <Spinner /> : (
|
{loading ? <Spinner /> : (
|
||||||
@@ -145,12 +124,13 @@ export default function AdminPagesPage() {
|
|||||||
<th>权重</th>
|
<th>权重</th>
|
||||||
<th>发布</th>
|
<th>发布</th>
|
||||||
<th>展示</th>
|
<th>展示</th>
|
||||||
|
<th>更新时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td colSpan={7} className="admin-table-empty">暂无单页</td></tr>
|
<tr><td colSpan={8} className="admin-table-empty">暂无单页</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
) : (
|
) : (
|
||||||
<AdminSortableList
|
<AdminSortableList
|
||||||
@@ -171,14 +151,76 @@ export default function AdminPagesPage() {
|
|||||||
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{p.title}</td>
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-table-link"
|
||||||
|
onClick={() => nav(`/admin/pages/${p.id}/edit`)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td><code>/page/{p.slug}</code></td>
|
<td><code>/page/{p.slug}</code></td>
|
||||||
<td>{p.sort_order}</td>
|
<td>{p.sort_order}</td>
|
||||||
<td>{p.published ? '是' : '否'}</td>
|
<td>
|
||||||
<td>{[p.show_in_footer && '页脚', p.show_in_nav && '导航'].filter(Boolean).join('、') || '—'}</td>
|
<label className="admin-page-publish-toggle">
|
||||||
|
<Switch
|
||||||
|
checked={!!p.published}
|
||||||
|
disabled={busy}
|
||||||
|
onCheckedChange={(v) => togglePublished(p, v)}
|
||||||
|
aria-label={p.published ? `取消发布 ${p.title}` : `发布 ${p.title}`}
|
||||||
|
/>
|
||||||
|
<span className={cn(
|
||||||
|
'admin-page-publish-toggle__label',
|
||||||
|
p.published ? 'is-on' : 'is-off',
|
||||||
|
)}>
|
||||||
|
{p.published ? '已发布' : '草稿'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="admin-table-tags">
|
||||||
|
{p.show_in_footer && <Badge variant="outline">页脚</Badge>}
|
||||||
|
{p.show_in_nav && <Badge variant="outline">导航</Badge>}
|
||||||
|
{!p.show_in_footer && !p.show_in_nav && '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="admin-table-muted">{p.updated_at ? formatTime(p.updated_at) : '—'}</td>
|
||||||
<td className="admin-table-actions">
|
<td className="admin-table-actions">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} disabled={reordering}><Pencil size={14} /></Button>
|
<Button
|
||||||
<Button variant="ghost" size="sm" onClick={() => remove(p.id)} disabled={reordering}><Trash2 size={14} /></Button>
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => nav(`/admin/pages/${p.id}/edit`)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`编辑 ${p.title}`}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`删除 ${p.title}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除单页「{p.title}」?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
删除后不可恢复,前台链接 /page/{p.slug} 将失效。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => remove(p.id)}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -187,65 +229,6 @@ export default function AdminPagesPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent className="admin-page-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingId ? '编辑单页' : '新建单页'}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="admin-form-grid">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="page-title">标题</Label>
|
|
||||||
<Input
|
|
||||||
id="page-title"
|
|
||||||
value={form.title ?? ''}
|
|
||||||
onChange={e => {
|
|
||||||
const title = e.target.value;
|
|
||||||
setForm(f => ({
|
|
||||||
...f,
|
|
||||||
title,
|
|
||||||
slug: f.slug || slugify(title),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="page-slug">Slug(URL 路径)</Label>
|
|
||||||
<Input
|
|
||||||
id="page-slug"
|
|
||||||
value={form.slug ?? ''}
|
|
||||||
onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
|
|
||||||
placeholder="about-us"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<Label htmlFor="page-sort">排序</Label>
|
|
||||||
<Input
|
|
||||||
id="page-sort"
|
|
||||||
type="number"
|
|
||||||
value={form.sort_order ?? 0}
|
|
||||||
onChange={e => setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-form-switches">
|
|
||||||
<label><Switch checked={!!form.published} onCheckedChange={v => setForm(f => ({ ...f, published: v }))} /> 发布</label>
|
|
||||||
<label><Switch checked={!!form.show_in_footer} onCheckedChange={v => setForm(f => ({ ...f, show_in_footer: v }))} /> 页脚展示</label>
|
|
||||||
<label><Switch checked={!!form.show_in_nav} onCheckedChange={v => setForm(f => ({ ...f, show_in_nav: v }))} /> 侧栏导航</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>正文</Label>
|
|
||||||
<ArticleEditor
|
|
||||||
value={form.content ?? ''}
|
|
||||||
onChange={html => setForm(f => ({ ...f, content: html }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
|
||||||
<Button disabled={saving} onClick={save}>{saving ? '保存中…' : '保存'}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
293
frontend/src/pages/admin/AdminSitePageEditPage.tsx
Normal file
293
frontend/src/pages/admin/AdminSitePageEditPage.tsx
Normal file
@@ -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<SitePageForm>({ ...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 (
|
||||||
|
<div className="admin-site-page-edit admin-site-page-edit--loading">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-site-page-edit">
|
||||||
|
<header className="admin-site-page-edit__header">
|
||||||
|
<div className="admin-site-page-edit__header-left">
|
||||||
|
<button type="button" className="compose-back" onClick={goBack}>
|
||||||
|
<ArrowLeft size={16} aria-hidden />
|
||||||
|
<span>返回列表</span>
|
||||||
|
</button>
|
||||||
|
<h1 className="admin-site-page-edit__title">{isNew ? '新建单页' : '编辑单页'}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="admin-site-page-edit__header-actions">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={preview} disabled={!canPreview}>
|
||||||
|
<ExternalLink size={14} aria-hidden />
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="admin-site-page-edit__meta" aria-label="单页设置">
|
||||||
|
<div className="admin-site-page-edit__meta-grid">
|
||||||
|
<div className="admin-site-page-edit__field">
|
||||||
|
<Label htmlFor="site-page-title">标题</Label>
|
||||||
|
<Input
|
||||||
|
id="site-page-title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
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="关于我们"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-site-page-edit__field">
|
||||||
|
<Label htmlFor="site-page-slug">Slug(URL 路径)</Label>
|
||||||
|
<Input
|
||||||
|
id="site-page-slug"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlugTouched(true);
|
||||||
|
setForm(f => ({ ...f, slug: e.target.value.trim().toLowerCase() }));
|
||||||
|
}}
|
||||||
|
placeholder="about-us"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-invalid={!!slugError && !!form.slug.trim()}
|
||||||
|
/>
|
||||||
|
<p className={cn('admin-site-page-edit__hint', slugError && form.slug.trim() && 'admin-site-page-edit__hint--error')}>
|
||||||
|
{slugError && form.slug.trim() ? slugError : '访问路径:/page/your-slug · 2–64 位小写/数字/连字符'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-site-page-edit__field admin-site-page-edit__field--narrow">
|
||||||
|
<Label htmlFor="site-page-sort">排序权重</Label>
|
||||||
|
<Input
|
||||||
|
id="site-page-sort"
|
||||||
|
type="number"
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-site-page-edit__switches">
|
||||||
|
<label className="admin-site-page-edit__switch">
|
||||||
|
<Switch checked={form.published} onCheckedChange={v => setForm(f => ({ ...f, published: v }))} />
|
||||||
|
<span>发布</span>
|
||||||
|
</label>
|
||||||
|
<label className="admin-site-page-edit__switch">
|
||||||
|
<Switch checked={form.show_in_footer} onCheckedChange={v => setForm(f => ({ ...f, show_in_footer: v }))} />
|
||||||
|
<span>页脚展示</span>
|
||||||
|
</label>
|
||||||
|
<label className="admin-site-page-edit__switch">
|
||||||
|
<Switch checked={form.show_in_nav} onCheckedChange={v => setForm(f => ({ ...f, show_in_nav: v }))} />
|
||||||
|
<span>侧栏导航</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="admin-site-page-edit__body">
|
||||||
|
<div className="admin-site-page-edit__shell">
|
||||||
|
<ArticleEditor
|
||||||
|
value={form.content}
|
||||||
|
onChange={html => setForm(f => ({ ...f, content: html }))}
|
||||||
|
placeholder="撰写单页正文…"
|
||||||
|
enableContentGates={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="admin-site-page-edit__footer">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack}>取消</Button>
|
||||||
|
<Button type="button" disabled={saving} onClick={save}>
|
||||||
|
<Save size={16} aria-hidden />
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<UnsavedChangesDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onStay={stayOnPage}
|
||||||
|
onLeave={discardAndLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2342,6 +2342,65 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
.page-inner { padding: 20px 24px; max-width: 720px; }
|
.page-inner { padding: 20px 24px; max-width: 720px; }
|
||||||
.page-inner-wide { padding: 20px 24px; }
|
.page-inner-wide { padding: 20px 24px; }
|
||||||
.page-inner-wide--profile { max-width: 820px; }
|
.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-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; }
|
.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 {
|
.post-row--v2 .post-title {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5227,6 +5286,16 @@ a.post-title:visited {
|
|||||||
color: #a16207;
|
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 {
|
.dark .article-editor-tools .article-tool-btn--reply {
|
||||||
color: #e8b84a;
|
color: #e8b84a;
|
||||||
}
|
}
|
||||||
@@ -7113,8 +7182,8 @@ a.waline-comment-author:hover {
|
|||||||
.friend-link-site-info {
|
.friend-link-site-info {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--j13-green) 6%, #eef6ff 94%);
|
background: var(--j13-bg-block-muted, #f8fafc);
|
||||||
border: 1px solid color-mix(in srgb, var(--j13-green) 18%, #dbeafe 82%);
|
border: 1px solid var(--j13-border-light, #e5e7eb);
|
||||||
}
|
}
|
||||||
.friend-link-site-info__title {
|
.friend-link-site-info__title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
@@ -7145,13 +7214,7 @@ a.waline-comment-author:hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: var(--foreground, #334155);
|
color: var(--foreground, #334155);
|
||||||
}
|
user-select: text;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.friend-link-apply-dialog {
|
.friend-link-apply-dialog {
|
||||||
@@ -7196,6 +7259,15 @@ a.waline-comment-author:hover {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
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 {
|
.friend-link-apply-field__hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -7214,18 +7286,67 @@ a.waline-comment-author:hover {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--j13-bg-surface, #fff);
|
background: var(--j13-bg-surface, #fff);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--foreground, #334155);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.friend-link-apply-placement__option:hover {
|
.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 {
|
.friend-link-apply-placement__option--active {
|
||||||
border-color: var(--j13-green, #2d6a4f);
|
border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border, #cbd5e1));
|
||||||
background: var(--j13-bg-block-accent, #f0faf5);
|
background: var(--j13-bg-block-muted, #f8fafc);
|
||||||
color: var(--j13-green, #2d6a4f);
|
color: var(--foreground, #1f2937);
|
||||||
font-weight: 600;
|
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 {
|
.friend-link-apply-logo-row {
|
||||||
@@ -10431,10 +10552,13 @@ button.profile-stat:hover strong {
|
|||||||
.article-editor--fullscreen .article-editor-bar {
|
.article-editor--fullscreen .article-editor-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
margin: 0 auto;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
z-index: auto;
|
z-index: auto;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor--fullscreen .article-editor-status {
|
.article-editor--fullscreen .article-editor-status {
|
||||||
@@ -10443,11 +10567,11 @@ button.profile-stat:hover strong {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 全屏富文本:工具栏与编辑区同宽居中 */
|
/* 全屏富文本:工具栏铺满,避免窄宽把「积分可见」等按钮挤没 */
|
||||||
.article-editor--fullscreen.article-editor--rich .article-editor-bar {
|
.article-editor--fullscreen.article-editor--rich .article-editor-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--j13-article-read-w);
|
max-width: none;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor--fullscreen .article-editor-body {
|
.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.is-active,
|
||||||
.article-tool-btn--points[aria-pressed='true'] {
|
.article-tool-btn--points[aria-pressed='true'],
|
||||||
|
.article-tool-btn--points.active {
|
||||||
color: #a16207;
|
color: #a16207;
|
||||||
}
|
}
|
||||||
.profile-badge-wall {
|
.profile-badge-wall {
|
||||||
@@ -13291,8 +13416,48 @@ a.pm-thread-head__name:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义单页 / 友情链接 / 特殊帖 */
|
/* 自定义单页:与首页帖子列表相同的 12px 留白,卡片铺满主栏 */
|
||||||
.site-page__head h1 { margin: 0 0 1rem; font-size: 1.5rem; }
|
.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__head h1 { margin: 0 0 0.5rem; }
|
||||||
.links-page__hint { color: var(--muted-fg, #666); margin: 0 0 1rem; }
|
.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; }
|
.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-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 {
|
.links-page-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -14245,23 +14651,41 @@ button.post-poll__option,
|
|||||||
.links-page-empty {
|
.links-page-empty {
|
||||||
margin: 24px 0;
|
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 {
|
.links-page-my {
|
||||||
margin-top: 36px;
|
margin-top: 0;
|
||||||
padding-top: 24px;
|
padding-top: 0;
|
||||||
border-top: 1px solid var(--border, #e5e7eb);
|
border-top: none;
|
||||||
}
|
}
|
||||||
.links-page-my__title {
|
.links-page-my__title {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 12px;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.links-page-my__empty {
|
.links-page-my__empty {
|
||||||
margin: 0;
|
margin: 0 0 8px;
|
||||||
color: var(--muted-fg, #64748b);
|
color: var(--muted-fg, #64748b);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.links-page-my-list {
|
.links-page-my-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.links-page-my-row {
|
.links-page-my-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ export function insertMarkdownReplyOnly(
|
|||||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
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<points-only data-gate="points" data-cost="${cost}">\n\n`;
|
||||||
|
const snippet = `${open}\n</points-only>\n\n`;
|
||||||
|
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||||
|
const cursor = selectionStart + open.length;
|
||||||
|
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||||
|
}
|
||||||
|
|
||||||
/** 在光标处插入链接 Markdown */
|
/** 在光标处插入链接 Markdown */
|
||||||
export function insertMarkdownLink(
|
export function insertMarkdownLink(
|
||||||
textarea: HTMLTextAreaElement,
|
textarea: HTMLTextAreaElement,
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ func (h *Handlers) APIPageDetail(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"page": page})
|
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 管理端单页列表
|
// APIAdminPages 管理端单页列表
|
||||||
func (h *Handlers) APIAdminPages(c *gin.Context) {
|
func (h *Handlers) APIAdminPages(c *gin.Context) {
|
||||||
pages, err := h.SitePage.ListAll()
|
pages, err := h.SitePage.ListAll()
|
||||||
@@ -87,6 +102,31 @@ func (h *Handlers) APIAdminDeletePage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "单页已删除"})
|
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 投票
|
// APIPollVote 投票
|
||||||
func (h *Handlers) APIPollVote(c *gin.Context) {
|
func (h *Handlers) APIPollVote(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -224,8 +224,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
|
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
|
||||||
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
|
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
|
||||||
adminAPI.GET("/pages", h.APIAdminPages)
|
adminAPI.GET("/pages", h.APIAdminPages)
|
||||||
|
adminAPI.GET("/pages/:id", h.APIAdminGetPage)
|
||||||
adminAPI.POST("/pages", h.APIAdminCreatePage)
|
adminAPI.POST("/pages", h.APIAdminCreatePage)
|
||||||
adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
|
adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
|
||||||
|
adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished)
|
||||||
adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
|
adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
|
||||||
adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
|
adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
|
||||||
adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
|
adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
|
||||||
|
|||||||
@@ -10,11 +10,23 @@ import (
|
|||||||
var (
|
var (
|
||||||
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
|
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
|
||||||
replyOnlyBlockRe = regexp.MustCompile(`(?is)<reply-only\b[^>]*>([\s\S]*?)</reply-only>`)
|
replyOnlyBlockRe = regexp.MustCompile(`(?is)<reply-only\b[^>]*>([\s\S]*?)</reply-only>`)
|
||||||
|
pointsOnlyUnwrapRe = regexp.MustCompile(`(?is)<points-only\b[^>]*>([\s\S]*?)</points-only>`)
|
||||||
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
|
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
|
||||||
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
|
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
|
||||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
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 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
|
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
|
||||||
func RedactMembersOnlyHTML(html string) string {
|
func RedactMembersOnlyHTML(html string) string {
|
||||||
return redactGatedBlocks(html, membersOnlyBlockRe, "members-only")
|
return redactGatedBlocks(html, membersOnlyBlockRe, "members-only")
|
||||||
|
|||||||
@@ -26,3 +26,21 @@ func TestRedactGatedPostHTML(t *testing.T) {
|
|||||||
t.Fatalf("门控正文应被遮盖,得到: %q", out)
|
t.Fatalf("门控正文应被遮盖,得到: %q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnwrapContentGateTags(t *testing.T) {
|
||||||
|
in := `<p>公开</p>` +
|
||||||
|
`<members-only data-gate="login"><p>登录密</p></members-only>` +
|
||||||
|
`<reply-only data-gate="reply"><p>回复密</p></reply-only>` +
|
||||||
|
`<points-only data-gate="points" data-cost="10"><p>积分密</p></points-only>`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model.
|
|||||||
if err := q.First(&page).Error; err != nil {
|
if err := q.First(&page).Error; err != nil {
|
||||||
return nil, ErrSitePageNotFound
|
return nil, ErrSitePageNotFound
|
||||||
}
|
}
|
||||||
page.Content = SanitizePostHTML(page.Content)
|
page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content))
|
||||||
return &page, nil
|
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 {
|
if err := model.DB.First(&page, id).Error; err != nil {
|
||||||
return nil, ErrSitePageNotFound
|
return nil, ErrSitePageNotFound
|
||||||
}
|
}
|
||||||
|
page.Content = SanitizePostHTML(UnwrapContentGateTags(page.Content))
|
||||||
return &page, nil
|
return &page, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +146,15 @@ func (s *SitePageService) Delete(id uint) error {
|
|||||||
return nil
|
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) {
|
func (s *SitePageService) ListSitemap(limit int) ([]model.SitePage, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 500
|
limit = 500
|
||||||
@@ -161,7 +171,8 @@ func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, err
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("slug 格式无效(2-64 位小写字母、数字、连字符)")
|
return nil, errors.New("slug 格式无效(2-64 位小写字母、数字、连字符)")
|
||||||
}
|
}
|
||||||
content := s.filter.Filter(SanitizePostHTML(in.Content))
|
// 单页不支持登录/回复/积分可见:保存前剥离外壳,保留内部正文
|
||||||
|
content := s.filter.Filter(SanitizePostHTML(UnwrapContentGateTags(in.Content)))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return nil, errors.New("标题不能为空")
|
return nil, errors.New("标题不能为空")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user