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