feat: 优化单页编辑、列表留白与友链申请体验

单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:39:24 +08:00
parent f559fc4c4b
commit 9eefe24a33
18 changed files with 1318 additions and 430 deletions

View File

@@ -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>} />

View File

@@ -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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">SlugURL </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>
);
}

View 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 '264 位小写字母、数字或连字符,且不能以连字符开头/结尾';
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">SlugURL </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 · 264 位小写/数字/连字符'}
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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("标题不能为空")
}