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 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(
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></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="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
|
||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||
|
||||
@@ -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<SitePage>) =>
|
||||
request<{ message: string; page: SitePage }>('/api/admin/pages', { method: 'POST', body: JSON.stringify(data) }),
|
||||
adminUpdatePage: (id: number, data: Partial<SitePage>) =>
|
||||
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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
||||
{ value, onChange, placeholder = '在此撰写正文…', enableContentGates = true },
|
||||
ref,
|
||||
) {
|
||||
const isInternalUpdate = useRef(false);
|
||||
@@ -274,9 +280,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
ReplyOnly,
|
||||
PointsOnly,
|
||||
...(enableContentGates ? [MembersOnly, ReplyOnly, PointsOnly] : []),
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
@@ -698,65 +702,81 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
);
|
||||
}
|
||||
|
||||
tools.push(
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '读者回复后才可见;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('replyOnly'),
|
||||
className: 'article-tool-btn--reply',
|
||||
action: wrapReplyOnly,
|
||||
},
|
||||
{
|
||||
icon: <Coins size={15} />,
|
||||
title: '积分可见',
|
||||
hint: '读者花费积分解锁;可设价格',
|
||||
active: editor.isActive('pointsOnly'),
|
||||
className: 'article-tool-btn--points',
|
||||
action: wrapPointsOnly,
|
||||
},
|
||||
);
|
||||
if (enableContentGates) {
|
||||
tools.push(
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '读者回复后才可见;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('replyOnly'),
|
||||
className: 'article-tool-btn--reply',
|
||||
action: wrapReplyOnly,
|
||||
},
|
||||
{
|
||||
icon: <Coins size={15} />,
|
||||
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: <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: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
||||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '插入 <reply-only> 区块',
|
||||
className: 'article-tool-btn--reply',
|
||||
action: withMarkdown(insertMarkdownReplyOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => {
|
||||
const tools: ToolBtn[] = [
|
||||
{ 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: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
|
||||
{ icon: <TableIcon size={15} />, title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
];
|
||||
if (enableContentGates) {
|
||||
tools.push(
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '插入 <reply-only> 区块',
|
||||
className: 'article-tool-btn--reply',
|
||||
action: withMarkdown(insertMarkdownReplyOnly),
|
||||
},
|
||||
{
|
||||
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 words = mode === 'markdown'
|
||||
|
||||
@@ -188,35 +188,43 @@ export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, o
|
||||
>
|
||||
友链在我的网站首页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={linkPlacement === 'custom'}
|
||||
<div
|
||||
className={cn(
|
||||
'friend-link-apply-placement__option',
|
||||
linkPlacement === 'custom' && 'friend-link-apply-placement__option--active',
|
||||
'friend-link-apply-placement__custom',
|
||||
linkPlacement === 'custom' && 'friend-link-apply-placement__custom--open',
|
||||
)}
|
||||
onClick={() => setLinkPlacement('custom')}
|
||||
>
|
||||
友链在其它页面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={linkPlacement === 'custom'}
|
||||
className={cn(
|
||||
'friend-link-apply-placement__option',
|
||||
linkPlacement === 'custom' && 'friend-link-apply-placement__option--active',
|
||||
)}
|
||||
onClick={() => setLinkPlacement('custom')}
|
||||
>
|
||||
友链在其它页面
|
||||
</button>
|
||||
<div className="friend-link-apply-placement__custom-panel" aria-hidden={linkPlacement !== 'custom'}>
|
||||
<div className="friend-link-apply-placement__custom-inner">
|
||||
<Label htmlFor="friend-link-reciprocal">添加我方链接的页面地址</Label>
|
||||
<Input
|
||||
id="friend-link-reciprocal"
|
||||
value={reciprocalPageURL}
|
||||
onChange={e => setReciprocalPageURL(e.target.value)}
|
||||
placeholder="如:https://您的域名/link.htm"
|
||||
maxLength={512}
|
||||
tabIndex={linkPlacement === 'custom' ? 0 : -1}
|
||||
/>
|
||||
<p className="friend-link-apply-field__hint">
|
||||
请填写实际放置本站友链的页面,提交后将在后台检测该页面
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{linkPlacement === 'custom' && (
|
||||
<div className="friend-link-apply-field">
|
||||
<Label htmlFor="friend-link-reciprocal">添加我方链接的页面地址</Label>
|
||||
<Input
|
||||
id="friend-link-reciprocal"
|
||||
value={reciprocalPageURL}
|
||||
onChange={e => setReciprocalPageURL(e.target.value)}
|
||||
placeholder="如:https://您的域名/link.htm"
|
||||
maxLength={512}
|
||||
/>
|
||||
<p className="friend-link-apply-field__hint">
|
||||
请填写实际放置本站友链的页面,提交后将在后台检测该页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="friend-link-apply-field">
|
||||
<Label htmlFor="friend-link-logo">填写或上传网站 LOGO</Label>
|
||||
<div className="friend-link-apply-logo-row">
|
||||
|
||||
@@ -32,23 +32,11 @@ export default function FriendLinkSiteInfo() {
|
||||
</div>
|
||||
<div className="friend-link-site-info__item">
|
||||
<dt>地址</dt>
|
||||
<dd>
|
||||
{siteURL ? (
|
||||
<a href={siteURL} target="_blank" rel="noopener noreferrer">{siteURL}</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</dd>
|
||||
<dd>{siteURL || '—'}</dd>
|
||||
</div>
|
||||
<div className="friend-link-site-info__item">
|
||||
<dt>LOGO</dt>
|
||||
<dd>
|
||||
{siteLogoURL ? (
|
||||
<a href={siteLogoURL} target="_blank" rel="noopener noreferrer">{siteLogoURL}</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</dd>
|
||||
<dd>{siteLogoURL || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -44,16 +44,18 @@ export default function FavoritesPage() {
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title">我的收藏</h1>
|
||||
<p className="page-desc">共 {list.length} 篇收藏帖子</p>
|
||||
<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>
|
||||
<h1 className="page-title">我的收藏</h1>
|
||||
<p className="page-desc">共 {list.length} 篇收藏帖子</p>
|
||||
</header>
|
||||
|
||||
{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} />
|
||||
<p>还没有收藏任何帖子</p>
|
||||
<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 { 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<FriendLinkApply['status'], number> = {
|
||||
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 ? (
|
||||
<div className="empty-state links-page-empty list-page-panel__empty">
|
||||
<Link2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无友情链接</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
注册登录后可提交申请,审核通过后将展示在此页
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="links-page-board">
|
||||
<div className="links-page-grid">
|
||||
{friendLinks.map(link => {
|
||||
const logoURL = resolveFriendLinkLogo(link.logo, branding.site_url);
|
||||
return (
|
||||
<a
|
||||
key={`${link.name}-${link.url}`}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="links-page-card"
|
||||
title={link.name}
|
||||
>
|
||||
<div className="links-page-card__logo">
|
||||
{logoURL ? (
|
||||
<img src={logoURL} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span>{linkInitial(link.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<strong className="links-page-card__name">{link.name}</strong>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const myAppliesBlock = user ? (
|
||||
<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>
|
||||
{myLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : myApplies.length === 0 ? (
|
||||
<p className="links-page-my__empty">你还没有提交过友链申请,点击右上角「申请友链」即可提交</p>
|
||||
) : (
|
||||
<div className="links-page-my-list">
|
||||
{sortedApplies.map(apply => (
|
||||
<div key={apply.id} className="links-page-my-row">
|
||||
{apply.logo?.trim() && (
|
||||
<div className="links-page-my-row__logo">
|
||||
<img src={resolveFriendLinkLogo(apply.logo, branding.site_url)} alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
<div className="links-page-my-row__main">
|
||||
<div className="links-page-my-row__title">
|
||||
<strong>{apply.name}</strong>
|
||||
{applyStatusBadge(apply.status)}
|
||||
</div>
|
||||
<a href={apply.url} target="_blank" rel="noopener noreferrer" className="links-page-my-row__url">
|
||||
{apply.url}
|
||||
</a>
|
||||
{apply.status === 'rejected' && apply.review_note?.trim() && (
|
||||
<p className="links-page-my-row__note">拒绝原因:{apply.review_note}</p>
|
||||
)}
|
||||
{apply.status === 'pending' && (
|
||||
<p className="links-page-my-row__note">
|
||||
回链检测:{reciprocalStatusLabel(apply).text}
|
||||
</p>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<p className="links-page-my-row__note">修改后将重新进入审核,友链会暂时从列表移除</p>
|
||||
)}
|
||||
<p className="links-page-my-row__meta">{formatTime(apply.created_at)}</p>
|
||||
</div>
|
||||
{apply.status === 'pending' && (
|
||||
<div className="links-page-my-row__actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={cancelingId === apply.id}
|
||||
onClick={() => cancelApply(apply)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{apply.status === 'rejected' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改并重新提交
|
||||
</Button>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : 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">
|
||||
<div>
|
||||
<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 />
|
||||
申请友链
|
||||
<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>
|
||||
|
||||
{friendLinks.length === 0 ? (
|
||||
<div className="empty-state links-page-empty">
|
||||
<Link2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无友情链接</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
注册登录后可提交申请,审核通过后将展示在此页
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="links-page-board">
|
||||
<div className="links-page-grid">
|
||||
{friendLinks.map(link => {
|
||||
const logoURL = resolveFriendLinkLogo(link.logo, branding.site_url);
|
||||
return (
|
||||
<a
|
||||
key={`${link.name}-${link.url}`}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="links-page-card"
|
||||
title={link.name}
|
||||
>
|
||||
<div className="links-page-card__logo">
|
||||
{logoURL ? (
|
||||
<img src={logoURL} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span>{linkInitial(link.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<strong className="links-page-card__name">{link.name}</strong>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<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 && (
|
||||
<section className="links-page-my" aria-label="我的友链申请">
|
||||
<h2 className="links-page-my__title">我的申请</h2>
|
||||
{myLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : myApplies.length === 0 ? (
|
||||
<p className="links-page-my__empty">你还没有提交过友链申请</p>
|
||||
) : (
|
||||
<div className="links-page-my-list">
|
||||
{myApplies.map(apply => (
|
||||
<div key={apply.id} className="links-page-my-row">
|
||||
{apply.logo?.trim() && (
|
||||
<div className="links-page-my-row__logo">
|
||||
<img src={resolveFriendLinkLogo(apply.logo, branding.site_url)} alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
<div className="links-page-my-row__main">
|
||||
<div className="links-page-my-row__title">
|
||||
<strong>{apply.name}</strong>
|
||||
{applyStatusBadge(apply.status)}
|
||||
</div>
|
||||
<a href={apply.url} target="_blank" rel="noopener noreferrer" className="links-page-my-row__url">
|
||||
{apply.url}
|
||||
</a>
|
||||
{apply.status === 'rejected' && apply.review_note?.trim() && (
|
||||
<p className="links-page-my-row__note">拒绝原因:{apply.review_note}</p>
|
||||
)}
|
||||
{apply.status === 'pending' && (
|
||||
<p className="links-page-my-row__note">
|
||||
回链检测:{reciprocalStatusLabel(apply).text}
|
||||
</p>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<p className="links-page-my-row__note">修改后将重新进入审核,友链会暂时从列表移除</p>
|
||||
)}
|
||||
<p className="links-page-my-row__meta">{formatTime(apply.created_at)}</p>
|
||||
</div>
|
||||
{apply.status === 'pending' && (
|
||||
<div className="links-page-my-row__actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={cancelingId === apply.id}
|
||||
onClick={() => cancelApply(apply)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{apply.status === 'rejected' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改并重新提交
|
||||
</Button>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{/* 登录:申请区在上;游客:友链在上 */}
|
||||
{user ? (
|
||||
<>
|
||||
{myAppliesBlock}
|
||||
{friendLinksBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{friendLinksBlock}
|
||||
<p className="links-page-login-hint">
|
||||
<Link to={loginPath('/links')}>登录</Link>
|
||||
或
|
||||
<Link to="/register">注册</Link>
|
||||
后可申请友链并管理自己的申请
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<p className="links-page-login-hint">
|
||||
<Link to={loginPath('/links')}>登录</Link>
|
||||
或
|
||||
<Link to="/register">注册</Link>
|
||||
后可申请友链并管理自己的申请
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
|
||||
<InFlowSiteFooter />
|
||||
|
||||
<FriendLinkApplyDialog
|
||||
open={applyOpen}
|
||||
onOpenChange={handleApplyOpenChange}
|
||||
|
||||
@@ -51,21 +51,23 @@ export default function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title">开源码桶</h1>
|
||||
<p className="page-desc">
|
||||
论坛会员在 Gitea 上的公开仓库
|
||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||
</p>
|
||||
<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>
|
||||
<h1 className="page-title">开源码桶</h1>
|
||||
<p className="page-desc">
|
||||
论坛会员在 Gitea 上的公开仓库
|
||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : 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} />
|
||||
<p>暂无同步到的公开项目</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -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<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() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [rows, setRows] = useState<SitePage[]>([]);
|
||||
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 [togglingId, setTogglingId] = useState<number | null>(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 (
|
||||
<div className="admin-page">
|
||||
<header className="admin-page-head">
|
||||
<header className="admin-page-head admin-page-head-row">
|
||||
<div>
|
||||
<h1><FileText size={20} aria-hidden /> 单页管理</h1>
|
||||
<p>创建「关于我们」「版规」等独立页面</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}><Plus size={16} /> 新建单页</Button>
|
||||
<Button onClick={() => nav('/admin/pages/new')}><Plus size={16} /> 新建单页</Button>
|
||||
</header>
|
||||
|
||||
{loading ? <Spinner /> : (
|
||||
@@ -145,12 +124,13 @@ export default function AdminPagesPage() {
|
||||
<th>权重</th>
|
||||
<th>发布</th>
|
||||
<th>展示</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{rows.length === 0 ? (
|
||||
<tbody>
|
||||
<tr><td colSpan={7} className="admin-table-empty">暂无单页</td></tr>
|
||||
<tr><td colSpan={8} className="admin-table-empty">暂无单页</td></tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<AdminSortableList
|
||||
@@ -171,14 +151,76 @@ export default function AdminPagesPage() {
|
||||
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
||||
</div>
|
||||
</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>{p.sort_order}</td>
|
||||
<td>{p.published ? '是' : '否'}</td>
|
||||
<td>{[p.show_in_footer && '页脚', p.show_in_nav && '导航'].filter(Boolean).join('、') || '—'}</td>
|
||||
<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">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} disabled={reordering}><Pencil size={14} /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(p.id)} disabled={reordering}><Trash2 size={14} /></Button>
|
||||
<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>
|
||||
</tr>
|
||||
)}
|
||||
@@ -187,65 +229,6 @@ export default function AdminPagesPage() {
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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-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;
|
||||
|
||||
@@ -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<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 */
|
||||
export function insertMarkdownLink(
|
||||
textarea: HTMLTextAreaElement,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,11 +10,23 @@ import (
|
||||
var (
|
||||
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-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}" 之类噪声
|
||||
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
|
||||
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")
|
||||
|
||||
@@ -26,3 +26,21 @@ func TestRedactGatedPostHTML(t *testing.T) {
|
||||
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 {
|
||||
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("标题不能为空")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user