feat: 优化单页编辑、列表留白与友链申请体验
单页全屏编辑并禁用内容门控;对齐 Feed 留白;友链申请区前置并收敛弹框样式;略增大帖子列表标题字号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user