refactor(web): 残留自制控件统一替换为 antd 组件

- 状态点自制徽标改用 Badge,回收站剩余天数紧急态改用红色 Tag
- 危险按钮改用 danger 属性并补充语言,text+danger 不套实心红样式
- 后台搜索框改 antd Input 前缀形态,删除手写容器与聚焦环样式
- 加载态统一 Spin/Skeleton(Table 用 loading),空态统一 Empty
- Segmented 选项计数改用 Badge count,弱化为 muted 灰
- 首页分页说明改 Pagination showTotal,笔记错误态改 Empty 承接布局
- 代码块复制按钮改 antd Button 并增加「已复制」反馈,修复悬浮不显示
- 博客/后台图标按钮与溢出菜单触发器统一为 antd Button type=text
This commit is contained in:
2026-09-09 15:37:36 +08:00
parent 969d537aee
commit 92d89717c5
11 changed files with 219 additions and 283 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { App as AntdApp, ConfigProvider } from 'antd'
import { App as AntdApp, ConfigProvider, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { useAuth } from './lib/auth'
import { useTheme } from './lib/theme'
@@ -45,7 +45,7 @@ function AntdProvider({ children }: { children: ReactNode }) {
function RequireAuth({ children }: { children: ReactNode }) {
const { authenticated, loading } = useAuth()
if (loading) {
return <div className="pn-loading">加载中…</div>
return <div className="pn-loading"><Spin size="large" /></div>
}
if (!authenticated) {
return <Navigate to="/admin/login" replace />
@@ -56,7 +56,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
export default function App() {
return (
<AntdProvider>
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
<Suspense fallback={<div className="pn-loading"><Spin size="large" /></div>}>
<Routes>
<Route element={<BlogLayout />}>
<Route path="/" element={<Home />} />
+19 -24
View File
@@ -2,7 +2,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
import {
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
} from 'lucide-react'
import { Dropdown, Menu, Tooltip } from 'antd'
import { Button, Dropdown, Menu, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import { useSiteTitle } from '../features/site'
import { useTheme } from '../lib/theme'
@@ -20,9 +20,7 @@ function MoreMenu({ triggerClassName, triggerLabel, items }: {
// 曾致桌面导航整体横移 4px)
<div className="pn-moremenu">
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
<MoreHorizontal size={15} />
</button>
<Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
</Dropdown>
</div>
)
@@ -49,15 +47,14 @@ function ThemeButton({ className = 'pn-site-navlink' }: { className?: string })
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<button
type="button"
<Button
type="text"
className={className}
title={title}
aria-label={title}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)}
>
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
</button>
/>
)
}
@@ -132,14 +129,13 @@ function AdminThemeButton() {
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<Tooltip title={label} placement="bottom">
<button
type="button"
<Button
type="text"
className="pn-admin-iconbtn pn-admin-desktop-act"
aria-label={label}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)}
>
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
</button>
/>
</Tooltip>
)
}
@@ -269,25 +265,24 @@ function AdminSidebar() {
</span>
<span className="pn-admin-user-actions">
<Tooltip title="返回博客" placement="bottom">
<a
<Button
type="text"
className="pn-admin-iconbtn"
href="/"
target="_blank"
rel="noreferrer"
className="pn-admin-iconbtn"
aria-label="返回博客(新窗口打开)"
>
<ExternalLink size={15} />
</a>
icon={<ExternalLink size={15} />}
/>
</Tooltip>
<Tooltip title="退出登录" placement="bottom">
<button
type="button"
<Button
type="text"
className="pn-admin-iconbtn"
aria-label="退出登录"
icon={<LogOut size={15} />}
onClick={() => void logout().then(() => navigate('/'))}
>
<LogOut size={15} />
</button>
/>
</Tooltip>
</span>
</div>
+20 -8
View File
@@ -1,7 +1,8 @@
import { memo } from 'react'
import { memo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { Button } from 'antd'
import { sanitizePlugin } from '../lib/sanitize'
/**
@@ -25,18 +26,29 @@ function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
}
function CopyButton() {
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML。
// MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
const [copied, setCopied] = useState(false)
return (
<button
type="button"
className="pn-copy-btn"
<Button
size="small"
className={copied ? 'pn-copy-btn is-copied' : 'pn-copy-btn'}
onClick={(e) => {
const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre')
if (pre) void navigator.clipboard.writeText(pre.textContent ?? '')
const text = pre?.textContent ?? ''
if (!text) return
try {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
})
} catch {
/* clipboard 不可用(非安全上下文等)时静默 */
}
}}
>
复制
</button>
{copied ? '已复制' : '复制'}
</Button>
)
}
+70 -154
View File
@@ -213,17 +213,6 @@ input.ant-input::placeholder,
textarea.ant-input::placeholder {
color: var(--muted-foreground);
}
/* antd 按钮的 danger-outline 形态(默认描边 + 危险色文字) */
button.ant-btn.pn-btn-danger-outline {
color: var(--destructive-text);
border-color: var(--destructive-border);
background: transparent;
}
button.ant-btn.pn-btn-danger-outline:hover:not(:disabled) {
color: var(--destructive-text) !important;
border-color: var(--destructive-border) !important;
background: var(--destructive-subtle) !important;
}
/* Dropdown 内路由项:激活态与旧 .pn-moremenu-item.active 一致 */
.pn-dd-link {
display: inline-flex;
@@ -358,25 +347,7 @@ button.pn-login-back:hover {
}
/* 表格卡空态(无数据/加载中占位) */
.pn-empty {
display: grid;
place-items: center;
min-height: 132px;
padding: 12px;
color: var(--muted-foreground);
background: var(--muted);
font-size: 13px;
}
/* ── 状态点 ── */
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
flex: none;
}
/* 加载/空态(Spin/Skeleton/Empty)已由 antd 承接 */
/* ============================================================
博客端共享 shell —— 居中单栏 720px(设计稿博客系列)
@@ -463,9 +434,25 @@ button.pn-login-back:hover {
width: 14px;
height: 14px;
}
/* 同类的 antd Button 形态(主题切换按钮/溢出菜单触发器):
antd cssinjs 无 layer,需更高特异度对齐导航 pill 规格 */
button.ant-btn.pn-site-navlink {
height: auto;
padding: 7px 12px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
font-size: 14px;
font-weight: 500;
color: var(--muted-foreground);
}
button.ant-btn.pn-site-navlink:hover {
background: var(--muted);
color: var(--foreground);
}
/* 博客端溢出菜单触发按钮:<640px 显示(基础规则须在 640px MQ 之前,
否则同特异性下源码顺序靠后者覆盖 MQ 内的 display:inline-flex) */
.pn-site-menu-btn {
button.ant-btn.pn-site-menu-btn {
display: none;
}
.pn-site-main {
@@ -526,7 +513,7 @@ button.pn-login-back:hover {
.pn-site-navlink.hide-sm {
display: none;
}
.pn-site-menu-btn {
button.ant-btn.pn-site-menu-btn {
display: inline-flex;
}
}
@@ -645,22 +632,28 @@ button.pn-login-back:hover {
color: var(--muted-foreground);
font-size: 14px;
}
/* 首页分页(对应设计稿 table-foot + pager) */
/* 首页分页(对应设计稿 table-foot + pager)。
总数说明由 Pagination showTotal 承接:total 文本居左、页码居右 */
.pn-home-pager {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 12px;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.pn-home-pager-note {
font-size: 12.5px;
.pn-home-pager ul.ant-pagination {
flex: 1;
justify-content: space-between;
}
.pn-home-pager .ant-pagination-total-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.02em;
color: var(--muted-foreground);
white-space: nowrap;
}
/* 首页分页器已由 antd Pagination 承接(.pn-home-pager 仅保留布局行) */
@media (max-width: 640px) {
.pn-home-content {
padding-top: 40px;
@@ -957,29 +950,23 @@ button.pn-note-back:hover {
color: var(--muted-foreground);
font-style: italic;
}
/* 代码块复制按钮 */
.pn-copy-btn {
/* 代码块复制按钮(antd Button size=small;悬浮于所属包装 div 时显示) */
.pn-note-body div:has(> .pn-copy-btn) {
position: relative;
}
.pn-note-body .pn-copy-btn {
position: absolute;
right: 8px;
top: 8px;
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.6;
cursor: pointer;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
transition: opacity 0.15s ease;
}
.pn-note-body pre:hover .pn-copy-btn,
.pn-copy-btn:focus-visible {
.pn-note-body div:has(> .pn-copy-btn):hover .pn-copy-btn,
.pn-note-body .pn-copy-btn:focus-visible,
.pn-note-body .pn-copy-btn.is-copied {
opacity: 1;
}
.pn-copy-btn:hover {
color: var(--foreground);
}
/* ============================================================
标签归档(标签归档.html)
@@ -1009,6 +996,9 @@ button.pn-note-back:hover {
flex-wrap: wrap;
gap: 8px;
}
.pn-tag-empty {
margin-top: 32px;
}
.pn-tag-groups {
margin-top: 48px;
display: flex;
@@ -1347,32 +1337,23 @@ button.pn-note-back:hover {
gap: 4px;
margin-left: auto;
}
.pn-admin-iconbtn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
/* 侧栏/topbar 图标按钮(antd Button type=text):28px 方形、muted 前景,
hover 沿用 sidebar accent;antd cssinjs 无 layer,需提权 */
button.ant-btn.pn-admin-iconbtn,
a.ant-btn.pn-admin-iconbtn {
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted-foreground);
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.pn-admin-iconbtn:hover {
button.ant-btn.pn-admin-iconbtn:hover:not(:disabled),
a.ant-btn.pn-admin-iconbtn:hover {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
.pn-admin-iconbtn svg {
width: 15px;
height: 15px;
}
/* 悬浮提示由 antd Tooltip 承接;侧栏隐藏时的移动端兜底按钮(<768px 显示) */
.pn-admin-mobile-act {
button.ant-btn.pn-admin-mobile-act {
display: none;
}
/* 溢出菜单挂载点(面板由 antd Dropdown 渲染,wrapper 仅保留占位语义) */
@@ -1447,39 +1428,15 @@ button.pn-note-back:hover {
gap: 20px;
min-width: 0;
}
/* 搜索框(topbar 内) */
.pn-admin-search {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
/* 页面级加载占位(antd Spin 居中) */
.pn-admin-loading {
display: grid;
place-items: center;
min-height: 320px;
}
/* 搜索框(topbar 内,antd Input + 前缀图标;<768px 隐藏) */
.pn-admin-actions .pn-al-search {
width: 248px;
padding: 0 10px;
border: 1px solid var(--input);
border-radius: var(--radius-sm);
background: var(--card);
transition: border-color 0.15s ease;
}
.pn-admin-search:focus-within {
border-color: var(--ring);
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.pn-admin-search svg {
width: 14px;
height: 14px;
flex: none;
color: var(--muted-foreground);
}
.pn-admin-search input {
width: 100%;
min-width: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
font-size: 13px;
color: var(--foreground);
}
/* 移动端 tabbar(<768px) */
.pn-admin-tabbar {
@@ -1520,13 +1477,13 @@ button.pn-note-back:hover {
padding-left: 10px;
padding-right: 10px;
}
.pn-admin-mobile-act {
button.ant-btn.pn-admin-mobile-act {
display: inline-flex;
}
.pn-admin-desktop-act {
button.ant-btn.pn-admin-desktop-act {
display: none;
}
.pn-admin-search {
.pn-admin-actions .pn-al-search {
display: none;
}
.pn-admin-content {
@@ -1588,11 +1545,14 @@ button.pn-note-back:hover {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.pn-al-tab-num {
/* Segmented 选项内计数徽标(antd Badge count,弱化为 muted 灰) */
.pn-al-tab-badge {
margin-left: 6px;
}
.pn-al-tab-badge .ant-badge-count {
background: var(--muted-foreground);
color: var(--background);
font-family: var(--font-mono);
font-size: 11px;
margin-left: 5px;
opacity: 0.65;
}
.pn-al-table-note {
font-size: 12px;
@@ -1640,27 +1600,6 @@ button.pn-note-back:hover {
color: var(--foreground);
white-space: nowrap;
}
.pn-al-status {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 500;
white-space: nowrap;
}
.pn-al-status.pn-al-is-public {
color: var(--success-text);
}
.pn-al-status.pn-al-is-public .dot {
background: var(--success);
}
.pn-al-status.pn-al-is-draft {
color: var(--muted-foreground);
}
.pn-al-status.pn-al-is-draft .dot {
background: var(--muted-foreground);
opacity: 0.55;
}
.pn-al-actions-cell {
display: flex;
align-items: center;
@@ -1804,10 +1743,8 @@ button.pn-ae-tool svg {
.pn-ae-preview .pn-note-body {
margin-top: 20px;
}
.pn-ae-upload-msg {
margin: 10px 0 0;
font-size: 12px;
color: var(--muted-foreground);
.pn-ae-upload-alert {
margin-top: 10px;
}
/* 发布设置侧卡 */
.pn-ae-side {
@@ -1830,20 +1767,6 @@ button.pn-ae-tool svg {
color: var(--muted-foreground);
white-space: nowrap;
}
.pn-ae-pvalue {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--foreground);
}
.pn-ae-pvalue.is-muted {
color: var(--muted-foreground);
}
.pn-ae-pvalue svg {
width: 14px;
height: 14px;
color: var(--muted-foreground);
}
/* 发布日期选择器(size=small) */
.pn-ae-datepicker {
flex: none;
@@ -1853,10 +1776,6 @@ button.pn-ae-tool svg {
font-family: var(--font-mono);
font-size: 12px;
}
.pn-ae-mono {
font-family: var(--font-mono);
font-size: 12px;
}
.pn-ae-pfield {
display: flex;
flex-direction: column;
@@ -1924,9 +1843,6 @@ button.pn-ae-tool svg {
color: var(--foreground);
white-space: nowrap;
}
.pn-at-days-urgent {
color: var(--destructive-text);
}
.pn-at-actions {
display: flex;
align-items: center;
+22 -17
View File
@@ -6,7 +6,7 @@ import {
Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
Save, Table, Trash2,
} from 'lucide-react'
import { Button, Card, DatePicker, Divider, Input, Select, Switch } from 'antd'
import { Alert, Badge, Button, Card, DatePicker, Divider, Input, Select, Spin, Switch } from 'antd'
import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
@@ -72,7 +72,7 @@ export default function AdminEdit() {
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
const dirtyRef = useRef(false)
const slugTouchedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -190,7 +190,7 @@ export default function AdminEdit() {
return (
<>
<AdminTopbar title="编辑笔记" />
<div className="pn-admin-content"><div className="pn-empty">加载中…</div></div>
<div className="pn-admin-content"><div className="pn-admin-loading"><Spin /></div></div>
</>
)
}
@@ -261,16 +261,23 @@ export default function AdminEdit() {
</div>
<div className="pn-ae-body-wrap">
<Suspense fallback={<div className="pn-empty" style={{ minHeight: 420 }}>编辑器加载中…</div>}>
<Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
<Editor
value={form.content}
onChange={(v) => set('content', v)}
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
onUploadEnd={(err) => setUploadMsg(err ?? null)}
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
/>
</Suspense>
</div>
{uploadMsg && <p className="pn-ae-upload-msg">{uploadMsg}</p>}
{uploadMsg && (
<Alert
className="pn-ae-upload-alert"
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
showIcon
message={uploadMsg.text}
/>
)}
{showPreview && (
<div className="pn-ae-preview">
@@ -285,12 +292,10 @@ export default function AdminEdit() {
<div className="pn-ae-prow">
<span className="pn-ae-plabel">状态</span>
<span
className={`pn-ae-pvalue pn-ae-mono ${form.status === 'public' ? '' : 'is-muted'}`}
style={form.status === 'public' ? { color: 'var(--success-text)' } : undefined}
>
<span className="dot" />{form.status === 'public' ? '公开' : '私有'}
</span>
<Badge
status={form.status === 'public' ? 'success' : 'default'}
text={form.status === 'public' ? '公开' : '私有'}
/>
<Switch
checked={form.status === 'public'}
onChange={(checked) => set('status', checked ? 'public' : 'private')}
@@ -355,13 +360,13 @@ export default function AdminEdit() {
<>
<Divider className="pn-ae-divider" />
<Button
className="pn-btn-danger-outline"
style={{ width: '100%' }}
danger
block
icon={<Trash2 size={15} />}
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate()}
>
<Trash2 size={15} />
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span>
{trashMutation.isPending ? '删除中…' : '移至回收站'}
</Button>
</>
)}
+20 -26
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
import { Button, Card, Input, Modal, Segmented, Table, Tag } from 'antd'
import { Badge, Button, Card, Empty, Input, Modal, Segmented, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
@@ -122,14 +122,9 @@ export default function AdminList() {
{
title: '状态',
key: 'status',
render: (_, n) => {
const isPublic = n.status === 'public'
return (
<span className={`pn-al-status ${isPublic ? 'pn-al-is-public' : 'pn-al-is-draft'}`}>
<span className="dot" />{isPublic ? '已发布' : '私有'}
</span>
)
},
render: (_, n) => (
<Badge status={n.status === 'public' ? 'success' : 'default'} text={n.status === 'public' ? '已发布' : '私有'} />
),
},
{
title: '操作',
@@ -156,7 +151,7 @@ export default function AdminList() {
/>
<Button
type="text"
className="pn-btn-danger-outline"
danger
icon={<Trash2 size={15} />}
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
@@ -171,17 +166,15 @@ export default function AdminList() {
return (
<>
<AdminTopbar title="笔记管理">
<label className="pn-admin-search">
<Search size={14} aria-hidden />
<Input
variant="borderless"
type="search"
className="pn-al-search"
allowClear
placeholder="搜索笔记…"
aria-label="搜索笔记"
value={query}
onChange={(e) => setQuery(e.target.value)}
prefix={<Search size={14} aria-hidden />}
/>
</label>
<Button
type="primary"
icon={<CirclePlus size={15} />}
@@ -229,31 +222,32 @@ export default function AdminList() {
onChange={(v) => setFilter(v as StatusFilter)}
className="min-w-0"
options={[
{ value: 'all', label: <>全部<span className="pn-al-tab-num">{items.length}</span></> },
{ value: 'public', label: <>已发布<span className="pn-al-tab-num">{publicCount}</span></> },
{ value: 'private', label: <>私有<span className="pn-al-tab-num">{privateCount}</span></> },
{ value: 'all', label: <>全部<Badge count={items.length} showZero size="small" className="pn-al-tab-badge" /></> },
{ value: 'public', label: <>已发布<Badge count={publicCount} showZero size="small" className="pn-al-tab-badge" /></> },
{ value: 'private', label: <>私有<Badge count={privateCount} showZero size="small" className="pn-al-tab-badge" /></> },
]}
/>
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
</div>
{isLoading ? (
<div className="pn-empty">加载中…</div>
) : visible.length === 0 ? (
<div className="pn-empty">
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
</div>
) : (
<Table
className="pn-al-table"
columns={columns}
dataSource={visible}
rowKey="id"
loading={isLoading}
pagination={false}
scroll={{ x: 600 }}
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
/>
),
}}
/>
)}
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
+6 -8
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Alert, App, Button, Card, Table } from 'antd'
import { Alert, App, Button, Card, Empty, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
@@ -77,7 +77,7 @@ export default function AdminTrash() {
key: 'days',
render: (_, n) => {
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
return <span className={`pn-at-days${left <= 3 ? ' pn-at-days-urgent' : ''}`}>{left} 天</span>
return left <= 3 ? <Tag color="red">{left} 天</Tag> : <span className="pn-at-days">{left} 天</span>
},
},
{
@@ -122,20 +122,18 @@ export default function AdminTrash() {
/>
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
{isLoading ? (
<div className="pn-empty">加载中…</div>
) : items.length === 0 ? (
<div className="pn-empty">回收站是空的</div>
) : (
<Table
className="pn-al-table"
columns={columns}
dataSource={items}
rowKey="id"
loading={isLoading}
pagination={false}
scroll={{ x: 560 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
}}
/>
)}
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
+8 -6
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Pin } from 'lucide-react'
import { Pagination, Tag } from 'antd'
import { Empty, Flex, Pagination, Skeleton, Tag } from 'antd'
import { api } from '../lib/api'
import type { NoteItem, NoteList } from '../lib/api'
import { useDocumentTitle } from '../features/site'
@@ -69,9 +69,13 @@ export default function Home() {
<div className="pn-home-content">
<section aria-label="笔记列表">
{isLoading ? (
<p className="pn-home-list-empty">加载中…</p>
<Flex vertical gap={44} className="pn-home-skeleton">
{[0, 1, 2].map((i) => (
<Skeleton key={i} active title paragraph={{ rows: 2 }} />
))}
</Flex>
) : !data || data.items.length === 0 ? (
<p className="pn-home-list-empty">还没有公开的笔记</p>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有公开的笔记" />
) : (
data.items.map((n) => <NotePost key={n.slug} note={n} />)
)}
@@ -79,15 +83,13 @@ export default function Home() {
{totalPages > 1 && (
<nav className="pn-home-pager" aria-label="分页">
<span className="pn-home-pager-note">
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页
</span>
<Pagination
size="small"
current={page}
pageSize={data?.page_size ?? 10}
total={data?.total ?? 0}
showSizeChanger={false}
showTotal={() => `共 ${data?.total ?? 0} 篇 · 第 ${page} 页 / 共 ${totalPages} 页`}
onChange={(p) => setPage(p)}
/>
</nav>
+19 -8
View File
@@ -1,7 +1,7 @@
import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
import { Alert, Button, Card, Tag } from 'antd'
import { Alert, Button, Card, Empty, Skeleton, Tag } from 'antd'
import { api } from '../lib/api'
import type { NoteDetail } from '../lib/api'
import { useAuth } from '../lib/auth'
@@ -20,17 +20,28 @@ export default function Note() {
})
useDocumentTitle(note?.title)
if (isLoading) return <p className="pn-home-list-empty">加载中…</p>
if (isLoading) {
return (
<article className="pn-note-article">
<Skeleton active title paragraph={{ rows: 6 }} />
</article>
)
}
if (error || !note) {
return (
<div className="pn-note-article">
<p className="pn-home-list-empty">笔记不存在或未公开</p>
<p style={{ textAlign: 'center', marginTop: -40 }}>
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
<article className="pn-note-article">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
<Button
type="text"
size="small"
className="pn-note-back"
icon={<ArrowLeft size={14} />}
onClick={() => navigate('/')}
>
返回首页
</Button>
</p>
</div>
</Empty>
</article>
)
}
+7 -5
View File
@@ -1,7 +1,7 @@
import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Tag } from 'lucide-react'
import { Tag as AntTag } from 'antd'
import { Empty, Skeleton, Spin, Tag as AntTag } from 'antd'
import { api } from '../lib/api'
import type { NoteList, TagCount } from '../lib/api'
import { useDocumentTitle } from '../features/site'
@@ -53,10 +53,12 @@ export default function TagPage() {
aria-label="全部标签"
/>
{!tagsData && (
<span className="p-2 font-mono text-xs text-muted-foreground">加载中…</span>
<div className="pn-tag-cloud"><Spin size="small" /></div>
)}
{tagsData && tagsData.tags.length === 0 && (
<span className="p-2 font-mono text-xs text-muted-foreground">还没有标签</span>
<div className="pn-tag-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
</div>
)}
{tag === undefined && tagsData && (
@@ -69,11 +71,11 @@ export default function TagPage() {
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
<div className="pn-tag-rows">
{isLoading ? (
<p className="pn-home-list-empty">加载中…</p>
<Skeleton active title paragraph={{ rows: 3 }} />
) : error ? (
<p className="pn-home-list-empty">加载失败,请稍后重试</p>
) : !data || data.items.length === 0 ? (
<p className="pn-home-list-empty">该标签下暂无公开笔记</p>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该标签下暂无公开笔记" />
) : (
data.items.map((n) => (
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
+2 -1
View File
@@ -340,7 +340,8 @@ const useShadcnTheme = (dark: boolean) => {
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
(props as { type?: string }).type === 'default' &&
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
(props as { danger?: boolean }).danger &&
(props as { danger?: boolean; type?: string }).danger &&
(props as { type?: string }).type !== 'text' &&
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
),
}),