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:
@@ -91,7 +91,9 @@ internal/httpapi/ 路由与 handler(公共 / 认证 / 管理 / feed)
|
|||||||
internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
|
internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
|
||||||
web/ React 19 + Vite + Tailwind 4 前端
|
web/ React 19 + Vite + Tailwind 4 前端
|
||||||
├─ src/pages/ 9 页面:首页 / 笔记详情 / 标签页 / 登录 / 后台列表 / 编辑 / 回收站 / 设置 / 404
|
├─ src/pages/ 9 页面:首页 / 笔记详情 / 标签页 / 登录 / 后台列表 / 编辑 / 回收站 / 设置 / 404
|
||||||
├─ src/components/ Layout(导航与主题切换)、Editor、MarkdownViewer、Toast
|
├─ src/components/ Layout(导航与主题切换)、Editor、MarkdownViewer
|
||||||
|
├─ src/theme/ shadcnTheme(antd ConfigProvider 主题:品牌 token + 明暗算法 + 桥接样式)
|
||||||
|
├─ src/hooks/ use-toast(基于 antd notification 的操作反馈)
|
||||||
└─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate
|
└─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate
|
||||||
deploy/ systemd 单元 ×3 + Caddyfile
|
deploy/ systemd 单元 ×3 + Caddyfile
|
||||||
```
|
```
|
||||||
|
|||||||
+7
-5
@@ -100,13 +100,13 @@
|
|||||||
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
|
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
|
||||||
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
|
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
|
||||||
| 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — |
|
| 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — |
|
||||||
| 样式/组件 | Tailwind CSS 4 + **shadcn/ui** | 4.3.3 / CLI 4.21.0 | CSS-first 无样板配置;shadcn 复制式组件零运行时、克制美观,正合内容型博客;按需叠加 Radix 原语 | Ant Design 6(重、后台味浓)、Chakra 3、HeroUI、Radix Themes |
|
| 样式/组件 | Tailwind CSS 4 + **Ant Design 6** | 4.3.3 / 6.6.3 | Tailwind 负责页面排版与设计 token;交互组件统一用 antd(Button/Input/Table/Modal/Dropdown/Tooltip/notification 等),经 ConfigProvider 自定义主题对齐黑白灰品牌 token 并随三态主题切换明暗算法;v6 原生支持 React 19 | shadcn/ui + Radix(曾采用,组件需自维护,替换为 antd 降低长期维护成本)、Chakra 3、HeroUI |
|
||||||
| 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) |
|
| 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) |
|
||||||
| 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook |
|
| 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook |
|
||||||
| Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) |
|
| Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) |
|
||||||
| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
|
| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
|
||||||
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
|
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
|
||||||
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一、与 shadcn 默认匹配 | — |
|
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一(antd 组件内亦复用) | — |
|
||||||
|
|
||||||
### 3.3 关键决策记录(ADR 摘要)
|
### 3.3 关键决策记录(ADR 摘要)
|
||||||
|
|
||||||
@@ -194,9 +194,11 @@ pure-note/
|
|||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── main.tsx / App.tsx / router.tsx
|
│ ├── main.tsx / App.tsx / router.tsx
|
||||||
│ ├── lib/ # api client、auth、csrf、格式化
|
│ ├── lib/ # api client、auth、csrf、格式化
|
||||||
│ ├── components/ # shadcn/ui 生成目录 + 业务组件
|
│ ├── theme/ # antd ConfigProvider 主题(品牌 token + 明暗算法)
|
||||||
|
│ ├── components/ # 业务组件
|
||||||
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
|
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
|
||||||
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
|
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
|
||||||
|
│ ├── hooks/ # use-toast(antd notification 封装)
|
||||||
│ ├── features/ # notes / admin 业务 hooks
|
│ ├── features/ # notes / admin 业务 hooks
|
||||||
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
|
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
|
||||||
└── deploy/
|
└── deploy/
|
||||||
@@ -705,7 +707,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
|||||||
| react / react-dom | 19.2.8 | UI 框架 |
|
| react / react-dom | 19.2.8 | UI 框架 |
|
||||||
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
|
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
|
||||||
| tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 |
|
| tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 |
|
||||||
| shadcn/ui(CLI)+ Radix primitives | 4.21.0 | 组件库 |
|
| antd + antd-style | 6.6.3 / 4.1.0 | 组件库(ConfigProvider 自定义主题 + createStyles 桥接) |
|
||||||
| react-router | 8.3.1 | 路由 |
|
| react-router | 8.3.1 | 路由 |
|
||||||
| @tanstack/react-query | 5.102.8 | 服务端状态 |
|
| @tanstack/react-query | 5.102.8 | 服务端状态 |
|
||||||
| react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS |
|
| react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS |
|
||||||
@@ -719,7 +721,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
|||||||
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
|
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
|
||||||
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
|
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
|
||||||
- https://github.com/facebook/react · https://github.com/vitejs/vite
|
- https://github.com/facebook/react · https://github.com/vitejs/vite
|
||||||
- https://github.com/shadcn-ui/ui · https://github.com/remix-run/react-router
|
- https://github.com/ant-design/ant-design · https://github.com/remix-run/react-router
|
||||||
- https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize
|
- https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize
|
||||||
- https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
|
- https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
|
||||||
|
|
||||||
|
|||||||
Generated
+1425
-17
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
|||||||
"@codemirror/lang-markdown": "^6.5.2",
|
"@codemirror/lang-markdown": "^6.5.2",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"@uiw/react-codemirror": "^4.25.11",
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
|
"antd": "^6.6.3",
|
||||||
|
"antd-style": "^4.1.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.42.0",
|
"lucide-react": "^1.42.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
+29
-2
@@ -1,7 +1,11 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import { Navigate, Route, Routes } from 'react-router'
|
|
||||||
import type { ReactNode } 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 { useAuth } from './lib/auth'
|
||||||
|
import { useTheme } from './lib/theme'
|
||||||
|
import useShadcnTheme from './theme/shadcnTheme'
|
||||||
import { BlogLayout, AdminLayout } from './components/Layout'
|
import { BlogLayout, AdminLayout } from './components/Layout'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import TagPage from './pages/TagPage'
|
import TagPage from './pages/TagPage'
|
||||||
@@ -16,6 +20,27 @@ import NotFound from './pages/NotFound'
|
|||||||
const Note = lazy(() => import('./pages/Note'))
|
const Note = lazy(() => import('./pages/Note'))
|
||||||
const AdminEdit = lazy(() => import('./pages/AdminEdit'))
|
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)
|
// 登录守卫:401 统一跳转 /admin/login(§8.2)
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const { authenticated, loading } = useAuth()
|
const { authenticated, loading } = useAuth()
|
||||||
@@ -30,6 +55,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<AntdProvider>
|
||||||
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
|
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<BlogLayout />}>
|
<Route element={<BlogLayout />}>
|
||||||
@@ -49,5 +75,6 @@ export default function App() {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AntdProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,50 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Dropdown, Tooltip } from 'antd'
|
||||||
|
import type { MenuProps } from 'antd'
|
||||||
import { useSiteTitle } from '../features/site'
|
import { useSiteTitle } from '../features/site'
|
||||||
import { useTheme } from '../lib/theme'
|
import { useTheme } from '../lib/theme'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
|
|
||||||
/* ── 溢出菜单:触发按钮 + 下拉面板(点击外部 / Esc / 选中项关闭) ── */
|
/* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
|
||||||
|
|
||||||
function MoreMenu({ triggerClassName, triggerLabel, children }: {
|
function MoreMenu({ triggerClassName, triggerLabel, items }: {
|
||||||
triggerClassName: string
|
triggerClassName: string
|
||||||
triggerLabel: 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 (
|
return (
|
||||||
<div className="pn-moremenu" ref={ref}>
|
// wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
|
||||||
<button
|
// 曾致桌面导航整体横移 4px)
|
||||||
type="button"
|
<div className="pn-moremenu">
|
||||||
className={triggerClassName}
|
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
|
||||||
aria-haspopup="menu"
|
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={triggerLabel}
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<MoreHorizontal size={15} />
|
<MoreHorizontal size={15} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
</Dropdown>
|
||||||
<div className="pn-moremenu-panel" role="menu" onClick={() => setOpen(false)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 布局(博客系列设计稿) ─────────── */
|
/* ── 博客端:居中单栏 720px 布局(博客系列设计稿) ─────────── */
|
||||||
|
|
||||||
function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
|
function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) {
|
||||||
className?: string
|
|
||||||
tip?: 'native' | 'css'
|
|
||||||
}) {
|
|
||||||
const { mode, setMode } = useTheme()
|
const { mode, setMode } = useTheme()
|
||||||
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
||||||
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
||||||
@@ -65,8 +52,7 @@ function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={className}
|
className={className}
|
||||||
title={tip === 'native' ? title : undefined}
|
title={title}
|
||||||
data-tip={tip === 'css' ? title : undefined}
|
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={() => setMode(next)}
|
onClick={() => setMode(next)}
|
||||||
>
|
>
|
||||||
@@ -78,6 +64,7 @@ function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: {
|
|||||||
export function BlogLayout() {
|
export function BlogLayout() {
|
||||||
const { data } = useSiteTitle()
|
const { data } = useSiteTitle()
|
||||||
const title = data?.site_title ?? 'Pure Note'
|
const title = data?.site_title ?? 'Pure Note'
|
||||||
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<div className="pn-site-page">
|
<div className="pn-site-page">
|
||||||
<header className="pn-site-header">
|
<header className="pn-site-header">
|
||||||
@@ -95,14 +82,14 @@ export function BlogLayout() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
{/* <640px 首页/标签收进溢出菜单 */}
|
{/* <640px 首页/标签收进溢出菜单 */}
|
||||||
<MoreMenu triggerClassName="pn-site-navlink pn-site-menu-btn" triggerLabel="更多导航">
|
<MoreMenu
|
||||||
<NavLink to="/" end className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}>
|
triggerClassName="pn-site-navlink pn-site-menu-btn"
|
||||||
<House size={15} />首页
|
triggerLabel="更多导航"
|
||||||
</NavLink>
|
items={[
|
||||||
<NavLink to="/tags" className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}>
|
moreNavItem('/', true, <House size={15} />, '首页', location.pathname === '/'),
|
||||||
<Tags size={15} />标签
|
moreNavItem('/tags', false, <Tags size={15} />, '标签', location.pathname.startsWith('/tags')),
|
||||||
</NavLink>
|
]}
|
||||||
</MoreMenu>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -135,6 +122,24 @@ export function BlogLayout() {
|
|||||||
|
|
||||||
/* ── 管理端:sidebar + topbar + 移动端 tabbar(dashboard 设计稿) ── */
|
/* ── 管理端: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 }) {
|
export function AdminTopbar({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -146,24 +151,41 @@ export function AdminTopbar({ title, children }: { title: string; children?: Rea
|
|||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div className="pn-admin-actions">
|
<div className="pn-admin-actions">
|
||||||
{children}
|
{children}
|
||||||
<ThemeButton className="pn-admin-iconbtn tip-below pn-admin-desktop-act" tip="css" />
|
<AdminThemeButton />
|
||||||
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
|
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
|
||||||
<MoreMenu triggerClassName="pn-admin-iconbtn pn-admin-mobile-act" triggerLabel="更多操作">
|
<MoreMenu
|
||||||
<button type="button" className="pn-moremenu-item" onClick={() => setMode(next)}>
|
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} />}
|
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
|
||||||
主题:{modeLabel}
|
主题:{modeLabel}
|
||||||
</button>
|
</>
|
||||||
<a href="/" target="_blank" rel="noreferrer" className="pn-moremenu-item">
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blog',
|
||||||
|
label: (
|
||||||
|
<a href="/" target="_blank" rel="noreferrer">
|
||||||
<ExternalLink size={15} />返回博客
|
<ExternalLink size={15} />返回博客
|
||||||
</a>
|
</a>
|
||||||
<button
|
),
|
||||||
type="button"
|
},
|
||||||
className="pn-moremenu-item"
|
{
|
||||||
onClick={() => void logout().then(() => navigate('/'))}
|
key: 'logout',
|
||||||
>
|
onClick: () => void logout().then(() => navigate('/')),
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
<LogOut size={15} />退出登录
|
<LogOut size={15} />退出登录
|
||||||
</button>
|
</>
|
||||||
</MoreMenu>
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
@@ -220,25 +242,27 @@ function AdminSidebar() {
|
|||||||
<span className="pn-admin-user-role">站点所有者</span>
|
<span className="pn-admin-user-role">站点所有者</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="pn-admin-user-actions">
|
<span className="pn-admin-user-actions">
|
||||||
|
<Tooltip title="返回博客" placement="bottom">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="pn-admin-iconbtn"
|
className="pn-admin-iconbtn"
|
||||||
data-tip="返回博客"
|
|
||||||
aria-label="返回博客(新窗口打开)"
|
aria-label="返回博客(新窗口打开)"
|
||||||
>
|
>
|
||||||
<ExternalLink size={15} />
|
<ExternalLink size={15} />
|
||||||
</a>
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="退出登录" placement="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pn-admin-iconbtn"
|
className="pn-admin-iconbtn"
|
||||||
data-tip="退出登录"
|
|
||||||
aria-label="退出登录"
|
aria-label="退出登录"
|
||||||
onClick={() => void logout().then(() => navigate('/'))}
|
onClick={() => void logout().then(() => navigate('/'))}
|
||||||
>
|
>
|
||||||
<LogOut size={15} />
|
<LogOut size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
-974
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 {
|
export function formatDate(unix: number): string {
|
||||||
return new Date(unix * 1000).toLocaleDateString('zh-CN', {
|
return new Date(unix * 1000).toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
+68
-68
@@ -5,13 +5,14 @@ import {
|
|||||||
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
||||||
Save, Table, Trash2, X,
|
Save, Table, Trash2, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Button, Card, Divider, Input, Switch, Tag } from 'antd'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import MarkdownViewer from '../components/MarkdownViewer'
|
import MarkdownViewer from '../components/MarkdownViewer'
|
||||||
import Toast from '../components/Toast'
|
|
||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { formatDateISO, slugPreview } from '../lib/utils'
|
import { formatDateISO, slugPreview } from '../lib/utils'
|
||||||
|
|
||||||
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
||||||
@@ -50,6 +51,7 @@ export default function AdminEdit() {
|
|||||||
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
|
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const { data: existing, isLoading } = useQuery({
|
const { data: existing, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'note', noteId],
|
queryKey: ['admin', 'note', noteId],
|
||||||
@@ -64,7 +66,6 @@ export default function AdminEdit() {
|
|||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||||||
const [tagInput, setTagInput] = 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 dirtyRef = useRef(false)
|
||||||
const slugTouchedRef = useRef(false)
|
const slugTouchedRef = useRef(false)
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
@@ -121,7 +122,7 @@ export default function AdminEdit() {
|
|||||||
setSlugFieldError(null)
|
setSlugFieldError(null)
|
||||||
setErrorMsg(null)
|
setErrorMsg(null)
|
||||||
dirtyRef.current = false
|
dirtyRef.current = false
|
||||||
setToast({ msg: vars.auto ? '已自动保存' : '已保存', kind: 'success' })
|
toast({ description: vars.auto ? '已自动保存' : '已保存' })
|
||||||
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
|
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
|
||||||
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
|
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
|
||||||
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
|
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
|
||||||
@@ -131,7 +132,7 @@ export default function AdminEdit() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
setToast({ msg: '保存失败,请重试', kind: 'error' })
|
toast({ description: '保存失败,请重试', variant: 'error' })
|
||||||
if (e instanceof ApiError && e.code === 'slug_conflict') {
|
if (e instanceof ApiError && e.code === 'slug_conflict') {
|
||||||
setSlugFieldError(e.message)
|
setSlugFieldError(e.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -147,7 +148,7 @@ export default function AdminEdit() {
|
|||||||
invalidatePublic(qc)
|
invalidatePublic(qc)
|
||||||
navigate('/admin')
|
navigate('/admin')
|
||||||
},
|
},
|
||||||
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSave = form.title.trim().length > 0
|
const canSave = form.title.trim().length > 0
|
||||||
@@ -182,7 +183,7 @@ export default function AdminEdit() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="编辑笔记" />
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
|
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="text"
|
||||||
className="btn ghost"
|
icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
onClick={() => setShowPreview((v) => !v)}
|
onClick={() => setShowPreview((v) => !v)}
|
||||||
>
|
>
|
||||||
{showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
|
{showPreview ? '隐藏预览' : '预览'}
|
||||||
<span>{showPreview ? '隐藏预览' : '预览'}</span>
|
</Button>
|
||||||
</button>
|
<Button
|
||||||
<button
|
type="primary"
|
||||||
type="button"
|
icon={<Save size={15} />}
|
||||||
className="btn primary"
|
|
||||||
disabled={!canSave || save.isPending}
|
disabled={!canSave || save.isPending}
|
||||||
onClick={() => save.mutate({ f: form, auto: false })}
|
onClick={() => save.mutate({ f: form, auto: false })}
|
||||||
>
|
>
|
||||||
<Save size={15} />
|
{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
|
||||||
<span>{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}</span>
|
</Button>
|
||||||
</button>
|
|
||||||
</AdminTopbar>
|
</AdminTopbar>
|
||||||
|
|
||||||
<div className="pn-admin-content">
|
<div className="pn-admin-content">
|
||||||
<div className="pn-ae-grid">
|
<div className="pn-ae-grid">
|
||||||
<article className="card pn-ae-editor">
|
<Card className="pn-ae-main min-w-0">
|
||||||
<input
|
<Input
|
||||||
|
variant="borderless"
|
||||||
className="pn-ae-title-input"
|
className="pn-ae-title-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
@@ -230,24 +230,26 @@ export default function AdminEdit() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="pn-ae-tags">
|
<div className="pn-ae-tags">
|
||||||
{form.tags.map((t) => (
|
{form.tags.map((t) => (
|
||||||
<span key={t} className="pill pn-ae-tag">
|
<Tag
|
||||||
{t}
|
key={t}
|
||||||
<button
|
closable
|
||||||
type="button"
|
closeIcon={<X size={12} />}
|
||||||
className="pn-ae-tag-x"
|
onClose={(e) => {
|
||||||
aria-label={`移除标签:${t}`}
|
e.preventDefault()
|
||||||
onClick={() => set('tags', form.tags.filter((x) => x !== t))}
|
set('tags', form.tags.filter((x) => x !== t))
|
||||||
|
}}
|
||||||
|
aria-label={`标签:${t}`}
|
||||||
>
|
>
|
||||||
<X size={12} />
|
{t}
|
||||||
</button>
|
</Tag>
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
{tagInput === null ? (
|
{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>
|
<CirclePlus size={13} /><span>添加标签</span>
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<Input
|
||||||
|
variant="borderless"
|
||||||
className="pn-ae-tag-input"
|
className="pn-ae-tag-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={tagInput}
|
value={tagInput}
|
||||||
@@ -268,18 +270,18 @@ export default function AdminEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
|
<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="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
|
||||||
<button type="button" className="btn ghost pn-ae-tool" aria-label="斜体" title="斜体" onClick={() => insert('*文本*')}><Italic size={15} /></button>
|
<Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
|
||||||
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入链接" title="插入链接" onClick={() => insert('[标题](https://)')}><Link2 size={15} /></button>
|
<Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
|
||||||
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入代码" title="插入代码" onClick={() => insert('\n```\n代码\n```\n')}><Code size={15} /></button>
|
<Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
|
||||||
<button type="button" className="btn ghost pn-ae-tool" aria-label="无序列表" title="无序列表" onClick={() => insert('\n- 列表项\n')}><List size={15} /></button>
|
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
|
||||||
<button type="button" className="btn ghost pn-ae-tool" aria-label="引用" title="引用" onClick={() => insert('\n> 引用\n')}><Quote size={15} /></button>
|
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
|
||||||
<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={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
|
||||||
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
|
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-body-wrap">
|
<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
|
<Editor
|
||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(v) => set('content', v)}
|
onChange={(v) => set('content', v)}
|
||||||
@@ -295,11 +297,11 @@ export default function AdminEdit() {
|
|||||||
<MarkdownViewer source={form.content} />
|
<MarkdownViewer source={form.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</Card>
|
||||||
|
|
||||||
<aside className="pn-ae-side">
|
<aside className="pn-ae-side">
|
||||||
<section className="card pn-ae-publish">
|
<Card styles={{ body: { padding: 20 } }}>
|
||||||
<span className="title">发布设置</span>
|
<h3 className="pn-ae-side-title">发布设置</h3>
|
||||||
|
|
||||||
<div className="pn-ae-prow">
|
<div className="pn-ae-prow">
|
||||||
<span className="pn-ae-plabel">状态</span>
|
<span className="pn-ae-plabel">状态</span>
|
||||||
@@ -309,25 +311,19 @@ export default function AdminEdit() {
|
|||||||
>
|
>
|
||||||
<span className="dot" />{form.status === 'public' ? '公开' : '私有'}
|
<span className="dot" />{form.status === 'public' ? '公开' : '私有'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Switch
|
||||||
type="button"
|
checked={form.status === 'public'}
|
||||||
role="switch"
|
onChange={(checked) => set('status', checked ? 'public' : 'private')}
|
||||||
className="pn-switch"
|
|
||||||
aria-checked={form.status === 'public'}
|
|
||||||
aria-label="公开笔记"
|
aria-label="公开笔记"
|
||||||
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-prow">
|
<div className="pn-ae-prow">
|
||||||
<span className="pn-ae-plabel">置顶</span>
|
<span className="pn-ae-plabel">置顶</span>
|
||||||
<button
|
<Switch
|
||||||
type="button"
|
checked={form.pinned}
|
||||||
role="switch"
|
onChange={(checked) => set('pinned', checked)}
|
||||||
className="pn-switch"
|
|
||||||
aria-checked={form.pinned}
|
|
||||||
aria-label="置顶笔记"
|
aria-label="置顶笔记"
|
||||||
onClick={() => set('pinned', !form.pinned)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,14 +335,15 @@ export default function AdminEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-pfield">
|
<div className="pn-ae-pfield">
|
||||||
<label htmlFor="pn-ae-slug-input">固定链接</label>
|
<label className="pn-field-label text-xs" htmlFor="pn-ae-slug-input">固定链接</label>
|
||||||
<input
|
<Input
|
||||||
id="pn-ae-slug-input"
|
id="pn-ae-slug-input"
|
||||||
className={`pn-ae-input${slugFieldError ? ' is-error' : ''}`}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={form.slug}
|
value={form.slug}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
status={slugFieldError ? 'error' : undefined}
|
||||||
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
|
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)}
|
onChange={(e) => set('slug', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
|
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
|
||||||
@@ -354,11 +351,11 @@ export default function AdminEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-pfield">
|
<div className="pn-ae-pfield">
|
||||||
<label htmlFor="pn-ae-summary">摘要</label>
|
<label className="pn-field-label text-xs" htmlFor="pn-ae-summary">摘要</label>
|
||||||
<textarea
|
<Input.TextArea
|
||||||
id="pn-ae-summary"
|
id="pn-ae-summary"
|
||||||
className="pn-ae-input pn-ae-textarea"
|
|
||||||
rows={3}
|
rows={3}
|
||||||
|
styles={{ textarea: { fontSize: 12.5, lineHeight: 1.6 } }}
|
||||||
value={form.summary}
|
value={form.summary}
|
||||||
placeholder="留空自动截取正文"
|
placeholder="留空自动截取正文"
|
||||||
onChange={(e) => set('summary', e.target.value)}
|
onChange={(e) => set('summary', e.target.value)}
|
||||||
@@ -369,19 +366,22 @@ export default function AdminEdit() {
|
|||||||
|
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<>
|
<>
|
||||||
<hr className="divider pn-ae-divider" />
|
<Divider className="pn-ae-divider" />
|
||||||
<button type="button" className="pn-ae-danger" disabled={trashMutation.isPending} onClick={() => trashMutation.mutate()}>
|
<Button
|
||||||
|
className="pn-btn-danger-outline"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={trashMutation.isPending}
|
||||||
|
onClick={() => trashMutation.mutate()}
|
||||||
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span>
|
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span>
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-146
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
||||||
import { 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 { api } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import { formatDateISO } from '../lib/utils'
|
import { formatDateISO } from '../lib/utils'
|
||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
import Toast from '../components/Toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'public' | 'private'
|
type StatusFilter = 'all' | 'public' | 'private'
|
||||||
|
|
||||||
@@ -23,20 +25,21 @@ export default function AdminList() {
|
|||||||
useDocumentTitle('笔记管理')
|
useDocumentTitle('笔记管理')
|
||||||
const { data, isLoading } = useAdminNotes()
|
const { data, isLoading } = useAdminNotes()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { toast } = useToast()
|
||||||
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
|
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
|
||||||
const [filter, setFilter] = useState<StatusFilter>('all')
|
const [filter, setFilter] = useState<StatusFilter>('all')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
|
|
||||||
|
|
||||||
const trashMutation = useMutation({
|
const trashMutation = useMutation({
|
||||||
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
|
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setConfirmNote(null)
|
setConfirmNote(null)
|
||||||
setToast({ msg: '笔记已移至回收站', kind: 'success' })
|
toast({ description: '笔记已移至回收站' })
|
||||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
invalidatePublic(qc)
|
invalidatePublic(qc)
|
||||||
},
|
},
|
||||||
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleStatus = useMutation({
|
const toggleStatus = useMutation({
|
||||||
@@ -57,11 +60,11 @@ export default function AdminList() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: (_d, note) => {
|
onSuccess: (_d, note) => {
|
||||||
setToast({ msg: note.status === 'public' ? '已转为私有' : '已转为公开', kind: 'success' })
|
toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
|
||||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
invalidatePublic(qc)
|
invalidatePublic(qc)
|
||||||
},
|
},
|
||||||
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }),
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = data?.items ?? []
|
const items = data?.items ?? []
|
||||||
@@ -81,12 +84,97 @@ export default function AdminList() {
|
|||||||
})
|
})
|
||||||
}, [items, filter, query])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="笔记管理">
|
<AdminTopbar title="笔记管理">
|
||||||
<label className="pn-admin-search">
|
<label className="pn-admin-search">
|
||||||
<Search size={14} aria-hidden />
|
<Search size={14} aria-hidden />
|
||||||
<input
|
<Input
|
||||||
|
variant="borderless"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索笔记…"
|
placeholder="搜索笔记…"
|
||||||
aria-label="搜索笔记"
|
aria-label="搜索笔记"
|
||||||
@@ -94,167 +182,104 @@ export default function AdminList() {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Link to="/admin/notes/new" className="btn primary">
|
<Button
|
||||||
<CirclePlus size={15} />
|
type="primary"
|
||||||
<span>新建笔记</span>
|
icon={<CirclePlus size={15} />}
|
||||||
</Link>
|
onClick={() => void navigate('/admin/notes/new')}
|
||||||
|
>
|
||||||
|
新建笔记
|
||||||
|
</Button>
|
||||||
</AdminTopbar>
|
</AdminTopbar>
|
||||||
|
|
||||||
<div className="pn-admin-content">
|
<div className="pn-admin-content">
|
||||||
<section className="pn-al-metrics">
|
<section className="pn-al-metrics">
|
||||||
<article className="card metric">
|
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
|
||||||
<span className="eyebrow">笔记</span>
|
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">笔记</span>
|
||||||
<strong className="value">{data?.total ?? items.length}</strong>
|
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
|
||||||
<span className="meta">全部笔记,含 {privateCount} 篇私有</span>
|
{data?.total ?? items.length}
|
||||||
</article>
|
</strong>
|
||||||
<article className="card metric">
|
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
|
||||||
<span className="eyebrow">已发布</span>
|
全部笔记,含 {privateCount} 篇私有
|
||||||
<strong className="value">{publicCount}</strong>
|
</span>
|
||||||
<span className="meta">对外公开的笔记</span>
|
</Card>
|
||||||
</article>
|
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
|
||||||
<article className="card metric">
|
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">已发布</span>
|
||||||
<span className="eyebrow">私有</span>
|
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
|
||||||
<strong className="value">{privateCount}</strong>
|
{publicCount}
|
||||||
<span className="meta">尚未公开,仅自己可见</span>
|
</strong>
|
||||||
</article>
|
<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>
|
||||||
|
|
||||||
<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">
|
<div className="pn-al-table-head">
|
||||||
<nav className="tabs" aria-label="笔记筛选">
|
<Segmented
|
||||||
<button type="button" className={filter === 'all' ? 'active' : undefined} onClick={() => setFilter('all')}>
|
value={filter}
|
||||||
全部<span className="pn-al-tab-num">{items.length}</span>
|
onChange={(v) => setFilter(v as StatusFilter)}
|
||||||
</button>
|
className="min-w-0"
|
||||||
<button type="button" className={filter === 'public' ? 'active' : undefined} onClick={() => setFilter('public')}>
|
options={[
|
||||||
已发布<span className="pn-al-tab-num">{publicCount}</span>
|
{ value: 'all', label: <>全部<span className="pn-al-tab-num">{items.length}</span></> },
|
||||||
</button>
|
{ value: 'public', label: <>已发布<span className="pn-al-tab-num">{publicCount}</span></> },
|
||||||
<button type="button" className={filter === 'private' ? 'active' : undefined} onClick={() => setFilter('private')}>
|
{ value: 'private', label: <>私有<span className="pn-al-tab-num">{privateCount}</span></> },
|
||||||
私有<span className="pn-al-tab-num">{privateCount}</span>
|
]}
|
||||||
</button>
|
/>
|
||||||
</nav>
|
|
||||||
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
|
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="empty">加载中…</div>
|
<div className="pn-empty">加载中…</div>
|
||||||
) : visible.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="pn-empty">
|
||||||
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
|
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pn-table-scroll">
|
<Table
|
||||||
<table className="table" aria-label="笔记列表">
|
className="pn-al-table"
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr>
|
dataSource={visible}
|
||||||
<th>标题</th>
|
rowKey="id"
|
||||||
<th>标签</th>
|
pagination={false}
|
||||||
<th>日期</th>
|
scroll={{ x: 600 }}
|
||||||
<th>状态</th>
|
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pn-al-table-foot">
|
<div className="pn-al-table-foot">
|
||||||
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confirmNote && (
|
<Modal
|
||||||
<div
|
open={confirmNote !== null}
|
||||||
className="pn-modal-overlay"
|
onCancel={() => setConfirmNote(null)}
|
||||||
role="dialog"
|
title="移入回收站?"
|
||||||
aria-modal="true"
|
width={380}
|
||||||
aria-label="移入回收站"
|
centered
|
||||||
onClick={(e) => {
|
maskClosable={false}
|
||||||
if (e.target === e.currentTarget) setConfirmNote(null)
|
cancelText="取消"
|
||||||
|
okText={trashMutation.isPending ? '删除中…' : '确认删除'}
|
||||||
|
confirmLoading={trashMutation.isPending}
|
||||||
|
onOk={() => {
|
||||||
|
if (confirmNote) trashMutation.mutate(confirmNote.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="card pn-modal">
|
<p className="pn-modal-desc">
|
||||||
<h3>移入回收站?</h3>
|
「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
|
||||||
<p>「{confirmNote.title}」将被软删除,30 天内可在回收站恢复。</p>
|
</p>
|
||||||
{trashMutation.isError && <p style={{ color: 'var(--destructive-text)' }}>操作失败,请重试</p>}
|
{trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
|
||||||
<div className="pn-modal-actions">
|
</Modal>
|
||||||
<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)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react'
|
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react'
|
||||||
|
import { Button, Card, Divider, Input, InputNumber } from 'antd'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
import type { SiteSettings } from '../lib/api'
|
import type { SiteSettings } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
import Toast from '../components/Toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
useDocumentTitle('设置')
|
useDocumentTitle('设置')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { toast } = useToast()
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['admin', 'settings'],
|
queryKey: ['admin', 'settings'],
|
||||||
queryFn: () => api<SiteSettings>('/api/admin/settings'),
|
queryFn: () => api<SiteSettings>('/api/admin/settings'),
|
||||||
@@ -20,7 +22,6 @@ export default function AdminSettings() {
|
|||||||
const [desc, setDesc] = useState('')
|
const [desc, setDesc] = useState('')
|
||||||
const [beian, setBeian] = useState('')
|
const [beian, setBeian] = useState('')
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
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 }),
|
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize }),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setToast({ msg: '设置已保存', kind: 'success' })
|
toast({ description: '设置已保存' })
|
||||||
void qc.invalidateQueries({ queryKey: ['site'] })
|
void qc.invalidateQueries({ queryKey: ['site'] })
|
||||||
invalidatePublic(qc) // page_size 等设置影响公共列表
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="站点设置">
|
<AdminTopbar title="站点设置">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="primary"
|
||||||
className="btn primary"
|
icon={<Save size={15} />}
|
||||||
disabled={saveSettings.isPending}
|
disabled={saveSettings.isPending}
|
||||||
onClick={() => saveSettings.mutate()}
|
onClick={() => saveSettings.mutate()}
|
||||||
>
|
>
|
||||||
<Save size={15} />
|
|
||||||
{saveSettings.isPending ? '保存中…' : '保存更改'}
|
{saveSettings.isPending ? '保存中…' : '保存更改'}
|
||||||
</button>
|
</Button>
|
||||||
</AdminTopbar>
|
</AdminTopbar>
|
||||||
|
|
||||||
<div className="pn-admin-content pn-as-content">
|
<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">
|
<div className="pn-as-card-head">
|
||||||
<h2 className="title">站点信息</h2>
|
<h3 className="pn-as-card-title">站点信息</h3>
|
||||||
<p className="meta">这些信息会显示在博客的公开页面</p>
|
<p className="pn-as-card-desc">这些信息会显示在博客的公开页面</p>
|
||||||
</div>
|
</div>
|
||||||
<hr className="divider" />
|
<Divider style={{ margin: '16px 0 20px' }} />
|
||||||
<div className="pn-as-fields">
|
<div className="pn-as-fields">
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="site-title">站点名称</label>
|
<label htmlFor="site-title" className="pn-field-label">站点名称</label>
|
||||||
<input
|
<Input
|
||||||
id="site-title"
|
id="site-title"
|
||||||
className="pn-as-input"
|
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="site-desc">站点描述</label>
|
<label htmlFor="site-desc" className="pn-field-label">站点描述</label>
|
||||||
<textarea
|
<Input.TextArea
|
||||||
id="site-desc"
|
id="site-desc"
|
||||||
className="pn-as-input"
|
|
||||||
rows={3}
|
rows={3}
|
||||||
|
styles={{ textarea: { minHeight: 76, padding: '8px 12px', lineHeight: 1.7 } }}
|
||||||
value={desc}
|
value={desc}
|
||||||
onChange={(e) => setDesc(e.target.value)}
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="beian-no">公安备案号</label>
|
<label htmlFor="beian-no" className="pn-field-label">公安备案号</label>
|
||||||
<input
|
<Input
|
||||||
id="beian-no"
|
id="beian-no"
|
||||||
className="pn-as-input"
|
|
||||||
placeholder="如:京公网安备11000000000000号"
|
placeholder="如:京公网安备11000000000000号"
|
||||||
value={beian}
|
value={beian}
|
||||||
onChange={(e) => setBeian(e.target.value)}
|
onChange={(e) => setBeian(e.target.value)}
|
||||||
@@ -98,25 +96,21 @@ export default function AdminSettings() {
|
|||||||
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="page-size">首页每页条数(1–100)</label>
|
<label htmlFor="page-size" className="pn-field-label">首页每页条数(1–100)</label>
|
||||||
<input
|
<InputNumber
|
||||||
id="page-size"
|
id="page-size"
|
||||||
className="pn-as-input"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(v) => setPageSize(Number(v ?? 10))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
<ChangePassword onToast={setToast} />
|
<ChangePassword onToast={toast} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -124,7 +118,7 @@ export default function AdminSettings() {
|
|||||||
function ChangePassword({
|
function ChangePassword({
|
||||||
onToast,
|
onToast,
|
||||||
}: {
|
}: {
|
||||||
onToast: (t: { msg: string; kind: 'success' | 'error' } | null) => void
|
onToast: ReturnType<typeof useToast>['toast']
|
||||||
}) {
|
}) {
|
||||||
const [oldPw, setOldPw] = useState('')
|
const [oldPw, setOldPw] = useState('')
|
||||||
const [newPw, setNewPw] = useState('')
|
const [newPw, setNewPw] = useState('')
|
||||||
@@ -137,85 +131,77 @@ function ChangePassword({
|
|||||||
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
|
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onToast({ msg: '密码已修改', kind: 'success' })
|
onToast({ description: '密码已修改' })
|
||||||
setOldPw('')
|
setOldPw('')
|
||||||
setNewPw('')
|
setNewPw('')
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
onToast({
|
onToast({
|
||||||
msg:
|
description:
|
||||||
e.code === 'weak_password'
|
e.code === 'weak_password'
|
||||||
? '新密码长度至少 12 个字符'
|
? '新密码长度至少 12 个字符'
|
||||||
: e.status === 429
|
: e.status === 429
|
||||||
? '尝试过于频繁,请稍后再试'
|
? '尝试过于频繁,请稍后再试'
|
||||||
: '旧密码不正确',
|
: '旧密码不正确',
|
||||||
kind: 'error',
|
variant: 'error',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
onToast({ msg: '修改失败', kind: 'error' })
|
onToast({ description: '修改失败', variant: 'error' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card pn-as-card">
|
<Card className="pn-as-card">
|
||||||
<div className="pn-as-card-head">
|
<div className="pn-as-card-head">
|
||||||
<h2 className="title">修改密码</h2>
|
<h3 className="pn-as-card-title">修改密码</h3>
|
||||||
<p className="meta">更新管理后台的登录密码</p>
|
<p className="pn-as-card-desc">更新管理后台的登录密码</p>
|
||||||
</div>
|
</div>
|
||||||
<hr className="divider" />
|
<Divider style={{ margin: '16px 0 20px' }} />
|
||||||
<div className="pn-as-fields">
|
<div className="pn-as-fields">
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="old-pw">旧密码</label>
|
<label htmlFor="old-pw" className="pn-field-label">旧密码</label>
|
||||||
<div className="pn-as-control">
|
<Input
|
||||||
<Lock size={15} className="pn-as-leading-icon" aria-hidden />
|
|
||||||
<input
|
|
||||||
id="old-pw"
|
id="old-pw"
|
||||||
className="pn-as-input"
|
|
||||||
type={showPw ? 'text' : 'password'}
|
type={showPw ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
|
||||||
value={oldPw}
|
value={oldPw}
|
||||||
onChange={(e) => setOldPw(e.target.value)}
|
onChange={(e) => setOldPw(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label className="pn-as-label" htmlFor="new-pw">新密码(≥12 字符)</label>
|
<label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
|
||||||
<div className="pn-as-control has-toggle">
|
<Input
|
||||||
<Lock size={15} className="pn-as-leading-icon" aria-hidden />
|
|
||||||
<input
|
|
||||||
id="new-pw"
|
id="new-pw"
|
||||||
className="pn-as-input"
|
|
||||||
type={showPw ? 'text' : 'password'}
|
type={showPw ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="请输入新密码,至少 12 个字符"
|
placeholder="请输入新密码,至少 12 个字符"
|
||||||
value={newPw}
|
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
|
||||||
onChange={(e) => setNewPw(e.target.value)}
|
suffix={
|
||||||
/>
|
<Button
|
||||||
<button
|
type="text"
|
||||||
type="button"
|
size="small"
|
||||||
className="pn-as-eye"
|
|
||||||
aria-label={showPw ? '隐藏密码' : '显示密码'}
|
aria-label={showPw ? '隐藏密码' : '显示密码'}
|
||||||
onClick={() => setShowPw((v) => !v)}
|
onClick={() => setShowPw((v) => !v)}
|
||||||
>
|
icon={showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
/>
|
||||||
</button>
|
}
|
||||||
</div>
|
/>
|
||||||
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
|
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pn-as-card-foot">
|
<div className="pn-as-card-foot">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="small"
|
||||||
className="btn secondary"
|
|
||||||
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
||||||
onClick={() => change.mutate()}
|
onClick={() => change.mutate()}
|
||||||
>
|
>
|
||||||
<KeyRound size={15} />
|
<KeyRound size={15} />
|
||||||
{change.isPending ? '提交中…' : '修改密码'}
|
{change.isPending ? '提交中…' : '修改密码'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Info, RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { Alert, Button, Card, Table } from 'antd'
|
||||||
|
import type { TableProps } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import { daysRemaining, formatDateISO } from '../lib/utils'
|
import { daysRemaining, formatDateISO } from '../lib/utils'
|
||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
import Toast from '../components/Toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export default function AdminTrash() {
|
export default function AdminTrash() {
|
||||||
useDocumentTitle('回收站')
|
useDocumentTitle('回收站')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
|
const { toast } = useToast()
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'trash'],
|
queryKey: ['admin', 'trash'],
|
||||||
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
||||||
@@ -21,83 +22,95 @@ export default function AdminTrash() {
|
|||||||
const restore = useMutation({
|
const restore = useMutation({
|
||||||
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
|
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setToast({ msg: '笔记已恢复', kind: 'success' })
|
toast({ description: '笔记已恢复' })
|
||||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
invalidatePublic(qc)
|
invalidatePublic(qc)
|
||||||
},
|
},
|
||||||
onError: () => setToast({ msg: '恢复失败,请重试', kind: 'error' }),
|
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = data?.items ?? []
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="回收站" />
|
<AdminTopbar title="回收站" />
|
||||||
|
|
||||||
<div className="pn-admin-content">
|
<div className="pn-admin-content">
|
||||||
<div className="pn-at-alert" role="note">
|
<Alert
|
||||||
<Info size={16} />
|
role="note"
|
||||||
<p>回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。</p>
|
type="info"
|
||||||
</div>
|
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 ? (
|
{isLoading ? (
|
||||||
<div className="empty">加载中…</div>
|
<div className="pn-empty">加载中…</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="empty">回收站是空的</div>
|
<div className="pn-empty">回收站是空的</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pn-table-scroll">
|
<Table
|
||||||
<table className="table" aria-label="回收站笔记列表">
|
className="pn-al-table"
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr>
|
dataSource={items}
|
||||||
<th>标题</th>
|
rowKey="id"
|
||||||
<th>删除时间</th>
|
pagination={false}
|
||||||
<th>剩余天数</th>
|
scroll={{ x: 560 }}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pn-al-table-foot">
|
<div className="pn-al-table-foot">
|
||||||
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-35
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowRight, ChevronLeft, ChevronRight, Pin } from 'lucide-react'
|
import { ArrowRight, Pin } from 'lucide-react'
|
||||||
|
import { Pagination, Tag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem, NoteList, TagCount } from '../lib/api'
|
import type { NoteItem, NoteList, TagCount } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
@@ -43,7 +44,9 @@ function NotePost({ note }: { note: NoteItem }) {
|
|||||||
{note.tags.length > 0 && (
|
{note.tags.length > 0 && (
|
||||||
<div className="pn-home-tags">
|
<div className="pn-home-tags">
|
||||||
{note.tags.map((t) => (
|
{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>
|
</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() {
|
export default function Home() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
useDocumentTitle()
|
useDocumentTitle()
|
||||||
@@ -62,10 +70,14 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="pn-home-content">
|
<div className="pn-home-content">
|
||||||
{topTags.length > 0 && (
|
{topTags.length > 0 && (
|
||||||
<nav className="tabs pn-home-filter" aria-label="按标签筛选笔记">
|
<nav className={`pn-home-filter ${filterStripCls}`} aria-label="按标签筛选笔记">
|
||||||
<Link to="/" className={page === 1 ? 'active' : undefined}>全部</Link>
|
<Link to="/" aria-current={page === 1 ? 'page' : undefined}>
|
||||||
|
全部
|
||||||
|
</Link>
|
||||||
{topTags.map((t) => (
|
{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>
|
</nav>
|
||||||
)}
|
)}
|
||||||
@@ -85,36 +97,14 @@ export default function Home() {
|
|||||||
<span className="pn-home-pager-note">
|
<span className="pn-home-pager-note">
|
||||||
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页
|
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页
|
||||||
</span>
|
</span>
|
||||||
<div className="pn-pager">
|
<Pagination
|
||||||
<button
|
size="small"
|
||||||
type="button"
|
current={page}
|
||||||
className="btn ghost"
|
pageSize={data?.page_size ?? 10}
|
||||||
aria-label="上一页"
|
total={data?.total ?? 0}
|
||||||
disabled={page <= 1}
|
showSizeChanger={false}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onChange={(p) => setPage(p)}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-25
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { ArrowLeft, ArrowRight, CircleAlert, Eye, EyeOff, Lock } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Eye, EyeOff, Lock } from 'lucide-react'
|
||||||
|
import { Alert, Button, Card, Input } from 'antd'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
@@ -45,7 +46,8 @@ export default function Login() {
|
|||||||
<span className="pn-login-wordmark">Pure Note</span>
|
<span className="pn-login-wordmark">Pure Note</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="card pn-login-card" onSubmit={onSubmit}>
|
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
<h1 className="pn-login-title">管理员登录</h1>
|
<h1 className="pn-login-title">管理员登录</h1>
|
||||||
<p className="pn-login-sub">请输入管理密码以继续</p>
|
<p className="pn-login-sub">请输入管理密码以继续</p>
|
||||||
|
|
||||||
@@ -62,11 +64,10 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
|
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
|
||||||
<div className="pn-login-field">
|
<Input
|
||||||
<Lock size={16} className="pn-login-icon" aria-hidden />
|
|
||||||
<input
|
|
||||||
id="pn-login-password"
|
id="pn-login-password"
|
||||||
className="pn-login-input"
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
type={showPw ? 'text' : 'password'}
|
type={showPw ? 'text' : 'password'}
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="请输入管理密码"
|
placeholder="请输入管理密码"
|
||||||
@@ -75,36 +76,47 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
prefix={<Lock size={16} className="pn-login-lock" aria-hidden />}
|
||||||
<button
|
suffix={
|
||||||
type="button"
|
<Button
|
||||||
className="pn-login-eye"
|
type="text"
|
||||||
|
size="small"
|
||||||
aria-label={showPw ? '隐藏密码' : '显示密码'}
|
aria-label={showPw ? '隐藏密码' : '显示密码'}
|
||||||
onClick={() => setShowPw((v) => !v)}
|
onClick={() => setShowPw((v) => !v)}
|
||||||
>
|
icon={showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
/>
|
||||||
</button>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="pn-login-error" role="alert">
|
<Alert role="alert" type="error" showIcon message={error} className="pn-login-alert" />
|
||||||
<CircleAlert size={15} />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" className="btn primary pn-login-submit" disabled={submitting || password.length === 0}>
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
className="mt-5"
|
||||||
|
disabled={submitting || password.length === 0}
|
||||||
|
>
|
||||||
<span>{submitting ? '登录中…' : '登录'}</span>
|
<span>{submitting ? '登录中…' : '登录'}</span>
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<p className="pn-login-hint">连续失败将被暂时锁定</p>
|
<p className="pn-login-hint">连续失败将被暂时锁定</p>
|
||||||
</form>
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Link to="/" className="btn ghost pn-login-back">
|
<Button
|
||||||
<ArrowLeft size={14} />
|
type="text"
|
||||||
<span>返回博客</span>
|
size="small"
|
||||||
</Link>
|
className="pn-login-back"
|
||||||
|
icon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
返回博客
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Link } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { Button } from 'antd'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
useDocumentTitle('页面不存在')
|
useDocumentTitle('页面不存在')
|
||||||
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
<div className="pn-404-page">
|
<div className="pn-404-page">
|
||||||
<p className="pn-404-code">404</p>
|
<p className="pn-404-code">404</p>
|
||||||
<h1 className="pn-404-title">页面不存在</h1>
|
<h1 className="pn-404-title">页面不存在</h1>
|
||||||
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
|
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
|
||||||
<div className="pn-404-actions">
|
<div className="pn-404-actions">
|
||||||
<Link to="/" className="btn primary">
|
<Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
|
||||||
<ArrowLeft size={16} />
|
|
||||||
返回首页
|
返回首页
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-10
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useParams } from 'react-router'
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
|
||||||
|
import { Alert, Button, Card, Tag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteDetail } from '../lib/api'
|
import type { NoteDetail } from '../lib/api'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
@@ -10,6 +11,7 @@ import { formatDateISO, readingMinutes } from '../lib/utils'
|
|||||||
|
|
||||||
export default function Note() {
|
export default function Note() {
|
||||||
const { slug = '' } = useParams()
|
const { slug = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { authenticated } = useAuth()
|
const { authenticated } = useAuth()
|
||||||
const { data: note, isLoading, error } = useQuery({
|
const { data: note, isLoading, error } = useQuery({
|
||||||
queryKey: ['note', slug],
|
queryKey: ['note', slug],
|
||||||
@@ -24,7 +26,9 @@ export default function Note() {
|
|||||||
<div className="pn-note-article">
|
<div className="pn-note-article">
|
||||||
<p className="pn-home-list-empty">笔记不存在或未公开</p>
|
<p className="pn-home-list-empty">笔记不存在或未公开</p>
|
||||||
<p style={{ textAlign: 'center', marginTop: -40 }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -34,15 +38,23 @@ export default function Note() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="pn-note-article">
|
<article className="pn-note-article">
|
||||||
<Link to="/" className="btn ghost pn-note-back">
|
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
|
||||||
<ArrowLeft size={14} />返回首页
|
返回首页
|
||||||
</Link>
|
</Button>
|
||||||
|
|
||||||
{privatePreview && authenticated && (
|
{privatePreview && authenticated && (
|
||||||
<div className="pn-note-private" role="note">
|
<Alert
|
||||||
|
role="note"
|
||||||
|
type="info"
|
||||||
|
showIcon={false}
|
||||||
|
className="pn-note-preview-alert"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
<Eye size={15} />
|
<Eye size={15} />
|
||||||
私有预览:此笔记未公开,仅管理员可见
|
私有预览:此笔记未公开,仅管理员可见
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="pn-note-title">{note.title}</h1>
|
<h1 className="pn-note-title">{note.title}</h1>
|
||||||
@@ -57,7 +69,9 @@ export default function Note() {
|
|||||||
<>
|
<>
|
||||||
<span className="pn-note-meta-sep">·</span>
|
<span className="pn-note-meta-sep">·</span>
|
||||||
{note.tags.map((t) => (
|
{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="笔记导航">
|
<nav className="pn-note-postnav" aria-label="笔记导航">
|
||||||
{note.prev ? (
|
{note.prev ? (
|
||||||
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="card pn-note-nav-card">
|
<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-label"><ArrowLeft size={14} />上一篇</span>
|
||||||
<span className="pn-note-nav-title">{note.prev.title}</span>
|
<span className="pn-note-nav-title">{note.prev.title}</span>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
{note.next ? (
|
{note.next ? (
|
||||||
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="card pn-note-nav-card pn-note-nav-next">
|
<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-label">下一篇<ArrowRight size={14} /></span>
|
||||||
<span className="pn-note-nav-title">{note.next.title}</span>
|
<span className="pn-note-nav-title">{note.next.title}</span>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useParams } from 'react-router'
|
import { Link, useParams } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowRight, Tag } from 'lucide-react'
|
import { ArrowRight, Tag } from 'lucide-react'
|
||||||
|
import { Tag as AntTag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteList, TagCount } from '../lib/api'
|
import type { NoteList, TagCount } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
@@ -33,22 +34,22 @@ export default function TagPage() {
|
|||||||
|
|
||||||
<nav className="pn-tag-cloud" aria-label="全部标签">
|
<nav className="pn-tag-cloud" aria-label="全部标签">
|
||||||
{tagsData?.tags.map((t) => (
|
{tagsData?.tags.map((t) => (
|
||||||
|
<AntTag.CheckableTag key={t.name} checked={t.name === tag} className="pn-cloud-tag">
|
||||||
<Link
|
<Link
|
||||||
key={t.name}
|
|
||||||
to={`/tags/${encodeURIComponent(t.name)}`}
|
to={`/tags/${encodeURIComponent(t.name)}`}
|
||||||
className={`pn-tag-pill${t.name === tag ? ' active' : ''}`}
|
|
||||||
aria-current={t.name === tag ? 'true' : undefined}
|
aria-current={t.name === tag ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<Tag size={14} />
|
<Tag size={14} />
|
||||||
<span>{t.name}</span>
|
<span>{t.name}</span>
|
||||||
<span className="pn-tag-count">{t.count}</span>
|
<span className="pn-cloud-count">{t.count}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</AntTag.CheckableTag>
|
||||||
))}
|
))}
|
||||||
{!tagsData && (
|
{!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 && (
|
{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>
|
</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
|
||||||
+5
-1
@@ -15,7 +15,11 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "tests"]
|
"include": ["src", "tests"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
@@ -8,6 +9,12 @@ export default defineConfig({
|
|||||||
// /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改)
|
// /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改)
|
||||||
base: '/',
|
base: '/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
// shadcn 组件别名的运行时解析(components.json aliases)
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// 同源是 Cookie/CSRF 成立的前提(§10.5)
|
// 同源是 Cookie/CSRF 成立的前提(§10.5)
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
// 与 vite.config 一致:shadcn 组件的 @/ 别名
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
include: ['tests/**/*.test.?(c|m)[jt]s?(x)'],
|
include: ['tests/**/*.test.?(c|m)[jt]s?(x)'],
|
||||||
|
|||||||
Reference in New Issue
Block a user