feat: 官网 + 管理端 + Gin/GORM 后端首个完整版本
- web/: 产品官网(Hero 事件流终端、功能矩阵、架构、快速开始、下载、博客 + Markdown 详情页),青瓷绿双主题 - admin/: 内容管理(JWT 登录、文章分页/搜索/CRUD、草稿与发布),embed 挂 /admin - server/: Gin + GORM,MySQL(DSN 走 .env,SQLite 兜底);规范落地:sundynix_ 表前缀、字符串雪花主键、snake_case 列名、统一响应信封、公共分页参数 - 双 SPA embed 单二进制部署,make build 一键出包 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import { getToken } from '@/api/client'
|
||||
import { AdminLayout } from '@/components/admin-layout'
|
||||
import LoginPage from '@/pages/login'
|
||||
import PostsListPage from '@/pages/posts-list'
|
||||
import PostEditPage from '@/pages/post-edit'
|
||||
|
||||
function RequireAuth() {
|
||||
if (!getToken()) return <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<PostsListPage />} />
|
||||
<Route path="posts/new" element={<PostEditPage />} />
|
||||
<Route path="posts/:id" element={<PostEditPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { request, setToken, clearToken } from '@/api/client'
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const data = await request<{ token: string; username: string }>(
|
||||
'/api/admin/login',
|
||||
{ method: 'POST', body: JSON.stringify({ username, password }) },
|
||||
)
|
||||
setToken(data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** 管理端 API client:token 注入 + 统一信封解包 + 401 跳登录 */
|
||||
|
||||
const TOKEN_KEY = 'sundynix-admin-token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
code: number
|
||||
|
||||
constructor(status: number, code: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
const token = getToken()
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, { ...init, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
// basename=/admin,登录路由实际是 /admin/login
|
||||
if (!window.location.pathname.endsWith('/login')) {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
throw new ApiError(401, 40100, '登录已过期')
|
||||
}
|
||||
|
||||
let body: Envelope<T> | null = null
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>
|
||||
} catch {
|
||||
// 非 JSON 响应
|
||||
}
|
||||
if (!res.ok || !body || body.code !== 0) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body?.code ?? -1,
|
||||
body?.message ?? `请求失败(${res.status})`,
|
||||
)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { request } from '@/api/client'
|
||||
|
||||
export interface PostListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Post extends PostListItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PostForm {
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
content: string
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
list: T[] | null
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export function fetchPosts(page: number, pageSize: number, keyword: string) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
if (keyword) params.set('keyword', keyword)
|
||||
return request<PageResult<PostListItem>>(`/api/admin/posts?${params}`)
|
||||
}
|
||||
|
||||
export function fetchPost(id: string) {
|
||||
return request<Post>(`/api/admin/posts/${id}`)
|
||||
}
|
||||
|
||||
export function createPost(form: PostForm) {
|
||||
return request<Post>('/api/admin/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePost(id: string, form: PostForm) {
|
||||
return request<Post>(`/api/admin/posts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePost(id: string) {
|
||||
return request<null>(`/api/admin/posts/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||
import { logout } from '@/api/auth'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
|
||||
export function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { resolved, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-hairline bg-ground/85 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
|
||||
<Link to="/" className="font-mono text-[14px] font-semibold">
|
||||
sundynix <em className="not-italic text-accent">admin</em>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
resolved === 'dark' ? '切换到浅色主题' : '切换到深色主题'
|
||||
}
|
||||
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
|
||||
className="flex size-8 items-center justify-center rounded-full border border-hairline-strong text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
|
||||
>
|
||||
{resolved === 'dark' ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-[7px] border border-hairline-strong px-3 py-1.5 text-[13px] text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
|
||||
>
|
||||
<LogOut className="size-3.5" /> 退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: 'light' | 'dark'
|
||||
setTheme: (t: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'sundynix-theme'
|
||||
|
||||
function systemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system',
|
||||
)
|
||||
const [resolved, setResolved] = useState<'light' | 'dark'>(() =>
|
||||
theme === 'system' ? systemTheme() : theme,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const r = theme === 'system' ? systemTheme() : theme
|
||||
setResolved(r)
|
||||
document.documentElement.classList.toggle('dark', r === 'dark')
|
||||
}
|
||||
apply()
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
setThemeState(t)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── 设计 token(设计稿 v0.2)────────────────────────────
|
||||
青瓷绿单强调色;中性色整体向青绿偏移;
|
||||
终端窗双主题共用深色。 */
|
||||
:root {
|
||||
--ground: #f6f8f7;
|
||||
--surface: #ffffff;
|
||||
--ink: #16211d;
|
||||
--ink-2: #55665f;
|
||||
--ink-3: #8a9891;
|
||||
--hairline: #e1e8e4;
|
||||
--hairline-strong: #c9d4cf;
|
||||
--accent: #0e7c6b;
|
||||
--accent-ink: #0a5a4e;
|
||||
--accent-soft: #e3f1ec;
|
||||
--code-bg: #edf2f0;
|
||||
--term-bg: #10201b;
|
||||
--term-ink: #c7e8de;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ground: #0d1311;
|
||||
--surface: #131b18;
|
||||
--ink: #e7efeb;
|
||||
--ink-2: #93a69f;
|
||||
--ink-3: #64756e;
|
||||
--hairline: #22302b;
|
||||
--hairline-strong: #35453f;
|
||||
--accent: #3ecdad;
|
||||
--accent-ink: #6fdfc6;
|
||||
--accent-soft: #12352d;
|
||||
--code-bg: #182420;
|
||||
--term-bg: #0a1512;
|
||||
--term-ink: #a9d8cb;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-ground: var(--ground);
|
||||
--color-surface: var(--surface);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hairline: var(--hairline);
|
||||
--color-hairline-strong: var(--hairline-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-code: var(--code-bg);
|
||||
--color-term: var(--term-bg);
|
||||
--color-term-ink: var(--term-ink);
|
||||
|
||||
--font-sans: -apple-system, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ground);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ground text-ink font-sans antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* 终端光标 */
|
||||
@keyframes caret-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.caret-blink {
|
||||
animation: caret-blink 1.1s steps(1) infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret-blink {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import App from '@/App'
|
||||
import '@/index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter basename="/admin">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { getToken } from '@/api/client'
|
||||
import { login } from '@/api/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (getToken()) return <Navigate to="/" replace />
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-[360px] rounded-xl border border-hairline bg-surface p-8"
|
||||
>
|
||||
<p className="mb-1 font-mono text-[13px] font-semibold">
|
||||
sundynix <em className="not-italic text-accent">admin</em>
|
||||
</p>
|
||||
<h1 className="mb-6 text-[20px] font-[650] tracking-[-0.01em]">
|
||||
登录内容管理
|
||||
</h1>
|
||||
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className="w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14.5px] outline-none transition-colors focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-5 block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14.5px] outline-none transition-colors focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 text-[13px] text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-accent py-2.5 text-[14.5px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import {
|
||||
createPost,
|
||||
fetchPost,
|
||||
updatePost,
|
||||
type PostForm,
|
||||
} from '@/api/posts'
|
||||
|
||||
const EMPTY: PostForm = {
|
||||
slug: '',
|
||||
title: '',
|
||||
category: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
published: false,
|
||||
}
|
||||
|
||||
export default function PostEditPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [form, setForm] = useState<PostForm>(EMPTY)
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let cancelled = false
|
||||
fetchPost(id)
|
||||
.then((post) => {
|
||||
if (cancelled) return
|
||||
setForm({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
category: post.category,
|
||||
summary: post.summary,
|
||||
content: post.content ?? '',
|
||||
published: post.published_at !== null,
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const set = <K extends keyof PostForm>(key: K, value: PostForm[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updatePost(id, form)
|
||||
} else {
|
||||
await createPost(form)
|
||||
}
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14px] outline-none transition-colors focus:border-accent'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-6 inline-flex items-center gap-1.5 font-mono text-[12.5px] tracking-[0.08em] text-ink-3 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> 文章列表
|
||||
</Link>
|
||||
|
||||
<h1 className="mb-6 text-[22px] font-[650] tracking-[-0.01em]">
|
||||
{isEdit ? '编辑文章' : '新建文章'}
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="h-10 animate-pulse rounded-lg bg-code" />
|
||||
<div className="h-64 animate-pulse rounded-lg bg-code" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">标题 *</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
required
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
Slug *(URL 标识,如 my-first-post)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.slug}
|
||||
onChange={(e) => set('slug', e.target.value)}
|
||||
required
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
title="小写字母、数字,用 - 连接"
|
||||
className={`${inputCls} font-mono`}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
分类(如 RELEASE / ENGINEERING)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.category}
|
||||
onChange={(e) => set('category', e.target.value.toUpperCase())}
|
||||
className={`${inputCls} font-mono`}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">摘要</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.summary}
|
||||
onChange={(e) => set('summary', e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[13px] text-ink-2">
|
||||
正文(Markdown)
|
||||
</span>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) => set('content', e.target.value)}
|
||||
rows={18}
|
||||
spellCheck={false}
|
||||
className={`${inputCls} resize-y font-mono text-[13.5px] leading-relaxed`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-hairline pt-5">
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-[14px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.published}
|
||||
onChange={(e) => set('published', e.target.checked)}
|
||||
className="size-4 accent-(--accent)"
|
||||
/>
|
||||
发布到用户端
|
||||
<span className="text-[12.5px] text-ink-3">
|
||||
(不勾选保存为草稿)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{error && (
|
||||
<span className="text-[13px] text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-accent px-6 py-2 text-[14px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
deletePost,
|
||||
fetchPosts,
|
||||
type PageResult,
|
||||
type PostListItem,
|
||||
} from '@/api/posts'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function PostsListPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [input, setInput] = useState('')
|
||||
const [result, setResult] = useState<PageResult<PostListItem> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchPosts(page, PAGE_SIZE, keyword)
|
||||
.then((r) => {
|
||||
setResult(r)
|
||||
setError('')
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
}, [page, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const onDelete = async (post: PostListItem) => {
|
||||
if (!window.confirm(`确认删除「${post.title}」?此操作不可恢复。`)) return
|
||||
try {
|
||||
await deletePost(post.id)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.total / PAGE_SIZE)) : 1
|
||||
const list = result?.list ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-[22px] font-[650] tracking-[-0.01em]">文章管理</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
setKeyword(input)
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg border border-hairline-strong px-3 py-1.5"
|
||||
>
|
||||
<Search className="size-4 text-ink-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="搜标题 / slug / 分类"
|
||||
className="w-44 bg-transparent text-[13.5px] outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</form>
|
||||
<Link
|
||||
to="/posts/new"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-[13.5px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Plus className="size-4" /> 新建文章
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg border border-hairline bg-surface px-4 py-3 text-[13.5px] text-ink-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-hairline bg-surface">
|
||||
<table className="w-full text-left text-[13.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline font-mono text-[11px] tracking-[0.1em] text-ink-3">
|
||||
<th className="px-5 py-3 font-medium">标题</th>
|
||||
<th className="px-4 py-3 font-medium">SLUG</th>
|
||||
<th className="px-4 py-3 font-medium">分类</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">更新时间</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
className="border-b border-hairline last:border-b-0 hover:bg-ground"
|
||||
>
|
||||
<td className="max-w-[280px] truncate px-5 py-3 font-medium">
|
||||
<Link
|
||||
to={`/posts/${post.id}`}
|
||||
className="transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] text-ink-3">
|
||||
{post.slug}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11.5px] text-ink-2">
|
||||
{post.category || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{post.published_at ? (
|
||||
<span className="rounded-full bg-accent-soft px-2.5 py-0.5 text-[12px] text-accent-ink">
|
||||
已发布
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-code px-2.5 py-0.5 text-[12px] text-ink-3">
|
||||
草稿
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] tabular-nums text-ink-3">
|
||||
{post.updated_at.slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${post.title}`}
|
||||
onClick={() => onDelete(post)}
|
||||
className="rounded p-1.5 text-ink-3 transition-colors hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{result && list.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-10 text-center text-ink-3">
|
||||
{keyword ? '没有匹配的文章' : '还没有文章,点右上角新建'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{result && result.total > PAGE_SIZE && (
|
||||
<div className="mt-4 flex items-center justify-end gap-3 text-[13px] text-ink-2">
|
||||
<span className="font-mono tabular-nums">
|
||||
{page} / {totalPages} · 共 {result.total} 篇
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一页"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded border border-hairline-strong p-1.5 disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一页"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded border border-hairline-strong p-1.5 disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user