diff --git a/web/src/App.tsx b/web/src/App.tsx index c3741fb..c0290be 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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
的文本;不注入任何 HTML
+ // 复制按钮在点击时读取最近兄弟 的文本;不注入任何 HTML。
+ // MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
+ const [copied, setCopied] = useState(false)
return (
-
+ {copied ? '已复制' : '复制'}
+
)
}
diff --git a/web/src/index.css b/web/src/index.css
index 9979016..dbba803 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -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;
diff --git a/web/src/pages/AdminEdit.tsx b/web/src/pages/AdminEdit.tsx
index 30ae5ae..29b50c6 100644
--- a/web/src/pages/AdminEdit.tsx
+++ b/web/src/pages/AdminEdit.tsx
@@ -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(null)
const [slugFieldError, setSlugFieldError] = useState(null)
const [showPreview, setShowPreview] = useState(false)
- const [uploadMsg, setUploadMsg] = useState(null)
+ const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
const dirtyRef = useRef(false)
const slugTouchedRef = useRef(false)
const timerRef = useRef | null>(null)
@@ -190,7 +190,7 @@ export default function AdminEdit() {
return (
<>
- 加载中…
+
>
)
}
@@ -261,16 +261,23 @@ export default function AdminEdit() {
- 编辑器加载中… }>
+ }>
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)}
/>
- {uploadMsg && {uploadMsg}
}
+ {uploadMsg && (
+
+ )}
{showPreview && (
@@ -285,12 +292,10 @@ export default function AdminEdit() {
状态
-
- {form.status === 'public' ? '公开' : '私有'}
-
+
set('status', checked ? 'public' : 'private')}
@@ -355,13 +360,13 @@ export default function AdminEdit() {
<>
}
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate()}
>
-
- {trashMutation.isPending ? '删除中…' : '移至回收站'}
+ {trashMutation.isPending ? '删除中…' : '移至回收站'}
>
)}
diff --git a/web/src/pages/AdminList.tsx b/web/src/pages/AdminList.tsx
index 7df27b0..4af7b06 100644
--- a/web/src/pages/AdminList.tsx
+++ b/web/src/pages/AdminList.tsx
@@ -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 (
-
- {isPublic ? '已发布' : '私有'}
-
- )
- },
+ render: (_, n) => (
+
+ ),
},
{
title: '操作',
@@ -156,7 +151,7 @@ export default function AdminList() {
/>
}
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
@@ -171,17 +166,15 @@ export default function AdminList() {
return (
<>
-
+ setQuery(e.target.value)}
+ prefix={ }
+ />
}
@@ -229,31 +222,32 @@ export default function AdminList() {
onChange={(v) => setFilter(v as StatusFilter)}
className="min-w-0"
options={[
- { value: 'all', label: <>全部{items.length}> },
- { value: 'public', label: <>已发布{publicCount}> },
- { value: 'private', label: <>私有{privateCount}> },
+ { value: 'all', label: <>全部 > },
+ { value: 'public', label: <>已发布 > },
+ { value: 'private', label: <>私有 > },
]}
/>
共 {visible.length} 篇笔记
- {isLoading ? (
- 加载中…
- ) : visible.length === 0 ? (
-
- {items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
-
- ) : (
- (n.status === 'public' ? '' : 'pn-al-is-draft')}
- />
- )}
+ (n.status === 'public' ? '' : 'pn-al-is-draft')}
+ locale={{
+ emptyText: (
+
+ ),
+ }}
+ />
共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇
diff --git a/web/src/pages/AdminTrash.tsx b/web/src/pages/AdminTrash.tsx
index 547993d..7666da0 100644
--- a/web/src/pages/AdminTrash.tsx
+++ b/web/src/pages/AdminTrash.tsx
@@ -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 {left} 天
+ return left <= 3 ? {left} 天 : {left} 天
},
},
{
@@ -122,20 +122,18 @@ export default function AdminTrash() {
/>
- {isLoading ? (
- 加载中…
- ) : items.length === 0 ? (
- 回收站是空的
- ) : (
-
- )}
+
,
+ }}
+ />
共 {data?.total ?? 0} 篇已删除笔记
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx
index c21cb09..f4c42bd 100644
--- a/web/src/pages/Home.tsx
+++ b/web/src/pages/Home.tsx
@@ -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() {
{isLoading ? (
- 加载中…
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
) : !data || data.items.length === 0 ? (
- 还没有公开的笔记
+
) : (
data.items.map((n) => )
)}
@@ -79,15 +83,13 @@ export default function Home() {
{totalPages > 1 && (
diff --git a/web/src/pages/Note.tsx b/web/src/pages/Note.tsx
index 9980f0c..9569051 100644
--- a/web/src/pages/Note.tsx
+++ b/web/src/pages/Note.tsx
@@ -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 加载中…
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
if (error || !note) {
return (
-
- 笔记不存在或未公开
-
- } onClick={() => navigate('/')}>
+
+
+ }
+ onClick={() => navigate('/')}
+ >
返回首页
-
-
+
+
)
}
diff --git a/web/src/pages/TagPage.tsx b/web/src/pages/TagPage.tsx
index c64c3c6..434c13b 100644
--- a/web/src/pages/TagPage.tsx
+++ b/web/src/pages/TagPage.tsx
@@ -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 && (
- 加载中…
+
)}
{tagsData && tagsData.tags.length === 0 && (
- 还没有标签
+
+
+
)}
{tag === undefined && tagsData && (
@@ -69,11 +71,11 @@ export default function TagPage() {
{tag}
{isLoading ? (
- 加载中…
+
) : error ? (
加载失败,请稍后重试
) : !data || data.items.length === 0 ? (
- 该标签下暂无公开笔记
+
) : (
data.items.map((n) => (
diff --git a/web/src/theme/shadcnTheme.ts b/web/src/theme/shadcnTheme.ts
index 6173241..c622c77 100644
--- a/web/src/theme/shadcnTheme.ts
+++ b/web/src/theme/shadcnTheme.ts
@@ -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),
),
}),