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:
+48
-21
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user