feat(web): 整体替换 UI 框架为 Ant Design 6 并接入自定义主题

以 antd ConfigProvider + antd-style 承载 zinc 风格自定义主题(明暗双套 token),
button/input/select/notification 用 createStyles 定制;三态主题经
darkAlgorithm 无刷新切换;use-toast 改基于 antd notification;
移除 shadcn/ui 组件目录与 radix 依赖;样式桥接层保证排版与多端布局不变。
This commit is contained in:
2026-09-09 10:35:24 +08:00
parent 5a78de6aa6
commit 87d9bab5dc
23 changed files with 2787 additions and 1608 deletions
+48 -21
View File
@@ -1,7 +1,11 @@
import { lazy, Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router'
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 zhCN from 'antd/locale/zh_CN'
import { useAuth } from './lib/auth'
import { useTheme } from './lib/theme'
import useShadcnTheme from './theme/shadcnTheme'
import { BlogLayout, AdminLayout } from './components/Layout'
import Home from './pages/Home'
import TagPage from './pages/TagPage'
@@ -16,6 +20,27 @@ import NotFound from './pages/NotFound'
const Note = lazy(() => import('./pages/Note'))
const AdminEdit = lazy(() => import('./pages/AdminEdit'))
// antd ConfigProvider:跟随应用三态主题切换 light/dark 算法(system 监听系统变化)
function AntdProvider({ children }: { children: ReactNode }) {
const { mode } = useTheme()
const [systemDark, setSystemDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = (e: MediaQueryListEvent) => setSystemDark(e.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
const dark = mode === 'dark' || (mode === 'system' && systemDark)
const configProps = useShadcnTheme(dark)
return (
<ConfigProvider locale={zhCN} {...configProps}>
<AntdApp>{children}</AntdApp>
</ConfigProvider>
)
}
// 登录守卫:401 统一跳转 /admin/login(§8.2)
function RequireAuth({ children }: { children: ReactNode }) {
const { authenticated, loading } = useAuth()
@@ -30,24 +55,26 @@ function RequireAuth({ children }: { children: ReactNode }) {
export default function App() {
return (
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
<Routes>
<Route element={<BlogLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes/:slug" element={<Note />} />
<Route path="/tags" element={<TagPage />} />
<Route path="/tags/:tag" element={<TagPage />} />
</Route>
<Route path="/admin/login" element={<Login />} />
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
<Route path="/admin" element={<AdminList />} />
<Route path="/admin/trash" element={<AdminTrash />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/notes/new" element={<AdminEdit />} />
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
<AntdProvider>
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
<Routes>
<Route element={<BlogLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes/:slug" element={<Note />} />
<Route path="/tags" element={<TagPage />} />
<Route path="/tags/:tag" element={<TagPage />} />
</Route>
<Route path="/admin/login" element={<Login />} />
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
<Route path="/admin" element={<AdminList />} />
<Route path="/admin/trash" element={<AdminTrash />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/notes/new" element={<AdminEdit />} />
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AntdProvider>
)
}
+111 -87
View File
@@ -1,63 +1,50 @@
import { useEffect, useRef, useState } from 'react'
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, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import { useSiteTitle } from '../features/site'
import { useTheme } from '../lib/theme'
import { useAuth } from '../lib/auth'
/* ── 溢出菜单:触发按钮 + 下拉面板(点击外部 / Esc / 选中项关闭) ── */
/* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
function MoreMenu({ triggerClassName, triggerLabel, children }: {
function MoreMenu({ triggerClassName, triggerLabel, items }: {
triggerClassName: string
triggerLabel: string
children: React.ReactNode
items: MenuProps['items']
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('pointerdown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div className="pn-moremenu" ref={ref}>
<button
type="button"
className={triggerClassName}
aria-haspopup="menu"
aria-expanded={open}
aria-label={triggerLabel}
onClick={() => setOpen((v) => !v)}
>
<MoreHorizontal size={15} />
</button>
{open && (
<div className="pn-moremenu-panel" role="menu" onClick={() => setOpen(false)}>
{children}
</div>
)}
// wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
// 曾致桌面导航整体横移 4px)
<div className="pn-moremenu">
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
<MoreHorizontal size={15} />
</button>
</Dropdown>
</div>
)
}
/* 菜单内路由项:激活态样式见 index.css 的 .pn-dd-item-active 桥接规则 */
function moreNavItem(to: string, end: boolean, icon: React.ReactNode, label: string, active: boolean): NonNullable<MenuProps['items']>[number] {
return {
key: to,
className: active ? 'pn-dd-item-active' : undefined,
label: (
<NavLink to={to} end={end} className="pn-dd-link">
{icon}
{label}
</NavLink>
),
}
}
/* ── 博客端:居中单栏 720px 布局(博客系列设计稿) ─────────── */
function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
className?: string
tip?: 'native' | 'css'
}) {
function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) {
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
@@ -65,8 +52,7 @@ function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
<button
type="button"
className={className}
title={tip === 'native' ? title : undefined}
data-tip={tip === 'css' ? title : undefined}
title={title}
aria-label={title}
onClick={() => setMode(next)}
>
@@ -78,6 +64,7 @@ function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
export function BlogLayout() {
const { data } = useSiteTitle()
const title = data?.site_title ?? 'Pure Note'
const location = useLocation()
return (
<div className="pn-site-page">
<header className="pn-site-header">
@@ -95,14 +82,14 @@ export function BlogLayout() {
</NavLink>
<ThemeButton />
{/* <640px 首页/标签收进溢出菜单 */}
<MoreMenu triggerClassName="pn-site-navlink pn-site-menu-btn" triggerLabel="更多导航">
<NavLink to="/" end className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}>
<House size={15} />首页
</NavLink>
<NavLink to="/tags" className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}>
<Tags size={15} />标签
</NavLink>
</MoreMenu>
<MoreMenu
triggerClassName="pn-site-navlink pn-site-menu-btn"
triggerLabel="更多导航"
items={[
moreNavItem('/', true, <House size={15} />, '首页', location.pathname === '/'),
moreNavItem('/tags', false, <Tags size={15} />, '标签', location.pathname.startsWith('/tags')),
]}
/>
</nav>
</div>
</header>
@@ -135,6 +122,24 @@ export function BlogLayout() {
/* ── 管理端:sidebar + topbar + 移动端 tabbar(dashboard 设计稿) ── */
function AdminThemeButton() {
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<Tooltip title={label} placement="bottom">
<button
type="button"
className="pn-admin-iconbtn pn-admin-desktop-act"
aria-label={label}
onClick={() => setMode(next)}
>
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
</button>
</Tooltip>
)
}
export function AdminTopbar({ title, children }: { title: string; children?: React.ReactNode }) {
const { logout } = useAuth()
const navigate = useNavigate()
@@ -146,24 +151,41 @@ export function AdminTopbar({ title, children }: { title: string; children?: Rea
<h1>{title}</h1>
<div className="pn-admin-actions">
{children}
<ThemeButton className="pn-admin-iconbtn tip-below pn-admin-desktop-act" tip="css" />
<AdminThemeButton />
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
<MoreMenu triggerClassName="pn-admin-iconbtn pn-admin-mobile-act" triggerLabel="更多操作">
<button type="button" className="pn-moremenu-item" onClick={() => setMode(next)}>
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
主题:{modeLabel}
</button>
<a href="/" target="_blank" rel="noreferrer" className="pn-moremenu-item">
<ExternalLink size={15} />返回博客
</a>
<button
type="button"
className="pn-moremenu-item"
onClick={() => void logout().then(() => navigate('/'))}
>
<LogOut size={15} />退出登录
</button>
</MoreMenu>
<MoreMenu
triggerClassName="pn-admin-iconbtn pn-admin-mobile-act"
triggerLabel="更多操作"
items={[
{
key: 'theme',
onClick: () => setMode(next),
label: (
<>
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
主题:{modeLabel}
</>
),
},
{
key: 'blog',
label: (
<a href="/" target="_blank" rel="noreferrer">
<ExternalLink size={15} />返回博客
</a>
),
},
{
key: 'logout',
onClick: () => void logout().then(() => navigate('/')),
label: (
<>
<LogOut size={15} />退出登录
</>
),
},
]}
/>
</div>
</header>
)
@@ -220,25 +242,27 @@ function AdminSidebar() {
<span className="pn-admin-user-role">站点所有者</span>
</span>
<span className="pn-admin-user-actions">
<a
href="/"
target="_blank"
rel="noreferrer"
className="pn-admin-iconbtn"
data-tip="返回博客"
aria-label="返回博客(新窗口打开)"
>
<ExternalLink size={15} />
</a>
<button
type="button"
className="pn-admin-iconbtn"
data-tip="退出登录"
aria-label="退出登录"
onClick={() => void logout().then(() => navigate('/'))}
>
<LogOut size={15} />
</button>
<Tooltip title="返回博客" placement="bottom">
<a
href="/"
target="_blank"
rel="noreferrer"
className="pn-admin-iconbtn"
aria-label="返回博客(新窗口打开)"
>
<ExternalLink size={15} />
</a>
</Tooltip>
<Tooltip title="退出登录" placement="bottom">
<button
type="button"
className="pn-admin-iconbtn"
aria-label="退出登录"
onClick={() => void logout().then(() => navigate('/'))}
>
<LogOut size={15} />
</button>
</Tooltip>
</span>
</div>
</div>
-30
View File
@@ -1,30 +0,0 @@
import { useEffect } from 'react'
import { CircleCheck, TriangleAlert } from 'lucide-react'
/**
* Toast:右下角操作反馈(设计稿 toast 契约)。
* message 为 null 时隐藏;展示 2.6s 后调用 onDone 清除。
*/
export default function Toast({
message,
kind = 'success',
onDone,
}: {
message: string | null
kind?: 'success' | 'error'
onDone: () => void
}) {
useEffect(() => {
if (!message) return
const t = setTimeout(onDone, 2600)
return () => clearTimeout(t)
}, [message, onDone])
if (!message) return null
return (
<div className={`pn-toast${kind === 'error' ? ' is-error' : ''}`} role="status">
{kind === 'error' ? <TriangleAlert size={16} /> : <CircleCheck size={16} />}
<span>{message}</span>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { App } from 'antd'
/**
* 操作反馈:基于 antd notification(经 ConfigProvider 主题 + App 上下文)。
* 保留旧 toast 契约——成功/错误两种、2.6s 自动消失、右下角(移动端底部通栏)。
*/
type ToastInput = {
description: string
variant?: 'success' | 'error'
}
export function useToast() {
const notification = App.useApp().notification
return {
toast: ({ description, variant }: ToastInput) => {
const notify = variant === 'error' ? notification.error : notification.success
notify({
message: '',
description,
placement: 'bottomRight',
duration: 2.6,
})
},
}
}
+219 -975
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,7 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ')
}
export function formatDate(unix: number): string {
return new Date(unix * 1000).toLocaleDateString('zh-CN', {
year: 'numeric',
+68 -68
View File
@@ -5,13 +5,14 @@ import {
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote,
Save, Table, Trash2, X,
} from 'lucide-react'
import { Button, Card, Divider, Input, Switch, Tag } from 'antd'
import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import MarkdownViewer from '../components/MarkdownViewer'
import Toast from '../components/Toast'
import { AdminTopbar } from '../components/Layout'
import { useToast } from '@/hooks/use-toast'
import { formatDateISO, slugPreview } from '../lib/utils'
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
@@ -50,6 +51,7 @@ export default function AdminEdit() {
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
const navigate = useNavigate()
const qc = useQueryClient()
const { toast } = useToast()
const { data: existing, isLoading } = useQuery({
queryKey: ['admin', 'note', noteId],
@@ -64,7 +66,6 @@ export default function AdminEdit() {
const [showPreview, setShowPreview] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [tagInput, setTagInput] = useState<string | null>(null)
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
const dirtyRef = useRef(false)
const slugTouchedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -121,7 +122,7 @@ export default function AdminEdit() {
setSlugFieldError(null)
setErrorMsg(null)
dirtyRef.current = false
setToast({ msg: vars.auto ? '已自动保存' : '已保存', kind: 'success' })
toast({ description: vars.auto ? '已自动保存' : '已保存' })
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
@@ -131,7 +132,7 @@ export default function AdminEdit() {
}
},
onError: (e) => {
setToast({ msg: '保存失败,请重试', kind: 'error' })
toast({ description: '保存失败,请重试', variant: 'error' })
if (e instanceof ApiError && e.code === 'slug_conflict') {
setSlugFieldError(e.message)
} else {
@@ -147,7 +148,7 @@ export default function AdminEdit() {
invalidatePublic(qc)
navigate('/admin')
},
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const canSave = form.title.trim().length > 0
@@ -182,7 +183,7 @@ export default function AdminEdit() {
return (
<>
<AdminTopbar title="编辑笔记" />
<div className="pn-admin-content"><div className="table-card empty">加载中…</div></div>
<div className="pn-admin-content"><div className="pn-empty">加载中…</div></div>
</>
)
}
@@ -192,29 +193,28 @@ export default function AdminEdit() {
return (
<>
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
<button
type="button"
className="btn ghost"
<Button
type="text"
icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
onClick={() => setShowPreview((v) => !v)}
>
{showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
<span>{showPreview ? '隐藏预览' : '预览'}</span>
</button>
<button
type="button"
className="btn primary"
{showPreview ? '隐藏预览' : '预览'}
</Button>
<Button
type="primary"
icon={<Save size={15} />}
disabled={!canSave || save.isPending}
onClick={() => save.mutate({ f: form, auto: false })}
>
<Save size={15} />
<span>{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}</span>
</button>
{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
</Button>
</AdminTopbar>
<div className="pn-admin-content">
<div className="pn-ae-grid">
<article className="card pn-ae-editor">
<input
<Card className="pn-ae-main min-w-0">
<Input
variant="borderless"
className="pn-ae-title-input"
type="text"
value={form.title}
@@ -230,24 +230,26 @@ export default function AdminEdit() {
</span>
<div className="pn-ae-tags">
{form.tags.map((t) => (
<span key={t} className="pill pn-ae-tag">
<Tag
key={t}
closable
closeIcon={<X size={12} />}
onClose={(e) => {
e.preventDefault()
set('tags', form.tags.filter((x) => x !== t))
}}
aria-label={`标签:${t}`}
>
{t}
<button
type="button"
className="pn-ae-tag-x"
aria-label={`移除标签:${t}`}
onClick={() => set('tags', form.tags.filter((x) => x !== t))}
>
<X size={12} />
</button>
</span>
</Tag>
))}
{tagInput === null ? (
<button type="button" className="btn ghost pn-ae-add-tag" onClick={() => setTagInput('')}>
<Button type="text" className="pn-ae-add-tag" onClick={() => setTagInput('')}>
<CirclePlus size={13} /><span>添加标签</span>
</button>
</Button>
) : (
<input
<Input
variant="borderless"
className="pn-ae-tag-input"
autoFocus
value={tagInput}
@@ -268,18 +270,18 @@ export default function AdminEdit() {
</div>
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
<button type="button" className="btn ghost pn-ae-tool" aria-label="加粗" title="加粗" onClick={() => insert('**文本**')}><Bold size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="斜体" title="斜体" onClick={() => insert('*文本*')}><Italic size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入链接" title="插入链接" onClick={() => insert('[标题](https://)')}><Link2 size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入代码" title="插入代码" onClick={() => insert('\n```\n代码\n```\n')}><Code size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="无序列表" title="无序列表" onClick={() => insert('\n- 列表项\n')}><List size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="引用" title="引用" onClick={() => insert('\n> 引用\n')}><Quote size={15} /></button>
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入表格" title="插入表格" onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')}><Table size={15} /></button>
<Button type="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
<Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
<Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
<Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
<Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
</div>
<div className="pn-ae-body-wrap">
<Suspense fallback={<div className="table-card empty" style={{ minHeight: 420 }}>编辑器加载中…</div>}>
<Suspense fallback={<div className="pn-empty" style={{ minHeight: 420 }}>编辑器加载中…</div>}>
<Editor
value={form.content}
onChange={(v) => set('content', v)}
@@ -295,11 +297,11 @@ export default function AdminEdit() {
<MarkdownViewer source={form.content} />
</div>
)}
</article>
</Card>
<aside className="pn-ae-side">
<section className="card pn-ae-publish">
<span className="title">发布设置</span>
<Card styles={{ body: { padding: 20 } }}>
<h3 className="pn-ae-side-title">发布设置</h3>
<div className="pn-ae-prow">
<span className="pn-ae-plabel">状态</span>
@@ -309,25 +311,19 @@ export default function AdminEdit() {
>
<span className="dot" />{form.status === 'public' ? '公开' : '私有'}
</span>
<button
type="button"
role="switch"
className="pn-switch"
aria-checked={form.status === 'public'}
<Switch
checked={form.status === 'public'}
onChange={(checked) => set('status', checked ? 'public' : 'private')}
aria-label="公开笔记"
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
/>
</div>
<div className="pn-ae-prow">
<span className="pn-ae-plabel">置顶</span>
<button
type="button"
role="switch"
className="pn-switch"
aria-checked={form.pinned}
<Switch
checked={form.pinned}
onChange={(checked) => set('pinned', checked)}
aria-label="置顶笔记"
onClick={() => set('pinned', !form.pinned)}
/>
</div>
@@ -339,14 +335,15 @@ export default function AdminEdit() {
</div>
<div className="pn-ae-pfield">
<label htmlFor="pn-ae-slug-input">固定链接</label>
<input
<label className="pn-field-label text-xs" htmlFor="pn-ae-slug-input">固定链接</label>
<Input
id="pn-ae-slug-input"
className={`pn-ae-input${slugFieldError ? ' is-error' : ''}`}
type="text"
value={form.slug}
spellCheck={false}
status={slugFieldError ? 'error' : undefined}
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
styles={{ input: { fontFamily: 'var(--font-mono)', fontSize: 12.5, lineHeight: 1.6 } }}
onChange={(e) => set('slug', e.target.value)}
/>
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
@@ -354,11 +351,11 @@ export default function AdminEdit() {
</div>
<div className="pn-ae-pfield">
<label htmlFor="pn-ae-summary">摘要</label>
<textarea
<label className="pn-field-label text-xs" htmlFor="pn-ae-summary">摘要</label>
<Input.TextArea
id="pn-ae-summary"
className="pn-ae-input pn-ae-textarea"
rows={3}
styles={{ textarea: { fontSize: 12.5, lineHeight: 1.6 } }}
value={form.summary}
placeholder="留空自动截取正文"
onChange={(e) => set('summary', e.target.value)}
@@ -369,19 +366,22 @@ export default function AdminEdit() {
{!isNew && (
<>
<hr className="divider pn-ae-divider" />
<button type="button" className="pn-ae-danger" disabled={trashMutation.isPending} onClick={() => trashMutation.mutate()}>
<Divider className="pn-ae-divider" />
<Button
className="pn-btn-danger-outline"
style={{ width: '100%' }}
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate()}
>
<Trash2 size={15} />
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span>
</button>
</Button>
</>
)}
</section>
</Card>
</aside>
</div>
</div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</>
)
}
+173 -148
View File
@@ -1,14 +1,16 @@
import { Link } from 'react-router'
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 { useMemo, useState } from 'react'
import { Button, Card, Input, Modal, Segmented, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast'
import { useToast } from '@/hooks/use-toast'
type StatusFilter = 'all' | 'public' | 'private'
@@ -23,20 +25,21 @@ export default function AdminList() {
useDocumentTitle('笔记管理')
const { data, isLoading } = useAdminNotes()
const qc = useQueryClient()
const navigate = useNavigate()
const { toast } = useToast()
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
const [filter, setFilter] = useState<StatusFilter>('all')
const [query, setQuery] = useState('')
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
const trashMutation = useMutation({
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
onSuccess: () => {
setConfirmNote(null)
setToast({ msg: '笔记已移至回收站', kind: 'success' })
toast({ description: '笔记已移至回收站' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const toggleStatus = useMutation({
@@ -57,11 +60,11 @@ export default function AdminList() {
})
},
onSuccess: (_d, note) => {
setToast({ msg: note.status === 'public' ? '已转为私有' : '已转为公开', kind: 'success' })
toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const items = data?.items ?? []
@@ -81,12 +84,97 @@ export default function AdminList() {
})
}, [items, filter, query])
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
render: (_, n) => (
<>
<Link
to={`/admin/notes/${n.id}/edit`}
className="pn-al-note-title"
style={{ textDecoration: 'none' }}
>
{n.pinned && <Pin size={12} aria-label="置顶" />}
<span>{n.title}</span>
</Link>
<span className="pn-al-note-slug">/{n.slug}</span>
</>
),
},
{
title: '标签',
key: 'tags',
render: (_, n) =>
n.tags.length > 0 ? (
<div className="pn-al-tags">
{n.tags.map((t) => <Tag key={t}>{t}</Tag>)}
</div>
) : (
<span className="pn-al-note-slug">—</span>
),
},
{
title: '日期',
key: 'date',
render: (_, n) => <span className="pn-al-date">{formatDateISO(n.updated_at)}</span>,
},
{
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>
)
},
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => {
const isPublic = n.status === 'public'
return (
<div className="pn-al-actions-cell">
<Button
type="text"
icon={isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
title={isPublic ? '转为私有' : '转为公开'}
aria-label={isPublic ? '转为私有' : '转为公开'}
disabled={toggleStatus.isPending}
onClick={() => void toggleStatus.mutate(n)}
/>
<Button
type="text"
icon={<Pencil size={15} />}
title="编辑"
aria-label={`编辑笔记:${n.title}`}
onClick={() => void navigate(`/admin/notes/${n.id}/edit`)}
/>
<Button
type="text"
className="pn-btn-danger-outline"
icon={<Trash2 size={15} />}
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
onClick={() => setConfirmNote(n)}
/>
</div>
)
},
},
]
return (
<>
<AdminTopbar title="笔记管理">
<label className="pn-admin-search">
<Search size={14} aria-hidden />
<input
<Input
variant="borderless"
type="search"
placeholder="搜索笔记…"
aria-label="搜索笔记"
@@ -94,167 +182,104 @@ export default function AdminList() {
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<Link to="/admin/notes/new" className="btn primary">
<CirclePlus size={15} />
<span>新建笔记</span>
</Link>
<Button
type="primary"
icon={<CirclePlus size={15} />}
onClick={() => void navigate('/admin/notes/new')}
>
新建笔记
</Button>
</AdminTopbar>
<div className="pn-admin-content">
<section className="pn-al-metrics">
<article className="card metric">
<span className="eyebrow">笔记</span>
<strong className="value">{data?.total ?? items.length}</strong>
<span className="meta">全部笔记,含 {privateCount} 篇私有</span>
</article>
<article className="card metric">
<span className="eyebrow">已发布</span>
<strong className="value">{publicCount}</strong>
<span className="meta">对外公开的笔记</span>
</article>
<article className="card metric">
<span className="eyebrow">私有</span>
<strong className="value">{privateCount}</strong>
<span className="meta">尚未公开,仅自己可见</span>
</article>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">笔记</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{data?.total ?? items.length}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
全部笔记,含 {privateCount} 篇私有
</span>
</Card>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">已发布</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{publicCount}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
对外公开的笔记
</span>
</Card>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">私有</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{privateCount}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
尚未公开,仅自己可见
</span>
</Card>
</section>
<section className="table-card pn-al-table-card">
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
<div className="pn-al-table-head">
<nav className="tabs" aria-label="笔记筛选">
<button type="button" className={filter === 'all' ? 'active' : undefined} onClick={() => setFilter('all')}>
全部<span className="pn-al-tab-num">{items.length}</span>
</button>
<button type="button" className={filter === 'public' ? 'active' : undefined} onClick={() => setFilter('public')}>
已发布<span className="pn-al-tab-num">{publicCount}</span>
</button>
<button type="button" className={filter === 'private' ? 'active' : undefined} onClick={() => setFilter('private')}>
私有<span className="pn-al-tab-num">{privateCount}</span>
</button>
</nav>
<Segmented
value={filter}
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></> },
]}
/>
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
</div>
{isLoading ? (
<div className="empty">加载中…</div>
<div className="pn-empty">加载中…</div>
) : visible.length === 0 ? (
<div className="empty">
<div className="pn-empty">
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
</div>
) : (
<div className="pn-table-scroll">
<table className="table" aria-label="笔记列表">
<thead>
<tr>
<th>标题</th>
<th>标签</th>
<th>日期</th>
<th>状态</th>
<th className="pn-al-col-actions">操作</th>
</tr>
</thead>
<tbody>
{visible.map((n) => {
const isPublic = n.status === 'public'
return (
<tr key={n.id} className={isPublic ? undefined : 'pn-al-is-draft'}>
<td>
<Link to={`/admin/notes/${n.id}/edit`} className="pn-al-note-title" style={{ textDecoration: 'none' }}>
{n.pinned && <Pin size={12} aria-label="置顶" />}
<span>{n.title}</span>
</Link>
<span className="pn-al-note-slug">/{n.slug}</span>
</td>
<td>
{n.tags.length > 0 ? (
<div className="pn-al-tags">
{n.tags.map((t) => <span key={t} className="pn-al-tag">{t}</span>)}
</div>
) : (
<span className="pn-al-note-slug">—</span>
)}
</td>
<td><span className="pn-al-date">{formatDateISO(n.updated_at)}</span></td>
<td>
<span className={`pn-al-status ${isPublic ? 'pn-al-is-public' : 'pn-al-is-draft'}`}>
<span className="dot" />{isPublic ? '已发布' : '私有'}
</span>
</td>
<td>
<div className="pn-al-actions-cell">
<button
type="button"
className="btn ghost pn-icon-btn"
title={isPublic ? '转为私有' : '转为公开'}
aria-label={isPublic ? '转为私有' : '转为公开'}
disabled={toggleStatus.isPending}
onClick={() => void toggleStatus.mutate(n)}
>
{isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
<Link
to={`/admin/notes/${n.id}/edit`}
className="btn ghost pn-icon-btn"
title="编辑"
aria-label={`编辑笔记:${n.title}`}
>
<Pencil size={15} />
</Link>
<button
type="button"
className="btn ghost pn-icon-btn danger"
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
onClick={() => setConfirmNote(n)}
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</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')}
/>
)}
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
</div>
</section>
</Card>
</div>
{confirmNote && (
<div
className="pn-modal-overlay"
role="dialog"
aria-modal="true"
aria-label="移入回收站"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmNote(null)
}}
>
<div className="card pn-modal">
<h3>移入回收站?</h3>
<p>「{confirmNote.title}」将被软删除,30 天内可在回收站恢复。</p>
{trashMutation.isError && <p style={{ color: 'var(--destructive-text)' }}>操作失败,请重试</p>}
<div className="pn-modal-actions">
<button type="button" className="btn secondary" onClick={() => setConfirmNote(null)}>取消</button>
<button
type="button"
className="btn danger"
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate(confirmNote.id)}
>
{trashMutation.isPending ? '删除中…' : '确认删除'}
</button>
</div>
</div>
</div>
)}
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
<Modal
open={confirmNote !== null}
onCancel={() => setConfirmNote(null)}
title="移入回收站?"
width={380}
centered
maskClosable={false}
cancelText="取消"
okText={trashMutation.isPending ? '删除中…' : '确认删除'}
confirmLoading={trashMutation.isPending}
onOk={() => {
if (confirmNote) trashMutation.mutate(confirmNote.id)
}}
>
<p className="pn-modal-desc">
「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
</p>
{trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
</Modal>
</>
)
}
+64 -78
View File
@@ -1,16 +1,18 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react'
import { Button, Card, Divider, Input, InputNumber } from 'antd'
import { api, ApiError } from '../lib/api'
import type { SiteSettings } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast'
import { useToast } from '@/hooks/use-toast'
export default function AdminSettings() {
useDocumentTitle('设置')
const qc = useQueryClient()
const { toast } = useToast()
const { data } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => api<SiteSettings>('/api/admin/settings'),
@@ -20,7 +22,6 @@ export default function AdminSettings() {
const [desc, setDesc] = useState('')
const [beian, setBeian] = useState('')
const [pageSize, setPageSize] = useState(10)
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
useEffect(() => {
if (data) {
@@ -38,59 +39,56 @@ export default function AdminSettings() {
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize }),
}),
onSuccess: () => {
setToast({ msg: '设置已保存', kind: 'success' })
toast({ description: '设置已保存' })
void qc.invalidateQueries({ queryKey: ['site'] })
invalidatePublic(qc) // page_size 等设置影响公共列表
},
onError: (e) => setToast({ msg: e instanceof ApiError ? e.message : '保存失败', kind: 'error' }),
onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
})
return (
<>
<AdminTopbar title="站点设置">
<button
type="button"
className="btn primary"
<Button
type="primary"
icon={<Save size={15} />}
disabled={saveSettings.isPending}
onClick={() => saveSettings.mutate()}
>
<Save size={15} />
{saveSettings.isPending ? '保存中…' : '保存更改'}
</button>
</Button>
</AdminTopbar>
<div className="pn-admin-content pn-as-content">
<section className="card pn-as-card">
<Card className="pn-as-card">
<div className="pn-as-card-head">
<h2 className="title">站点信息</h2>
<p className="meta">这些信息会显示在博客的公开页面</p>
<h3 className="pn-as-card-title">站点信息</h3>
<p className="pn-as-card-desc">这些信息会显示在博客的公开页面</p>
</div>
<hr className="divider" />
<Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields">
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="site-title">站点名称</label>
<input
<label htmlFor="site-title" className="pn-field-label">站点名称</label>
<Input
id="site-title"
className="pn-as-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="site-desc">站点描述</label>
<textarea
<label htmlFor="site-desc" className="pn-field-label">站点描述</label>
<Input.TextArea
id="site-desc"
className="pn-as-input"
rows={3}
styles={{ textarea: { minHeight: 76, padding: '8px 12px', lineHeight: 1.7 } }}
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>
</div>
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="beian-no">公安备案号</label>
<input
<label htmlFor="beian-no" className="pn-field-label">公安备案号</label>
<Input
id="beian-no"
className="pn-as-input"
placeholder="如:京公网安备11000000000000号"
value={beian}
onChange={(e) => setBeian(e.target.value)}
@@ -98,25 +96,21 @@ export default function AdminSettings() {
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
</div>
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="page-size">首页每页条数(1–100)</label>
<input
<label htmlFor="page-size" className="pn-field-label">首页每页条数(1–100)</label>
<InputNumber
id="page-size"
className="pn-as-input"
type="number"
min={1}
max={100}
style={{ width: 140 }}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
onChange={(v) => setPageSize(Number(v ?? 10))}
/>
</div>
</div>
</section>
</Card>
<ChangePassword onToast={setToast} />
<ChangePassword onToast={toast} />
</div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</>
)
}
@@ -124,7 +118,7 @@ export default function AdminSettings() {
function ChangePassword({
onToast,
}: {
onToast: (t: { msg: string; kind: 'success' | 'error' } | null) => void
onToast: ReturnType<typeof useToast>['toast']
}) {
const [oldPw, setOldPw] = useState('')
const [newPw, setNewPw] = useState('')
@@ -137,85 +131,77 @@ function ChangePassword({
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
}),
onSuccess: () => {
onToast({ msg: '密码已修改', kind: 'success' })
onToast({ description: '密码已修改' })
setOldPw('')
setNewPw('')
},
onError: (e) => {
if (e instanceof ApiError) {
onToast({
msg:
description:
e.code === 'weak_password'
? '新密码长度至少 12 个字符'
: e.status === 429
? '尝试过于频繁,请稍后再试'
: '旧密码不正确',
kind: 'error',
variant: 'error',
})
} else {
onToast({ msg: '修改失败', kind: 'error' })
onToast({ description: '修改失败', variant: 'error' })
}
},
})
return (
<section className="card pn-as-card">
<Card className="pn-as-card">
<div className="pn-as-card-head">
<h2 className="title">修改密码</h2>
<p className="meta">更新管理后台的登录密码</p>
<h3 className="pn-as-card-title">修改密码</h3>
<p className="pn-as-card-desc">更新管理后台的登录密码</p>
</div>
<hr className="divider" />
<Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields">
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="old-pw">旧密码</label>
<div className="pn-as-control">
<Lock size={15} className="pn-as-leading-icon" aria-hidden />
<input
id="old-pw"
className="pn-as-input"
type={showPw ? 'text' : 'password'}
autoComplete="current-password"
value={oldPw}
onChange={(e) => setOldPw(e.target.value)}
/>
</div>
<label htmlFor="old-pw" className="pn-field-label">旧密码</label>
<Input
id="old-pw"
type={showPw ? 'text' : 'password'}
autoComplete="current-password"
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
value={oldPw}
onChange={(e) => setOldPw(e.target.value)}
/>
</div>
<div className="pn-as-field">
<label className="pn-as-label" htmlFor="new-pw">新密码(≥12 字符)</label>
<div className="pn-as-control has-toggle">
<Lock size={15} className="pn-as-leading-icon" aria-hidden />
<input
id="new-pw"
className="pn-as-input"
type={showPw ? 'text' : 'password'}
autoComplete="new-password"
placeholder="请输入新密码,至少 12 个字符"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
/>
<button
type="button"
className="pn-as-eye"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
>
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
<label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
<Input
id="new-pw"
type={showPw ? 'text' : 'password'}
autoComplete="new-password"
placeholder="请输入新密码,至少 12 个字符"
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
suffix={
<Button
type="text"
size="small"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
icon={showPw ? <EyeOff size={15} /> : <Eye size={15} />}
/>
}
/>
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
</div>
</div>
<div className="pn-as-card-foot">
<button
type="button"
className="btn secondary"
<Button
size="small"
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
onClick={() => change.mutate()}
>
<KeyRound size={15} />
{change.isPending ? '提交中…' : '修改密码'}
</button>
</Button>
</div>
</section>
</Card>
)
}
+72 -59
View File
@@ -1,18 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Alert, Button, Card, Table } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { daysRemaining, formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast'
import { useToast } from '@/hooks/use-toast'
export default function AdminTrash() {
useDocumentTitle('回收站')
const qc = useQueryClient()
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
const { toast } = useToast()
const { data, isLoading } = useQuery({
queryKey: ['admin', 'trash'],
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
@@ -21,83 +22,95 @@ export default function AdminTrash() {
const restore = useMutation({
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
onSuccess: () => {
setToast({ msg: '笔记已恢复', kind: 'success' })
toast({ description: '笔记已恢复' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => setToast({ msg: '恢复失败,请重试', kind: 'error' }),
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
})
const items = data?.items ?? []
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
className: 'pn-at-title-cell',
render: (_, n) => (
<>
<span className="pn-at-note-title">{n.title}</span>
<span className="pn-at-note-slug">/{n.slug}</span>
</>
),
},
{
title: '删除时间',
key: 'deleted',
render: (_, n) => (
<span className="pn-at-mono">{n.deleted_at ? formatDateISO(n.deleted_at) : '—'}</span>
),
},
{
title: '剩余天数',
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>
},
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => (
<div className="pn-at-actions">
<Button
type="text"
size="small"
icon={<RotateCcw size={14} />}
disabled={restore.isPending}
onClick={() => restore.mutate(n.id)}
>
恢复
</Button>
</div>
),
},
]
return (
<>
<AdminTopbar title="回收站" />
<div className="pn-admin-content">
<div className="pn-at-alert" role="note">
<Info size={16} />
<p>回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。</p>
</div>
<Alert
role="note"
type="info"
showIcon
message="回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。"
/>
<section className="table-card pn-at-table-card">
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
{isLoading ? (
<div className="empty">加载中…</div>
<div className="pn-empty">加载中…</div>
) : items.length === 0 ? (
<div className="empty">回收站是空的</div>
<div className="pn-empty">回收站是空的</div>
) : (
<div className="pn-table-scroll">
<table className="table" aria-label="回收站笔记列表">
<thead>
<tr>
<th>标题</th>
<th>删除时间</th>
<th>剩余天数</th>
<th className="pn-al-col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map((n) => {
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
return (
<tr key={n.id}>
<td className="pn-at-title-cell">
<span className="pn-at-note-title">{n.title}</span>
<span className="pn-at-note-slug">/{n.slug}</span>
</td>
<td>
<span className="pn-at-mono">{n.deleted_at ? formatDateISO(n.deleted_at) : '—'}</span>
</td>
<td>
<span className={`pn-at-days${left <= 3 ? ' pn-at-days-urgent' : ''}`}>{left} 天</span>
</td>
<td>
<div className="pn-at-actions">
<button
type="button"
className="btn ghost"
disabled={restore.isPending}
onClick={() => restore.mutate(n.id)}
>
<RotateCcw size={14} />恢复
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Table
className="pn-al-table"
columns={columns}
dataSource={items}
rowKey="id"
pagination={false}
scroll={{ x: 560 }}
/>
)}
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
</div>
</section>
</Card>
</div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</>
)
}
+25 -35
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, ChevronLeft, ChevronRight, Pin } from 'lucide-react'
import { ArrowRight, Pin } from 'lucide-react'
import { Pagination, Tag } from 'antd'
import { api } from '../lib/api'
import type { NoteItem, NoteList, TagCount } from '../lib/api'
import { useDocumentTitle } from '../features/site'
@@ -43,7 +44,9 @@ function NotePost({ note }: { note: NoteItem }) {
{note.tags.length > 0 && (
<div className="pn-home-tags">
{note.tags.map((t) => (
<Link key={t} to={`/tags/${encodeURIComponent(t)}`} className="pn-home-tag">{t}</Link>
<Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))}
</div>
)}
@@ -51,6 +54,11 @@ function NotePost({ note }: { note: NoteItem }) {
)
}
// 首页标签筛选:链接型分段导航(路由跳转而非受控切换,故用原生导航标记;
// 链接配色由 .pn-home-filter a 的无 layer 规则承接,见 index.css)
const filterStripCls =
'inline-flex min-w-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-muted p-1'
export default function Home() {
const [page, setPage] = useState(1)
useDocumentTitle()
@@ -62,10 +70,14 @@ export default function Home() {
return (
<div className="pn-home-content">
{topTags.length > 0 && (
<nav className="tabs pn-home-filter" aria-label="按标签筛选笔记">
<Link to="/" className={page === 1 ? 'active' : undefined}>全部</Link>
<nav className={`pn-home-filter ${filterStripCls}`} aria-label="按标签筛选笔记">
<Link to="/" aria-current={page === 1 ? 'page' : undefined}>
全部
</Link>
{topTags.map((t) => (
<Link key={t.name} to={`/tags/${encodeURIComponent(t.name)}`}>{t.name}</Link>
<Link key={t.name} to={`/tags/${encodeURIComponent(t.name)}`}>
{t.name}
</Link>
))}
</nav>
)}
@@ -85,36 +97,14 @@ export default function Home() {
<span className="pn-home-pager-note">
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页
</span>
<div className="pn-pager">
<button
type="button"
className="btn ghost"
aria-label="上一页"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
type="button"
className={`pn-page-btn${p === page ? ' is-active' : ''}`}
onClick={() => setPage(p)}
>
{p}
</button>
))}
<button
type="button"
className="btn ghost"
aria-label="下一页"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</button>
</div>
<Pagination
size="small"
current={page}
pageSize={data?.page_size ?? 10}
total={data?.total ?? 0}
showSizeChanger={false}
onChange={(p) => setPage(p)}
/>
</nav>
)}
</div>
+59 -47
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { ArrowLeft, ArrowRight, CircleAlert, Eye, EyeOff, Lock } from 'lucide-react'
import { useNavigate } from 'react-router'
import { ArrowLeft, ArrowRight, Eye, EyeOff, Lock } from 'lucide-react'
import { Alert, Button, Card, Input } from 'antd'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { useDocumentTitle } from '../features/site'
@@ -45,28 +46,28 @@ export default function Login() {
<span className="pn-login-wordmark">Pure Note</span>
</div>
<form className="card pn-login-card" onSubmit={onSubmit}>
<h1 className="pn-login-title">管理员登录</h1>
<p className="pn-login-sub">请输入管理密码以继续</p>
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
<form onSubmit={onSubmit}>
<h1 className="pn-login-title">管理员登录</h1>
<p className="pn-login-sub">请输入管理密码以继续</p>
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
<input
id="pn-login-username"
className="sr-only"
type="text"
name="username"
autoComplete="username"
value="admin"
readOnly
tabIndex={-1}
/>
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
<div className="pn-login-field">
<Lock size={16} className="pn-login-icon" aria-hidden />
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
<input
id="pn-login-username"
className="sr-only"
type="text"
name="username"
autoComplete="username"
value="admin"
readOnly
tabIndex={-1}
/>
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
<Input
id="pn-login-password"
className="pn-login-input"
size="large"
variant="outlined"
type={showPw ? 'text' : 'password'}
name="password"
placeholder="请输入管理密码"
@@ -75,36 +76,47 @@ export default function Login() {
required
value={password}
onChange={(e) => setPassword(e.target.value)}
prefix={<Lock size={16} className="pn-login-lock" aria-hidden />}
suffix={
<Button
type="text"
size="small"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
icon={showPw ? <EyeOff size={16} /> : <Eye size={16} />}
/>
}
/>
<button
type="button"
className="pn-login-eye"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
{error && (
<Alert role="alert" type="error" showIcon message={error} className="pn-login-alert" />
)}
<Button
htmlType="submit"
type="primary"
size="large"
block
className="mt-5"
disabled={submitting || password.length === 0}
>
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<span>{submitting ? '登录中…' : '登录'}</span>
<ArrowRight size={16} />
</Button>
{error && (
<div className="pn-login-error" role="alert">
<CircleAlert size={15} />
<span>{error}</span>
</div>
)}
<p className="pn-login-hint">连续失败将被暂时锁定</p>
</form>
</Card>
<button type="submit" className="btn primary pn-login-submit" disabled={submitting || password.length === 0}>
<span>{submitting ? '登录中…' : '登录'}</span>
<ArrowRight size={16} />
</button>
<p className="pn-login-hint">连续失败将被暂时锁定</p>
</form>
<Link to="/" className="btn ghost pn-login-back">
<ArrowLeft size={14} />
<span>返回博客</span>
</Link>
<Button
type="text"
size="small"
className="pn-login-back"
icon={<ArrowLeft size={14} />}
onClick={() => navigate('/')}
>
返回博客
</Button>
</div>
)
}
+5 -4
View File
@@ -1,19 +1,20 @@
import { Link } from 'react-router'
import { useNavigate } from 'react-router'
import { ArrowLeft } from 'lucide-react'
import { Button } from 'antd'
import { useDocumentTitle } from '../features/site'
export default function NotFound() {
useDocumentTitle('页面不存在')
const navigate = useNavigate()
return (
<div className="pn-404-page">
<p className="pn-404-code">404</p>
<h1 className="pn-404-title">页面不存在</h1>
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
<div className="pn-404-actions">
<Link to="/" className="btn primary">
<ArrowLeft size={16} />
<Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
返回首页
</Link>
</Button>
</div>
</div>
)
+34 -16
View File
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router'
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 { api } from '../lib/api'
import type { NoteDetail } from '../lib/api'
import { useAuth } from '../lib/auth'
@@ -10,6 +11,7 @@ import { formatDateISO, readingMinutes } from '../lib/utils'
export default function Note() {
const { slug = '' } = useParams()
const navigate = useNavigate()
const { authenticated } = useAuth()
const { data: note, isLoading, error } = useQuery({
queryKey: ['note', slug],
@@ -24,7 +26,9 @@ export default function Note() {
<div className="pn-note-article">
<p className="pn-home-list-empty">笔记不存在或未公开</p>
<p style={{ textAlign: 'center', marginTop: -40 }}>
<Link to="/" className="btn ghost pn-note-back"><ArrowLeft size={14} />返回首页</Link>
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
返回首页
</Button>
</p>
</div>
)
@@ -34,15 +38,23 @@ export default function Note() {
return (
<article className="pn-note-article">
<Link to="/" className="btn ghost pn-note-back">
<ArrowLeft size={14} />返回首页
</Link>
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
返回首页
</Button>
{privatePreview && authenticated && (
<div className="pn-note-private" role="note">
<Eye size={15} />
私有预览:此笔记未公开,仅管理员可见
</div>
<Alert
role="note"
type="info"
showIcon={false}
className="pn-note-preview-alert"
message={
<>
<Eye size={15} />
私有预览:此笔记未公开,仅管理员可见
</>
}
/>
)}
<h1 className="pn-note-title">{note.title}</h1>
@@ -57,7 +69,9 @@ export default function Note() {
<>
<span className="pn-note-meta-sep">·</span>
{note.tags.map((t) => (
<Link key={t} to={`/tags/${encodeURIComponent(t)}`} className="pn-note-tag">{t}</Link>
<Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))}
</>
)}
@@ -67,17 +81,21 @@ export default function Note() {
<nav className="pn-note-postnav" aria-label="笔记导航">
{note.prev ? (
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="card pn-note-nav-card">
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
<span className="pn-note-nav-title">{note.prev.title}</span>
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="pn-note-nav-link">
<Card className="pn-note-nav-card" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
<span className="pn-note-nav-title">{note.prev.title}</span>
</Card>
</Link>
) : (
<span />
)}
{note.next ? (
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="card pn-note-nav-card pn-note-nav-next">
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
<span className="pn-note-nav-title">{note.next.title}</span>
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="pn-note-nav-link">
<Card className="pn-note-nav-card pn-note-nav-next" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
<span className="pn-note-nav-title">{note.next.title}</span>
</Card>
</Link>
) : (
<span />
+13 -12
View File
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Tag } from 'lucide-react'
import { Tag as AntTag } from 'antd'
import { api } from '../lib/api'
import type { NoteList, TagCount } from '../lib/api'
import { useDocumentTitle } from '../features/site'
@@ -33,22 +34,22 @@ export default function TagPage() {
<nav className="pn-tag-cloud" aria-label="全部标签">
{tagsData?.tags.map((t) => (
<Link
key={t.name}
to={`/tags/${encodeURIComponent(t.name)}`}
className={`pn-tag-pill${t.name === tag ? ' active' : ''}`}
aria-current={t.name === tag ? 'true' : undefined}
>
<Tag size={14} />
<span>{t.name}</span>
<span className="pn-tag-count">{t.count}</span>
</Link>
<AntTag.CheckableTag key={t.name} checked={t.name === tag} className="pn-cloud-tag">
<Link
to={`/tags/${encodeURIComponent(t.name)}`}
aria-current={t.name === tag ? 'true' : undefined}
>
<Tag size={14} />
<span>{t.name}</span>
<span className="pn-cloud-count">{t.count}</span>
</Link>
</AntTag.CheckableTag>
))}
{!tagsData && (
<span className="pn-tag-count" style={{ padding: 8 }}>加载中…</span>
<span className="p-2 font-mono text-xs text-muted-foreground">加载中…</span>
)}
{tagsData && tagsData.tags.length === 0 && (
<span className="pn-tag-count" style={{ padding: 8 }}>还没有标签</span>
<span className="p-2 font-mono text-xs text-muted-foreground">还没有标签</span>
)}
</nav>
+414
View File
@@ -0,0 +1,414 @@
import { useMemo } from 'react'
import { theme } from 'antd'
import type { ConfigProviderProps } from 'antd'
import { createStyles } from 'antd-style'
import { clsx } from 'clsx'
/**
* ConfigProvider 主题:把应用的黑白灰 zinc 体系映射到 antd token。
* 浅色 token 与设计稿 theme-vars 一致;深色走 darkAlgorithm 并覆盖
* index.css `.dark` 中定义的语义色。组件样式跟随 antd,本文件只做
* 品牌色/圆角/阴影的对齐,不再逐像素复刻旧手搓样式。
*/
const useStyles = createStyles(({ css }) => {
return {
buttonPrimaryLight: css({
backgroundColor: '#18181b',
color: '#ffffff',
border: '1px solid #18181b',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonPrimaryDark: css({
backgroundColor: '#fafafa',
color: '#18181b',
border: '1px solid #fafafa',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultLight: css({
backgroundColor: '#ffffff',
color: '#18181b',
border: '1px solid #e4e4e7',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultDark: css({
backgroundColor: '#27272a',
color: '#fafafa',
border: '1px solid #3f3f46',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDangerLight: css({
backgroundColor: '#dc2626',
color: '#ffffff',
border: '1px solid #dc2626',
fontWeight: 500,
}),
buttonDangerDark: css({
backgroundColor: '#ef4444',
color: '#fafafa',
border: '1px solid #ef4444',
fontWeight: 500,
}),
inputRootLight: css({
borderColor: '#e4e4e7',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputRootDark: css({
borderColor: '#3f3f46',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputElementLight: css({
color: '#18181b',
}),
inputElementDark: css({
color: '#fafafa',
}),
inputErrorLight: css({
borderColor: '#dc2626',
}),
inputErrorDark: css({
borderColor: '#ef4444',
}),
selectRoot: css({
borderColor: '#e4e4e7',
}),
notificationRootLight: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #e4e4e7',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
}),
notificationRootDark: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #3f3f46',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
},
}),
notificationTitle: css({
fontWeight: 600,
}),
notificationDescriptionLight: css({
color: '#525252',
}),
notificationDescriptionDark: css({
color: '#d4d4d8',
}),
}
})
/* eslint-disable react-hooks/exhaustive-deps */
const useShadcnTheme = (dark: boolean) => {
const { styles } = useStyles()
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: dark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: dark
? {
// 深色:与 index.css `.dark` 语义变量对齐(zinc 深色 ramp)
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#fafafa',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#e4e4e7',
colorTextBase: '#fafafa',
colorBgBase: '#18181b',
colorPrimaryBg: '#27272a',
colorPrimaryBgHover: '#3f3f46',
colorPrimaryBorder: '#3f3f46',
colorPrimaryBorderHover: '#52525b',
colorPrimaryHover: '#ffffff',
colorPrimaryActive: '#d4d4d8',
colorPrimaryText: '#fafafa',
colorPrimaryTextHover: '#ffffff',
colorPrimaryTextActive: '#e4e4e7',
colorSuccessBg: '#0f2417',
colorSuccessBgHover: '#163321',
colorSuccessBorder: '#166534',
colorSuccessBorderHover: '#15803d',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#4ade80',
colorSuccessTextHover: '#4ade80',
colorSuccessTextActive: '#22c55e',
colorWarningBg: '#271a0c',
colorWarningBgHover: '#33220f',
colorWarningBorder: '#9a3412',
colorWarningBorderHover: '#c2410c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#fb923c',
colorWarningTextHover: '#fb923c',
colorWarningTextActive: '#f97316',
colorErrorBg: '#2a1416',
colorErrorBgHover: '#38191c',
colorErrorBorder: '#7f1d1d',
colorErrorBorderHover: '#991b1b',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#fca5a5',
colorErrorTextHover: '#fca5a5',
colorErrorTextActive: '#f87171',
colorInfoBg: '#27272a',
colorInfoBgHover: '#3f3f46',
colorInfoBorder: '#3f3f46',
colorInfoBorderHover: '#52525b',
colorInfoHover: '#f4f4f5',
colorInfoActive: '#e4e4e7',
colorInfoText: '#fafafa',
colorInfoTextHover: '#f4f4f5',
colorInfoTextActive: '#e4e4e7',
colorText: '#fafafa',
colorTextSecondary: '#d4d4d8',
colorTextTertiary: '#a1a1aa',
colorTextQuaternary: '#71717a',
colorTextDisabled: '#71717a',
colorBgContainer: '#27272a',
colorBgElevated: '#27272a',
colorBgLayout: '#18181b',
colorBgSpotlight: 'rgba(250, 250, 250, 0.85)',
colorBgMask: 'rgba(0, 0, 0, 0.6)',
colorBorder: '#3f3f46',
colorBorderSecondary: '#27272a',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.25)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
}
: {
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#262626',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#262626',
colorTextBase: '#262626',
colorBgBase: '#ffffff',
colorPrimaryBg: '#f5f5f5',
colorPrimaryBgHover: '#e5e5e5',
colorPrimaryBorder: '#d4d4d4',
colorPrimaryBorderHover: '#a3a3a3',
colorPrimaryHover: '#404040',
colorPrimaryActive: '#171717',
colorPrimaryText: '#262626',
colorPrimaryTextHover: '#404040',
colorPrimaryTextActive: '#171717',
colorSuccessBg: '#f0fdf4',
colorSuccessBgHover: '#dcfce7',
colorSuccessBorder: '#bbf7d0',
colorSuccessBorderHover: '#86efac',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#16a34a',
colorSuccessTextHover: '#16a34a',
colorSuccessTextActive: '#15803d',
colorWarningBg: '#fff7ed',
colorWarningBgHover: '#fed7aa',
colorWarningBorder: '#fdba74',
colorWarningBorderHover: '#fb923c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#ea580c',
colorWarningTextHover: '#ea580c',
colorWarningTextActive: '#c2410c',
colorErrorBg: '#fef2f2',
colorErrorBgHover: '#fecaca',
colorErrorBorder: '#fca5a5',
colorErrorBorderHover: '#f87171',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#dc2626',
colorErrorTextHover: '#dc2626',
colorErrorTextActive: '#b91c1c',
colorInfoBg: '#f5f5f5',
colorInfoBgHover: '#e5e5e5',
colorInfoBorder: '#d4d4d4',
colorInfoBorderHover: '#a3a3a3',
colorInfoHover: '#404040',
colorInfoActive: '#171717',
colorInfoText: '#262626',
colorInfoTextHover: '#404040',
colorInfoTextActive: '#171717',
colorText: '#262626',
colorTextSecondary: '#525252',
colorTextTertiary: '#737373',
colorTextQuaternary: '#a3a3a3',
colorTextDisabled: '#a3a3a3',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#fafafa',
colorBgSpotlight: 'rgba(38, 38, 38, 0.85)',
colorBgMask: 'rgba(38, 38, 38, 0.45)',
colorBorder: '#e5e5e5',
colorBorderSecondary: '#f5f5f5',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
components: {
Button: {
primaryShadow: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
defaultBorderColor: dark ? '#3f3f46' : '#e4e4e7',
defaultColor: dark ? '#fafafa' : '#18181b',
defaultBg: dark ? '#27272a' : '#ffffff',
defaultHoverBg: dark ? '#3f3f46' : '#f4f4f5',
defaultHoverBorderColor: dark ? '#52525b' : '#d4d4d8',
defaultHoverColor: dark ? '#fafafa' : '#18181b',
defaultActiveBg: dark ? '#52525b' : '#e4e4e7',
defaultActiveBorderColor: dark ? '#52525b' : '#d4d4d8',
borderRadius: 6,
},
Input: {
activeShadow: 'none',
hoverBorderColor: dark ? '#a1a1aa' : '#a1a1aa',
activeBorderColor: dark ? '#d4d4d8' : '#18181b',
borderRadius: 6,
},
Select: {
optionSelectedBg: dark ? '#3f3f46' : '#f4f4f5',
optionActiveBg: dark ? '#27272a' : '#fafafa',
optionSelectedFontWeight: 500,
borderRadius: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 12,
},
Switch: {
trackHeight: 22,
trackMinWidth: 44,
innerMinMargin: 4,
innerMaxMargin: 24,
},
Checkbox: {
borderRadiusSM: 4,
},
Notification: {
colorSuccessBg: dark ? '#0f2417' : '#f0fdf4',
colorErrorBg: dark ? '#2a1416' : '#fef2f2',
colorInfoBg: dark ? '#27272a' : '#f5f5f5',
colorWarningBg: dark ? '#271a0c' : '#fff7ed',
},
Card: {},
Tooltip: {},
Radio: {},
},
},
button: {
classNames: ({ props }) => ({
root: clsx(
(props as { type?: string }).type === 'primary' &&
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
(props as { type?: string }).type === 'default' &&
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
(props as { danger?: boolean }).danger &&
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
),
}),
},
input: {
classNames: ({ props }) => ({
root: clsx(
dark ? styles.inputRootDark : styles.inputRootLight,
(props as { status?: string }).status === 'error' &&
(dark ? styles.inputErrorDark : styles.inputErrorLight),
),
input: dark ? styles.inputElementDark : styles.inputElementLight,
}),
},
select: {
classNames: {
root: styles.selectRoot,
},
},
notification: {
classNames: {
root: dark ? styles.notificationRootDark : styles.notificationRootLight,
title: styles.notificationTitle,
description: dark
? styles.notificationDescriptionDark
: styles.notificationDescriptionLight,
},
},
wave: {},
app: {},
card: {},
modal: {},
alert: {},
colorPicker: {},
checkbox: {},
dropdown: {},
datePicker: {},
inputNumber: {},
popover: {},
tooltip: {},
switch: {},
radio: {},
segmented: {},
progress: {},
}),
[
dark,
styles.buttonDangerDark,
styles.buttonDangerLight,
styles.buttonDefaultDark,
styles.buttonDefaultLight,
styles.buttonPrimaryDark,
styles.buttonPrimaryLight,
styles.inputElementDark,
styles.inputElementLight,
styles.inputErrorDark,
styles.inputErrorLight,
styles.inputRootDark,
styles.inputRootLight,
styles.notificationDescriptionDark,
styles.notificationDescriptionLight,
styles.notificationRootDark,
styles.notificationRootLight,
styles.notificationTitle,
styles.selectRoot,
],
)
}
/* eslint-enable react-hooks/exhaustive-deps */
export default useShadcnTheme