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

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

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 '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>
);
}