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:
Blizzard
2026-07-17 11:03:31 +08:00
parent d46267dde1
commit 5abc705eae
53 changed files with 5774 additions and 38 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>sundynix admin — 内容管理</title>
<script>
// 避免主题闪烁:React 挂载前先落 dark class
;(function () {
var t = localStorage.getItem('sundynix-theme')
var dark =
t === 'dark' ||
((t === null || t === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1768
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#10201b"/>
<path d="M9 20.5 15 10l3.2 5.6L21 11l2.5 4.3" fill="none" stroke="#3ecdad" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="23.5" cy="15.3" r="1.6" fill="#3ecdad"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+27
View File
@@ -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>
)
}
+14
View File
@@ -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()
}
+68
View File
@@ -0,0 +1,68 @@
/** 管理端 API clienttoken 注入 + 统一信封解包 + 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
}
+63
View File
@@ -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' })
}
+50
View File
@@ -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>
)
}
+63
View File
@@ -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
}
+90
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+16
View File
@@ -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>,
)
+82
View File
@@ -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>
)
}
+192
View File
@@ -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>
)
}
+179
View File
@@ -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>
)
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
},
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
base: '/admin/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
proxy: {
'/api': 'http://localhost:8090',
},
},
})