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:
+3
-3
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+35
-41
@@ -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"
|
||||
placeholder="搜索笔记…"
|
||||
aria-label="搜索笔记"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
className="pn-al-search"
|
||||
allowClear
|
||||
placeholder="搜索笔记…"
|
||||
aria-label="搜索笔记"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
prefix={<Search size={14} aria-hidden />}
|
||||
/>
|
||||
<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"
|
||||
pagination={false}
|
||||
scroll={{ x: 600 }}
|
||||
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
pagination={false}
|
||||
scroll={{ x: 560 }}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user