feat: Refactor service management scripts to use a unified dev script
- Added package.json to manage development scripts. - Updated restart-services.ps1 to call the new dev script for starting services. - Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services. - Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
This commit is contained in:
92
admin/package-lock.json
generated
92
admin/package-lock.json
generated
@@ -8,12 +8,16 @@
|
||||
"name": "admin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"marked": "^17.0.5",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2",
|
||||
@@ -561,6 +565,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -1223,6 +1250,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||
@@ -1844,6 +1878,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.328",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
|
||||
@@ -2710,6 +2753,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz",
|
||||
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -2723,6 +2778,37 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3083,6 +3169,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"marked": "^17.0.5",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
BrowserRouter,
|
||||
@@ -56,11 +57,11 @@ function AppLoadingScreen() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-muted-foreground">
|
||||
Termi admin
|
||||
Termi 后台
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Booting control room</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">正在进入管理后台</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Checking the current admin session and preparing the new React workspace.
|
||||
正在检查当前登录状态,并准备新的 React 管理工作台。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,14 +69,14 @@ function AppLoadingScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionGuard() {
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession()
|
||||
|
||||
if (!session.authenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicOnly() {
|
||||
@@ -97,12 +98,10 @@ function PublicOnly() {
|
||||
startTransition(() => {
|
||||
setSession(nextSession)
|
||||
})
|
||||
toast.success('Admin session unlocked.')
|
||||
toast.success('后台登录成功。')
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to sign in right now.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '当前无法登录后台。')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -127,12 +126,10 @@ function ProtectedLayout() {
|
||||
startTransition(() => {
|
||||
setSession(nextSession)
|
||||
})
|
||||
toast.success('Admin session closed.')
|
||||
toast.success('已退出后台。')
|
||||
navigate('/login', { replace: true })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to sign out right now.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '当前无法退出后台。')
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
@@ -147,16 +144,21 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicOnly />} />
|
||||
<Route element={<SessionGuard />}>
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="/posts" element={<PostsPage />} />
|
||||
<Route path="/posts/:slug" element={<PostsPage />} />
|
||||
<Route path="/comments" element={<CommentsPage />} />
|
||||
<Route path="/friend-links" element={<FriendLinksPage />} />
|
||||
<Route path="/reviews" element={<ReviewsPage />} />
|
||||
<Route path="/settings" element={<SiteSettingsPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ProtectedLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="posts" element={<PostsPage />} />
|
||||
<Route path="posts/:slug" element={<PostsPage />} />
|
||||
<Route path="comments" element={<CommentsPage />} />
|
||||
<Route path="friend-links" element={<FriendLinksPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="settings" element={<SiteSettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
@@ -178,7 +180,7 @@ export default function App() {
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to reach the backend session API.',
|
||||
error instanceof ApiError ? error.message : '当前无法连接后台会话接口。',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
@@ -21,38 +21,38 @@ import { cn } from '@/lib/utils'
|
||||
const primaryNav = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Overview',
|
||||
description: 'Live operational dashboard',
|
||||
label: '概览',
|
||||
description: '站点运营总览',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
to: '/posts',
|
||||
label: 'Posts',
|
||||
description: 'Markdown content workspace',
|
||||
label: '文章',
|
||||
description: 'Markdown 内容管理',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
to: '/comments',
|
||||
label: 'Comments',
|
||||
description: 'Moderation and paragraph replies',
|
||||
label: '评论',
|
||||
description: '审核与段落回复',
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
to: '/friend-links',
|
||||
label: 'Friend links',
|
||||
description: 'Partner queue and reciprocity',
|
||||
label: '友链',
|
||||
description: '友链申请与互链管理',
|
||||
icon: Link2,
|
||||
},
|
||||
{
|
||||
to: '/reviews',
|
||||
label: 'Reviews',
|
||||
description: 'Curated review library',
|
||||
label: '评测',
|
||||
description: '评测内容库',
|
||||
icon: BookOpenText,
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: 'Site settings',
|
||||
description: 'Brand, profile, and AI config',
|
||||
label: '设置',
|
||||
description: '品牌、资料与 AI 配置',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
@@ -77,15 +77,12 @@ export function AppShell({
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
|
||||
<Orbit className="h-3.5 w-3.5" />
|
||||
Termi admin
|
||||
Termi 后台
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Control room for the blog system
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">博客系统控制台</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
A dedicated React workspace for publishing, moderation, operations, and
|
||||
AI-related site controls.
|
||||
一个独立的 React 管理工作台,用来处理发布、审核、运营以及站内 AI 配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,20 +138,20 @@ export function AppShell({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Workspace status
|
||||
工作台状态
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Core admin flows are now available in the standalone app.
|
||||
核心后台流程已经迁移到独立管理端。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success">live</Badge>
|
||||
<Badge variant="success">运行中</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||
Public site and admin stay decoupled.
|
||||
前台站点与后台管理保持解耦。
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||
Backend remains the shared auth and data layer.
|
||||
后端继续作为统一认证与数据层。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,12 +165,14 @@ export function AppShell({
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-secondary-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
New admin workspace
|
||||
新版管理工作台
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Signed in as {username ?? 'admin'}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前登录:{username ?? 'admin'}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
React + shadcn/ui foundation
|
||||
React + shadcn/ui 基础架构
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,12 +201,12 @@ export function AppShell({
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open site
|
||||
打开前台
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{loggingOut ? 'Signing out...' : 'Sign out'}
|
||||
{loggingOut ? '退出中...' : '退出登录'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
40
admin/src/components/markdown-preview.tsx
Normal file
40
admin/src/components/markdown-preview.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MarkdownPreviewProps = {
|
||||
markdown: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
|
||||
const html = useMemo(() => {
|
||||
const rendered = marked.parse(markdown || '暂无内容。')
|
||||
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
|
||||
}, [markdown])
|
||||
|
||||
return (
|
||||
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>
|
||||
<article
|
||||
className={cn(
|
||||
'mx-auto max-w-4xl px-8 py-8 text-[15px] leading-8 text-slate-700',
|
||||
'[&_a]:text-blue-600 [&_a]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-100/80 [&_blockquote]:px-4 [&_blockquote]:py-3 [&_blockquote]:italic',
|
||||
'[&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
|
||||
'[&_h1]:mt-2 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-semibold',
|
||||
'[&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_hr]:my-8 [&_hr]:border-slate-200',
|
||||
'[&_li]:my-1 [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-4 [&_pre]:my-5 [&_pre]:overflow-x-auto [&_pre]:rounded-2xl [&_pre]:bg-slate-950 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-slate-100 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||
'[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-2xl [&_table]:border [&_table]:border-slate-200 [&_tbody_tr:nth-child(even)]:bg-slate-50/70 [&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-100 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left',
|
||||
'[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
admin/src/components/markdown-workbench.tsx
Normal file
332
admin/src/components/markdown-workbench.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import Editor, { DiffEditor, type BeforeMount } from '@monaco-editor/react'
|
||||
import { Expand, Minimize2, Sparkles } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type MarkdownWorkbenchPanel = 'edit' | 'preview' | 'diff'
|
||||
export type MarkdownWorkbenchMode = 'workspace' | 'polish'
|
||||
|
||||
type MarkdownWorkbenchProps = {
|
||||
value: string
|
||||
originalValue: string
|
||||
diffValue?: string
|
||||
path: string
|
||||
readOnly?: boolean
|
||||
mode: MarkdownWorkbenchMode
|
||||
visiblePanels: MarkdownWorkbenchPanel[]
|
||||
availablePanels?: MarkdownWorkbenchPanel[]
|
||||
allowPolish?: boolean
|
||||
preview: ReactNode
|
||||
polishPanel?: ReactNode
|
||||
originalLabel?: string
|
||||
modifiedLabel?: string
|
||||
onChange: (value: string) => void
|
||||
onModeChange: (next: MarkdownWorkbenchMode) => void
|
||||
onVisiblePanelsChange: (next: MarkdownWorkbenchPanel[]) => void
|
||||
}
|
||||
|
||||
export const editorTheme = 'termi-vscode'
|
||||
|
||||
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
|
||||
|
||||
function formatPanelLabel(panel: MarkdownWorkbenchPanel) {
|
||||
switch (panel) {
|
||||
case 'preview':
|
||||
return '预览'
|
||||
case 'diff':
|
||||
return '改动对比'
|
||||
case 'edit':
|
||||
default:
|
||||
return '编辑'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVisiblePanels(
|
||||
visiblePanels: MarkdownWorkbenchPanel[],
|
||||
availablePanels: MarkdownWorkbenchPanel[],
|
||||
) {
|
||||
const orderedAvailablePanels = orderedWorkbenchPanels.filter((panel) =>
|
||||
availablePanels.includes(panel),
|
||||
)
|
||||
const nextPanels = orderedAvailablePanels.filter((panel) => visiblePanels.includes(panel))
|
||||
return nextPanels.length ? nextPanels : orderedAvailablePanels.slice(0, 1)
|
||||
}
|
||||
|
||||
export const configureMonaco: BeforeMount = (monaco) => {
|
||||
monaco.editor.defineTheme(editorTheme, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'comment', foreground: '6A9955' },
|
||||
{ token: 'keyword', foreground: 'C586C0' },
|
||||
{ token: 'string', foreground: 'CE9178' },
|
||||
{ token: 'number', foreground: 'B5CEA8' },
|
||||
{ token: 'delimiter', foreground: 'D4D4D4' },
|
||||
{ token: 'type.identifier', foreground: '4EC9B0' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e',
|
||||
'editor.foreground': '#d4d4d4',
|
||||
'editor.lineHighlightBackground': '#2a2d2e',
|
||||
'editor.lineHighlightBorder': '#00000000',
|
||||
'editorCursor.foreground': '#aeafad',
|
||||
'editor.selectionBackground': '#264f78',
|
||||
'editor.inactiveSelectionBackground': '#3a3d41',
|
||||
'editorWhitespace.foreground': '#3b3b3b',
|
||||
'editorIndentGuide.background1': '#404040',
|
||||
'editorIndentGuide.activeBackground1': '#707070',
|
||||
'editorLineNumber.foreground': '#858585',
|
||||
'editorLineNumber.activeForeground': '#c6c6c6',
|
||||
'editorGutter.background': '#1e1e1e',
|
||||
'editorOverviewRuler.border': '#00000000',
|
||||
'diffEditor.insertedTextBackground': '#9ccc2c33',
|
||||
'diffEditor.removedTextBackground': '#ff6b6b2d',
|
||||
'diffEditor.insertedLineBackground': '#9ccc2c18',
|
||||
'diffEditor.removedLineBackground': '#ff6b6b18',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const sharedOptions = {
|
||||
automaticLayout: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, SFMono-Regular, monospace',
|
||||
fontLigatures: true,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 16, bottom: 16 },
|
||||
renderWhitespace: 'selection' as const,
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on' as const,
|
||||
}
|
||||
|
||||
export function MarkdownWorkbench({
|
||||
value,
|
||||
originalValue,
|
||||
diffValue,
|
||||
path,
|
||||
readOnly = false,
|
||||
mode,
|
||||
visiblePanels,
|
||||
availablePanels = ['edit', 'preview', 'diff'],
|
||||
allowPolish,
|
||||
preview,
|
||||
polishPanel,
|
||||
originalLabel = '基线版本',
|
||||
modifiedLabel = '目标版本',
|
||||
onChange,
|
||||
onModeChange,
|
||||
onVisiblePanelsChange,
|
||||
}: MarkdownWorkbenchProps) {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : 'h-[560px]'
|
||||
const diffContent = diffValue ?? value
|
||||
const polishEnabled = allowPolish ?? Boolean(polishPanel)
|
||||
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||
const renderDiffSideBySide = workspacePanels.length < 3 || fullscreen
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [fullscreen])
|
||||
|
||||
const togglePanel = (panel: MarkdownWorkbenchPanel) => {
|
||||
const currentPanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||
const nextPanels = currentPanels.includes(panel)
|
||||
? currentPanels.filter((item) => item !== panel)
|
||||
: orderedWorkbenchPanels.filter(
|
||||
(item) => availablePanels.includes(item) && (currentPanels.includes(item) || item === panel),
|
||||
)
|
||||
|
||||
onVisiblePanelsChange(nextPanels.length ? nextPanels : availablePanels.slice(0, 1))
|
||||
|
||||
if (mode !== 'workspace') {
|
||||
onModeChange('workspace')
|
||||
}
|
||||
}
|
||||
|
||||
const workbench = (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e] shadow-[0_24px_60px_rgba(15,23,42,0.28)]',
|
||||
fullscreen && 'relative h-[100dvh] rounded-none border-0 shadow-none',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-slate-800 bg-[#181818] px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-slate-400">{path}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{availablePanels.map((panel) => {
|
||||
const active = mode === 'workspace' && workspacePanels.includes(panel)
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={panel}
|
||||
variant={active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => togglePanel(panel)}
|
||||
className={
|
||||
active
|
||||
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||
}
|
||||
>
|
||||
{formatPanelLabel(panel)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{polishEnabled ? (
|
||||
<Button
|
||||
variant={mode === 'polish' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onModeChange(mode === 'polish' ? 'workspace' : 'polish')}
|
||||
className={
|
||||
mode === 'polish'
|
||||
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||
}
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
AI 润色
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFullscreen((current) => !current)}
|
||||
className="border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white"
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<Minimize2 className="mr-1 h-4 w-4" />
|
||||
退出全屏
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Expand className="mr-1 h-4 w-4" />
|
||||
全屏
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={editorHeight}>
|
||||
{mode === 'polish' ? (
|
||||
<div className="h-full bg-[#111111]">{polishPanel}</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col bg-slate-900 xl:flex-row">
|
||||
{workspacePanels.map((panel, index) => (
|
||||
<section
|
||||
key={panel}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col bg-[#1b1b1b]',
|
||||
index < workspacePanels.length - 1 &&
|
||||
'border-b border-slate-800 xl:border-b-0 xl:border-r',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||
<span>{formatPanelLabel(panel)}</span>
|
||||
{panel === 'diff' ? (
|
||||
<span>
|
||||
{originalLabel} / {modifiedLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span>{path}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panel === 'edit' ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
path={path}
|
||||
value={value}
|
||||
keepCurrentModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
readOnly,
|
||||
stickyScroll: { enabled: true },
|
||||
}}
|
||||
onChange={(next) => onChange(next ?? '')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{panel === 'preview' ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#141414]">{preview}</div>
|
||||
) : null}
|
||||
|
||||
{panel === 'diff' ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
original={originalValue}
|
||||
modified={diffContent}
|
||||
originalModelPath={`${path}#saved`}
|
||||
modifiedModelPath={`${path}#draft`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: renderDiffSideBySide,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!fullscreen) {
|
||||
return workbench
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return workbench
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[900] bg-slate-950/92 backdrop-blur-md" />
|
||||
<div className="fixed inset-0 z-[1000]">{workbench}</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 'Not available'
|
||||
return '暂无'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
@@ -9,12 +9,85 @@ export function formatDateTime(value: string | null | undefined) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatPostType(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'article':
|
||||
return '文章'
|
||||
case 'note':
|
||||
return '笔记'
|
||||
case 'page':
|
||||
return '页面'
|
||||
case 'snippet':
|
||||
return '片段'
|
||||
default:
|
||||
return value || '文章'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommentScope(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'paragraph':
|
||||
return '段落'
|
||||
case 'article':
|
||||
return '全文'
|
||||
default:
|
||||
return value || '全文'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFriendLinkStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'approved':
|
||||
return '已通过'
|
||||
case 'rejected':
|
||||
return '已拒绝'
|
||||
case 'pending':
|
||||
return '待审核'
|
||||
default:
|
||||
return value || '待审核'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReviewType(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'book':
|
||||
return '图书'
|
||||
case 'movie':
|
||||
return '电影'
|
||||
case 'game':
|
||||
return '游戏'
|
||||
case 'anime':
|
||||
return '动画'
|
||||
case 'music':
|
||||
return '音乐'
|
||||
default:
|
||||
return value || '未分类'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReviewStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'published':
|
||||
return '已发布'
|
||||
case 'draft':
|
||||
return '草稿'
|
||||
case 'archived':
|
||||
return '已归档'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
case 'in-progress':
|
||||
return '进行中'
|
||||
default:
|
||||
return value || '未知状态'
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyToNull(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
AdminAiReindexResponse,
|
||||
AdminAiProviderTestResponse,
|
||||
AdminDashboardResponse,
|
||||
AdminPostMetadataResponse,
|
||||
AdminPostPolishResponse,
|
||||
AdminSessionResponse,
|
||||
AdminSiteSettingsResponse,
|
||||
CommentListQuery,
|
||||
@@ -12,6 +15,7 @@ import type {
|
||||
FriendLinkRecord,
|
||||
MarkdownDeleteResponse,
|
||||
MarkdownDocumentResponse,
|
||||
MarkdownImportResponse,
|
||||
PostListQuery,
|
||||
PostRecord,
|
||||
ReviewRecord,
|
||||
@@ -37,7 +41,7 @@ async function readErrorMessage(response: Response) {
|
||||
const text = await response.text().catch(() => '')
|
||||
|
||||
if (!text) {
|
||||
return `Request failed with status ${response.status}.`
|
||||
return `请求失败,状态码 ${response.status}。`
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -123,6 +127,28 @@ export const adminApi = {
|
||||
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
||||
method: 'POST',
|
||||
}),
|
||||
testAiProvider: (provider: {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
api_base: string | null
|
||||
api_key: string | null
|
||||
chat_model: string | null
|
||||
}) =>
|
||||
request<AdminAiProviderTestResponse>('/api/admin/ai/test-provider', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider }),
|
||||
}),
|
||||
generatePostMetadata: (markdown: string) =>
|
||||
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
polishPostMarkdown: (markdown: string) =>
|
||||
request<AdminPostPolishResponse>('/api/admin/ai/polish-post', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
listPosts: (query?: PostListQuery) =>
|
||||
request<PostRecord[]>(
|
||||
appendQueryParams('/api/posts', {
|
||||
@@ -147,6 +173,7 @@ export const adminApi = {
|
||||
tags: payload.tags,
|
||||
post_type: payload.postType,
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
published: payload.published,
|
||||
}),
|
||||
@@ -163,11 +190,24 @@ export const adminApi = {
|
||||
tags: payload.tags,
|
||||
post_type: payload.postType,
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
}),
|
||||
}),
|
||||
getPostMarkdown: (slug: string) =>
|
||||
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
|
||||
importPosts: async (files: File[]) => {
|
||||
const formData = new FormData()
|
||||
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.webkitRelativePath || file.name)
|
||||
})
|
||||
|
||||
return request<MarkdownImportResponse>('/api/posts/markdown/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
updatePostMarkdown: (slug: string, markdown: string) =>
|
||||
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
|
||||
method: 'PATCH',
|
||||
|
||||
32
admin/src/lib/markdown-diff.ts
Normal file
32
admin/src/lib/markdown-diff.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function normalizeMarkdown(value: string) {
|
||||
return value.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
export function countLineDiff(left: string, right: string) {
|
||||
const leftLines = normalizeMarkdown(left).split('\n')
|
||||
const rightLines = normalizeMarkdown(right).split('\n')
|
||||
const previous = new Array(rightLines.length + 1).fill(0)
|
||||
|
||||
for (let leftIndex = 1; leftIndex <= leftLines.length; leftIndex += 1) {
|
||||
const current = new Array(rightLines.length + 1).fill(0)
|
||||
|
||||
for (let rightIndex = 1; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||
if (leftLines[leftIndex - 1] === rightLines[rightIndex - 1]) {
|
||||
current[rightIndex] = previous[rightIndex - 1] + 1
|
||||
} else {
|
||||
current[rightIndex] = Math.max(previous[rightIndex], current[rightIndex - 1])
|
||||
}
|
||||
}
|
||||
|
||||
for (let rightIndex = 0; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||
previous[rightIndex] = current[rightIndex]
|
||||
}
|
||||
}
|
||||
|
||||
const common = previous[rightLines.length]
|
||||
|
||||
return {
|
||||
additions: Math.max(rightLines.length - common, 0),
|
||||
deletions: Math.max(leftLines.length - common, 0),
|
||||
}
|
||||
}
|
||||
247
admin/src/lib/markdown-document.ts
Normal file
247
admin/src/lib/markdown-document.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
|
||||
export type ParsedMarkdownMeta = {
|
||||
title: string
|
||||
slug: string
|
||||
description: string
|
||||
category: string
|
||||
postType: string
|
||||
image: string
|
||||
images: string[]
|
||||
pinned: boolean
|
||||
published: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export type ParsedMarkdownDocument = {
|
||||
meta: ParsedMarkdownMeta
|
||||
body: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const defaultMeta: ParsedMarkdownMeta = {
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
category: '',
|
||||
postType: 'article',
|
||||
image: '',
|
||||
images: [],
|
||||
pinned: false,
|
||||
published: true,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
function parseScalar(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.startsWith('"') ||
|
||||
trimmed.startsWith("'") ||
|
||||
trimmed.startsWith('[') ||
|
||||
trimmed.startsWith('{')
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return trimmed.replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (trimmed === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function toStringList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const meta: ParsedMarkdownMeta = { ...defaultMeta }
|
||||
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
meta,
|
||||
body: normalized.trimStart(),
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
const endIndex = normalized.indexOf('\n---\n', 4)
|
||||
if (endIndex === -1) {
|
||||
return {
|
||||
meta,
|
||||
body: normalized.trimStart(),
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
const frontmatter = normalized.slice(4, endIndex)
|
||||
const body = normalized.slice(endIndex + 5).trimStart()
|
||||
let currentListKey: 'tags' | 'images' | 'categories' | null = null
|
||||
const categories: string[] = []
|
||||
|
||||
frontmatter.split('\n').forEach((line) => {
|
||||
const listItemMatch = line.match(/^\s*-\s*(.+)\s*$/)
|
||||
if (listItemMatch && currentListKey) {
|
||||
const parsed = parseScalar(listItemMatch[1])
|
||||
const nextValue = typeof parsed === 'string' ? parsed.trim() : String(parsed).trim()
|
||||
if (!nextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentListKey === 'tags') {
|
||||
meta.tags.push(nextValue)
|
||||
} else if (currentListKey === 'images') {
|
||||
meta.images.push(nextValue)
|
||||
} else {
|
||||
categories.push(nextValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currentListKey = null
|
||||
|
||||
const keyMatch = line.match(/^([A-Za-z_]+):\s*(.*)$/)
|
||||
if (!keyMatch) {
|
||||
return
|
||||
}
|
||||
|
||||
const [, rawKey, rawValue] = keyMatch
|
||||
const key = rawKey.trim()
|
||||
const value = parseScalar(rawValue)
|
||||
|
||||
if (key === 'tags') {
|
||||
const tags = toStringList(value)
|
||||
if (tags.length) {
|
||||
meta.tags = tags
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'tags'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'images') {
|
||||
const images = toStringList(value)
|
||||
if (images.length) {
|
||||
meta.images = images
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'images'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'categories' || key === 'category') {
|
||||
const parsedCategories = toStringList(value)
|
||||
if (parsedCategories.length) {
|
||||
categories.push(...parsedCategories)
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'categories'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'title':
|
||||
meta.title = String(value).trim()
|
||||
break
|
||||
case 'slug':
|
||||
meta.slug = String(value).trim()
|
||||
break
|
||||
case 'description':
|
||||
meta.description = String(value).trim()
|
||||
break
|
||||
case 'post_type':
|
||||
meta.postType = String(value).trim() || 'article'
|
||||
break
|
||||
case 'image':
|
||||
meta.image = String(value).trim()
|
||||
break
|
||||
case 'pinned':
|
||||
meta.pinned = Boolean(value)
|
||||
break
|
||||
case 'published':
|
||||
meta.published = value !== false
|
||||
break
|
||||
case 'draft':
|
||||
if (value === true) {
|
||||
meta.published = false
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
meta.category = categories[0] ?? meta.category
|
||||
|
||||
return {
|
||||
meta,
|
||||
body,
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
|
||||
const lines = [
|
||||
'---',
|
||||
`title: ${JSON.stringify(meta.title.trim() || meta.slug || 'untitled-post')}`,
|
||||
`slug: ${meta.slug.trim() || 'untitled-post'}`,
|
||||
]
|
||||
|
||||
if (meta.description.trim()) {
|
||||
lines.push(`description: ${JSON.stringify(meta.description.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.category.trim()) {
|
||||
lines.push(`category: ${JSON.stringify(meta.category.trim())}`)
|
||||
}
|
||||
|
||||
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
|
||||
lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`)
|
||||
lines.push(`published: ${meta.published ? 'true' : 'false'}`)
|
||||
|
||||
if (meta.image.trim()) {
|
||||
lines.push(`image: ${JSON.stringify(meta.image.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.images.length) {
|
||||
lines.push('images:')
|
||||
meta.images.forEach((image) => {
|
||||
lines.push(` - ${JSON.stringify(image)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.tags.length) {
|
||||
lines.push('tags:')
|
||||
meta.tags.forEach((tag) => {
|
||||
lines.push(` - ${JSON.stringify(tag)}`)
|
||||
})
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n---\n\n${body.trim()}\n`
|
||||
}
|
||||
149
admin/src/lib/markdown-merge.ts
Normal file
149
admin/src/lib/markdown-merge.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
|
||||
type DiffOperation =
|
||||
| { type: 'equal'; line: string }
|
||||
| { type: 'delete'; line: string }
|
||||
| { type: 'insert'; line: string }
|
||||
|
||||
export type DiffHunk = {
|
||||
id: string
|
||||
originalStart: number
|
||||
originalEnd: number
|
||||
modifiedStart: number
|
||||
modifiedEnd: number
|
||||
removedLines: string[]
|
||||
addedLines: string[]
|
||||
preview: string
|
||||
}
|
||||
|
||||
function diffOperations(originalLines: string[], modifiedLines: string[]) {
|
||||
const rows = originalLines.length
|
||||
const cols = modifiedLines.length
|
||||
const dp = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0))
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
for (let col = 1; col <= cols; col += 1) {
|
||||
if (originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||
dp[row][col] = dp[row - 1][col - 1] + 1
|
||||
} else {
|
||||
dp[row][col] = Math.max(dp[row - 1][col], dp[row][col - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const operations: DiffOperation[] = []
|
||||
let row = rows
|
||||
let col = cols
|
||||
|
||||
while (row > 0 || col > 0) {
|
||||
if (row > 0 && col > 0 && originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||
operations.push({ type: 'equal', line: originalLines[row - 1] })
|
||||
row -= 1
|
||||
col -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (col > 0 && (row === 0 || dp[row][col - 1] >= dp[row - 1][col])) {
|
||||
operations.push({ type: 'insert', line: modifiedLines[col - 1] })
|
||||
col -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
operations.push({ type: 'delete', line: originalLines[row - 1] })
|
||||
row -= 1
|
||||
}
|
||||
|
||||
return operations.reverse()
|
||||
}
|
||||
|
||||
export function computeDiffHunks(original: string, modified: string): DiffHunk[] {
|
||||
const originalLines = normalizeMarkdown(original).split('\n')
|
||||
const modifiedLines = normalizeMarkdown(modified).split('\n')
|
||||
const operations = diffOperations(originalLines, modifiedLines)
|
||||
const hunks: DiffHunk[] = []
|
||||
let originalLine = 1
|
||||
let modifiedLine = 1
|
||||
let current:
|
||||
| (Omit<DiffHunk, 'id' | 'originalEnd' | 'modifiedEnd' | 'preview'> & {
|
||||
idSeed: number
|
||||
})
|
||||
| null = null
|
||||
|
||||
const flush = () => {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
const previewSource = current.addedLines.join(' ').trim() || current.removedLines.join(' ').trim()
|
||||
hunks.push({
|
||||
id: `hunk-${current.idSeed}`,
|
||||
originalStart: current.originalStart,
|
||||
originalEnd: originalLine - 1,
|
||||
modifiedStart: current.modifiedStart,
|
||||
modifiedEnd: modifiedLine - 1,
|
||||
removedLines: current.removedLines,
|
||||
addedLines: current.addedLines,
|
||||
preview: previewSource.slice(0, 120) || '空白改动',
|
||||
})
|
||||
current = null
|
||||
}
|
||||
|
||||
operations.forEach((operation) => {
|
||||
if (operation.type === 'equal') {
|
||||
flush()
|
||||
originalLine += 1
|
||||
modifiedLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = {
|
||||
idSeed: hunks.length + 1,
|
||||
originalStart: originalLine,
|
||||
modifiedStart: modifiedLine,
|
||||
removedLines: [],
|
||||
addedLines: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (operation.type === 'delete') {
|
||||
current.removedLines.push(operation.line)
|
||||
originalLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
current.addedLines.push(operation.line)
|
||||
modifiedLine += 1
|
||||
})
|
||||
|
||||
flush()
|
||||
|
||||
return hunks
|
||||
}
|
||||
|
||||
export function applySelectedDiffHunks(
|
||||
original: string,
|
||||
hunks: DiffHunk[],
|
||||
selectedIds: Set<string>,
|
||||
) {
|
||||
const originalLines = normalizeMarkdown(original).split('\n')
|
||||
const resultLines: string[] = []
|
||||
let cursor = 1
|
||||
|
||||
hunks.forEach((hunk) => {
|
||||
const unchangedEnd = Math.max(hunk.originalStart - 1, cursor - 1)
|
||||
resultLines.push(...originalLines.slice(cursor - 1, unchangedEnd))
|
||||
|
||||
if (selectedIds.has(hunk.id)) {
|
||||
resultLines.push(...hunk.addedLines)
|
||||
} else if (hunk.originalEnd >= hunk.originalStart) {
|
||||
resultLines.push(...originalLines.slice(hunk.originalStart - 1, hunk.originalEnd))
|
||||
}
|
||||
|
||||
cursor = Math.max(hunk.originalEnd + 1, hunk.originalStart)
|
||||
})
|
||||
|
||||
resultLines.push(...originalLines.slice(cursor - 1))
|
||||
|
||||
return resultLines.join('\n')
|
||||
}
|
||||
82
admin/src/lib/post-draft-window.ts
Normal file
82
admin/src/lib/post-draft-window.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type DraftWindowSnapshot = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
markdown: string
|
||||
savedMarkdown: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'termi-admin-post-draft:'
|
||||
const POLISH_RESULT_PREFIX = 'termi-admin-post-polish-result:'
|
||||
|
||||
export type PolishWindowResult = {
|
||||
draftKey: string
|
||||
markdown: string
|
||||
target: 'editor' | 'create'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function saveDraftWindowSnapshot(snapshot: Omit<DraftWindowSnapshot, 'createdAt'>) {
|
||||
const key = `${STORAGE_PREFIX}${snapshot.slug}:${Date.now()}`
|
||||
const payload: DraftWindowSnapshot = {
|
||||
...snapshot,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(payload))
|
||||
return key
|
||||
}
|
||||
|
||||
export function loadDraftWindowSnapshot(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as DraftWindowSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function savePolishWindowResult(
|
||||
draftKey: string,
|
||||
markdown: string,
|
||||
target: 'editor' | 'create',
|
||||
) {
|
||||
const payload: PolishWindowResult = {
|
||||
draftKey,
|
||||
markdown,
|
||||
target,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
window.localStorage.setItem(`${POLISH_RESULT_PREFIX}${draftKey}`, JSON.stringify(payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
export function consumePolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(storageKey)
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as PolishWindowResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,15 @@ export interface AdminSiteSettingsResponse {
|
||||
social_email: string | null
|
||||
location: string | null
|
||||
tech_stack: string[]
|
||||
music_playlist: MusicTrack[]
|
||||
ai_enabled: boolean
|
||||
paragraph_comments_enabled: boolean
|
||||
ai_provider: string | null
|
||||
ai_api_base: string | null
|
||||
ai_api_key: string | null
|
||||
ai_chat_model: string | null
|
||||
ai_providers: AiProviderConfig[]
|
||||
ai_active_provider_id: string | null
|
||||
ai_embedding_model: string | null
|
||||
ai_system_prompt: string | null
|
||||
ai_top_k: number | null
|
||||
@@ -103,6 +107,15 @@ export interface AdminSiteSettingsResponse {
|
||||
ai_local_embedding: string
|
||||
}
|
||||
|
||||
export interface AiProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
api_base: string | null
|
||||
api_key: string | null
|
||||
chat_model: string | null
|
||||
}
|
||||
|
||||
export interface SiteSettingsPayload {
|
||||
siteName?: string | null
|
||||
siteShortName?: string | null
|
||||
@@ -120,11 +133,15 @@ export interface SiteSettingsPayload {
|
||||
socialEmail?: string | null
|
||||
location?: string | null
|
||||
techStack?: string[]
|
||||
musicPlaylist?: MusicTrack[]
|
||||
aiEnabled?: boolean
|
||||
paragraphCommentsEnabled?: boolean
|
||||
aiProvider?: string | null
|
||||
aiApiBase?: string | null
|
||||
aiApiKey?: string | null
|
||||
aiChatModel?: string | null
|
||||
aiProviders?: AiProviderConfig[]
|
||||
aiActiveProviderId?: string | null
|
||||
aiEmbeddingModel?: string | null
|
||||
aiSystemPrompt?: string | null
|
||||
aiTopK?: number | null
|
||||
@@ -136,6 +153,35 @@ export interface AdminAiReindexResponse {
|
||||
last_indexed_at: string | null
|
||||
}
|
||||
|
||||
export interface AdminAiProviderTestResponse {
|
||||
provider: string
|
||||
endpoint: string
|
||||
chat_model: string
|
||||
reply_preview: string
|
||||
}
|
||||
|
||||
export interface MusicTrack {
|
||||
title: string
|
||||
artist?: string | null
|
||||
album?: string | null
|
||||
url: string
|
||||
cover_image_url?: string | null
|
||||
accent_color?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface AdminPostMetadataResponse {
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface AdminPostPolishResponse {
|
||||
polished_markdown: string
|
||||
}
|
||||
|
||||
export interface PostRecord {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -148,6 +194,7 @@ export interface PostRecord {
|
||||
tags: unknown
|
||||
post_type: string | null
|
||||
image: string | null
|
||||
images: string[] | null
|
||||
pinned: boolean | null
|
||||
}
|
||||
|
||||
@@ -169,6 +216,7 @@ export interface CreatePostPayload {
|
||||
tags?: string[]
|
||||
postType?: string | null
|
||||
image?: string | null
|
||||
images?: string[] | null
|
||||
pinned?: boolean
|
||||
published?: boolean
|
||||
}
|
||||
@@ -182,6 +230,7 @@ export interface UpdatePostPayload {
|
||||
tags?: unknown
|
||||
postType?: string | null
|
||||
image?: string | null
|
||||
images?: string[] | null
|
||||
pinned?: boolean | null
|
||||
}
|
||||
|
||||
@@ -196,6 +245,11 @@ export interface MarkdownDeleteResponse {
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface MarkdownImportResponse {
|
||||
count: number
|
||||
slugs: string[]
|
||||
}
|
||||
|
||||
export interface CommentRecord {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -273,6 +327,7 @@ export interface ReviewRecord {
|
||||
description: string | null
|
||||
tags: string | null
|
||||
cover: string | null
|
||||
link_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -286,6 +341,7 @@ export interface CreateReviewPayload {
|
||||
description: string
|
||||
tags: string[]
|
||||
cover: string
|
||||
link_url?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateReviewPayload {
|
||||
@@ -297,4 +353,5 @@ export interface UpdateReviewPayload {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
link_url?: string | null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { formatDateTime } from '@/lib/admin-format'
|
||||
import { formatCommentScope, formatDateTime } from '@/lib/admin-format'
|
||||
import type { CommentRecord } from '@/lib/types'
|
||||
|
||||
function moderationBadgeVariant(approved: boolean | null) {
|
||||
@@ -49,13 +49,13 @@ export function CommentsPage() {
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Comments refreshed.')
|
||||
toast.success('评论列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load comments.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载评论列表。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -106,59 +106,58 @@ export function CommentsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Comments</Badge>
|
||||
<Badge variant="secondary">评论</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Moderation queue</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">评论审核队列</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
Review article comments and paragraph-specific responses from one place, with fast
|
||||
approval controls for the public discussion layer.
|
||||
在一个页面中处理全文评论与段落评论,快速完成公开讨论区的审核工作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={() => void loadComments(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Pending</p>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">待审核</p>
|
||||
<div className="mt-3 text-3xl font-semibold">{pendingCount}</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Needs moderation attention.</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">需要人工审核处理。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Paragraph replies
|
||||
段落评论
|
||||
</p>
|
||||
<div className="mt-3 text-3xl font-semibold">{paragraphCount}</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Scoped to paragraph anchors.</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">挂载到具体段落锚点。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Total</p>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">总数</p>
|
||||
<div className="mt-3 text-3xl font-semibold">{comments.length}</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Everything currently stored.</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">当前系统中全部评论。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Comment list</CardTitle>
|
||||
<CardTitle>评论列表</CardTitle>
|
||||
<CardDescription>
|
||||
Filter the queue, then approve, hide, or remove entries without leaving the page.
|
||||
先筛选,再直接通过、隐藏或删除评论,无需离开当前页面。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
|
||||
<Input
|
||||
placeholder="Search by author, post slug, content, or paragraph key"
|
||||
placeholder="按作者、文章 slug、评论内容或段落键搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
@@ -166,14 +165,14 @@ export function CommentsPage() {
|
||||
value={approvalFilter}
|
||||
onChange={(event) => setApprovalFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">All approval states</option>
|
||||
<option value="pending">Pending only</option>
|
||||
<option value="approved">Approved only</option>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">仅看待审核</option>
|
||||
<option value="approved">仅看已通过</option>
|
||||
</Select>
|
||||
<Select value={scopeFilter} onChange={(event) => setScopeFilter(event.target.value)}>
|
||||
<option value="all">All scopes</option>
|
||||
<option value="article">Article</option>
|
||||
<option value="paragraph">Paragraph</option>
|
||||
<option value="all">全部范围</option>
|
||||
<option value="article">全文</option>
|
||||
<option value="paragraph">段落</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -183,10 +182,10 @@ export function CommentsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Context</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>评论内容</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>上下文</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -195,20 +194,20 @@ export function CommentsPage() {
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{comment.author ?? 'Anonymous'}</span>
|
||||
<Badge variant="outline">{comment.scope}</Badge>
|
||||
<span className="font-medium">{comment.author ?? '匿名用户'}</span>
|
||||
<Badge variant="outline">{formatCommentScope(comment.scope)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{comment.content ?? 'No content provided.'}
|
||||
{comment.content ?? '暂无评论内容。'}
|
||||
</p>
|
||||
{comment.scope === 'paragraph' ? (
|
||||
<div className="rounded-2xl border border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<p className="font-mono">{comment.paragraph_key ?? 'missing-key'}</p>
|
||||
<p className="font-mono">{comment.paragraph_key ?? '缺少段落键'}</p>
|
||||
<p className="mt-1">
|
||||
{comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
|
||||
{comment.paragraph_excerpt ?? '没有保存段落摘录。'}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -216,16 +215,16 @@ export function CommentsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={moderationBadgeVariant(comment.approved)}>
|
||||
{comment.approved ? 'Approved' : 'Pending'}
|
||||
{comment.approved ? '已通过' : '待审核'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p className="font-mono text-xs">{comment.post_slug ?? 'unknown-post'}</p>
|
||||
<p className="font-mono text-xs">{comment.post_slug ?? '未知文章'}</p>
|
||||
{comment.reply_to_comment_id ? (
|
||||
<p>Replying to #{comment.reply_to_comment_id}</p>
|
||||
<p>回复评论 #{comment.reply_to_comment_id}</p>
|
||||
) : (
|
||||
<p>Top-level comment</p>
|
||||
<p>顶级评论</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -239,13 +238,11 @@ export function CommentsPage() {
|
||||
try {
|
||||
setActingId(comment.id)
|
||||
await adminApi.updateComment(comment.id, { approved: true })
|
||||
toast.success('Comment approved.')
|
||||
toast.success('评论已通过。')
|
||||
await loadComments(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: 'Unable to approve comment.',
|
||||
error instanceof ApiError ? error.message : '无法通过该评论。',
|
||||
)
|
||||
} finally {
|
||||
setActingId(null)
|
||||
@@ -253,7 +250,7 @@ export function CommentsPage() {
|
||||
}}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Approve
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -263,13 +260,11 @@ export function CommentsPage() {
|
||||
try {
|
||||
setActingId(comment.id)
|
||||
await adminApi.updateComment(comment.id, { approved: false })
|
||||
toast.success('Comment moved back to pending.')
|
||||
toast.success('评论已移回待审核。')
|
||||
await loadComments(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: 'Unable to update comment.',
|
||||
error instanceof ApiError ? error.message : '无法更新评论状态。',
|
||||
)
|
||||
} finally {
|
||||
setActingId(null)
|
||||
@@ -277,27 +272,25 @@ export function CommentsPage() {
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Hide
|
||||
隐藏
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={actingId === comment.id}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('Delete this comment permanently?')) {
|
||||
if (!window.confirm('确定要永久删除这条评论吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setActingId(comment.id)
|
||||
await adminApi.deleteComment(comment.id)
|
||||
toast.success('Comment deleted.')
|
||||
toast.success('评论已删除。')
|
||||
await loadComments(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: 'Unable to delete comment.',
|
||||
error instanceof ApiError ? error.message : '无法删除评论。',
|
||||
)
|
||||
} finally {
|
||||
setActingId(null)
|
||||
@@ -305,7 +298,7 @@ export function CommentsPage() {
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -316,7 +309,7 @@ export function CommentsPage() {
|
||||
<TableCell colSpan={4} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<MessageSquareText className="h-8 w-8" />
|
||||
<p>No comments match the current moderation filters.</p>
|
||||
<p>当前筛选条件下没有匹配的评论。</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCommentScope,
|
||||
formatFriendLinkStatus,
|
||||
formatPostType,
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
} from '@/lib/admin-format'
|
||||
import type { AdminDashboardResponse } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
@@ -70,13 +77,13 @@ export function DashboardPage() {
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Dashboard refreshed.')
|
||||
toast.success('仪表盘已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load dashboard.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -102,27 +109,27 @@ export function DashboardPage() {
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Posts',
|
||||
label: '文章总数',
|
||||
value: data.stats.total_posts,
|
||||
note: `${data.stats.total_comments} comments across the content library`,
|
||||
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||
icon: Rss,
|
||||
},
|
||||
{
|
||||
label: 'Pending comments',
|
||||
label: '待审核评论',
|
||||
value: data.stats.pending_comments,
|
||||
note: 'Queued for moderation follow-up',
|
||||
note: '等待审核处理',
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
label: '分类数量',
|
||||
value: data.stats.total_categories,
|
||||
note: `${data.stats.total_tags} tags currently in circulation`,
|
||||
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
label: 'AI chunks',
|
||||
label: 'AI 分块',
|
||||
value: data.stats.ai_chunks,
|
||||
note: data.stats.ai_enabled ? 'Knowledge base is enabled' : 'AI is currently disabled',
|
||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
]
|
||||
@@ -131,12 +138,11 @@ export function DashboardPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Dashboard</Badge>
|
||||
<Badge variant="secondary">仪表盘</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Operations overview</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">运营总览</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
This screen brings the most important publishing, moderation, and AI signals into the
|
||||
new standalone admin so the day-to-day control loop stays in one place.
|
||||
这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +151,7 @@ export function DashboardPage() {
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
Open Ask AI
|
||||
打开 AI 问答
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -154,7 +160,7 @@ export function DashboardPage() {
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,21 +175,21 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Recent posts</CardTitle>
|
||||
<CardTitle>最近文章</CardTitle>
|
||||
<CardDescription>
|
||||
Freshly imported or updated content flowing into the public site.
|
||||
最近同步到前台的文章内容。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_posts.length} rows</Badge>
|
||||
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -193,13 +199,13 @@ export function DashboardPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{post.title}</span>
|
||||
{post.pinned ? <Badge variant="success">pinned</Badge> : null}
|
||||
{post.pinned ? <Badge variant="success">置顶</Badge> : null}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{post.post_type}
|
||||
{formatPostType(post.post_type)}
|
||||
</TableCell>
|
||||
<TableCell>{post.category}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
|
||||
@@ -212,9 +218,9 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Site heartbeat</CardTitle>
|
||||
<CardTitle>站点状态</CardTitle>
|
||||
<CardDescription>
|
||||
A quick read on the public-facing site and the AI index state.
|
||||
快速查看前台站点与 AI 索引状态。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -225,7 +231,7 @@ export function DashboardPage() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
|
||||
</div>
|
||||
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
||||
{data.site.ai_enabled ? 'AI on' : 'AI off'}
|
||||
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +239,7 @@ export function DashboardPage() {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Reviews
|
||||
评测
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||
@@ -242,7 +248,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Friend links
|
||||
友链
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
||||
@@ -253,10 +259,10 @@ export function DashboardPage() {
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Last AI index
|
||||
最近一次 AI 索引
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{data.site.ai_last_indexed_at ?? 'The site has not been indexed yet.'}
|
||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -267,21 +273,21 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending comments</CardTitle>
|
||||
<CardTitle>待审核评论</CardTitle>
|
||||
<CardDescription>
|
||||
Queue visibility without opening the old moderation page.
|
||||
不进入旧后台也能查看审核队列。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} queued</Badge>
|
||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Post</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>范围</TableHead>
|
||||
<TableHead>文章</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -296,7 +302,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{comment.scope}
|
||||
{formatCommentScope(comment.scope)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{comment.post_slug}
|
||||
@@ -313,12 +319,12 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending friend links</CardTitle>
|
||||
<CardTitle>待审核友链</CardTitle>
|
||||
<CardDescription>
|
||||
Requests waiting for review and reciprocal checks.
|
||||
等待审核和互链确认的申请。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} pending</Badge>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.pending_friend_links.map((link) => (
|
||||
@@ -335,6 +341,9 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<Badge variant="outline">{link.category}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
状态:{formatFriendLinkStatus(link.status)}
|
||||
</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{link.created_at}
|
||||
</p>
|
||||
@@ -345,9 +354,9 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent reviews</CardTitle>
|
||||
<CardTitle>最近评测</CardTitle>
|
||||
<CardDescription>
|
||||
The latest review entries flowing into the public reviews page.
|
||||
最近同步到前台评测页的内容。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -359,7 +368,7 @@ export function DashboardPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{review.review_type} · {review.status}
|
||||
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
|
||||
import { emptyToNull, formatDateTime, formatFriendLinkStatus } from '@/lib/admin-format'
|
||||
import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types'
|
||||
|
||||
type FriendLinkFormState = {
|
||||
@@ -88,13 +88,13 @@ export function FriendLinksPage() {
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Friend links refreshed.')
|
||||
toast.success('友链列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load friend links.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载友链列表。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -135,12 +135,11 @@ export function FriendLinksPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Friend links</Badge>
|
||||
<Badge variant="secondary">友链</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Partner site queue</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">友链申请队列</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
Review inbound link exchanges, keep metadata accurate, and move requests through
|
||||
pending, approved, or rejected states in one dedicated workspace.
|
||||
审核前台提交的友链申请,维护站点信息,并在待审核、已通过、已拒绝之间完成流转。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,11 +152,11 @@ export function FriendLinksPage() {
|
||||
setForm(defaultFriendLinkForm)
|
||||
}}
|
||||
>
|
||||
New link
|
||||
新建友链
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadLinks(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,15 +164,15 @@ export function FriendLinksPage() {
|
||||
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Link inventory</CardTitle>
|
||||
<CardTitle>友链列表</CardTitle>
|
||||
<CardDescription>
|
||||
Pick an item to edit it, or start a new record from the right-hand form.
|
||||
选择一条友链进行编辑,或者直接在右侧创建新记录。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||
<Input
|
||||
placeholder="Search by site name, URL, category, or notes"
|
||||
placeholder="按站点名、URL、分类或备注搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
@@ -181,10 +180,10 @@ export function FriendLinksPage() {
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -209,18 +208,18 @@ export function FriendLinksPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{link.site_name ?? 'Untitled partner'}</span>
|
||||
<span className="font-medium">{link.site_name ?? '未命名站点'}</span>
|
||||
<Badge variant={statusBadgeVariant(link.status)}>
|
||||
{link.status ?? 'pending'}
|
||||
{formatFriendLinkStatus(link.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{link.site_url}</p>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{link.description ?? 'No description yet.'}
|
||||
{link.description ?? '暂无简介。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{link.category ?? 'uncategorized'}</p>
|
||||
<p>{link.category ?? '未分类'}</p>
|
||||
<p className="mt-1">{formatDateTime(link.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +229,7 @@ export function FriendLinksPage() {
|
||||
{!filteredLinks.length ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||
<Link2 className="h-8 w-8" />
|
||||
<p>No friend links match the current filters.</p>
|
||||
<p>当前筛选条件下没有匹配的友链。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -241,10 +240,9 @@ export function FriendLinksPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedLink ? 'Edit friend link' : 'Create friend link'}</CardTitle>
|
||||
<CardTitle>{selectedLink ? '编辑友链' : '新建友链'}</CardTitle>
|
||||
<CardDescription>
|
||||
Capture the reciprocal URL, classification, and moderation status the public link
|
||||
page depends on.
|
||||
维护前台友链页依赖的互链地址、分类和审核状态。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -252,14 +250,36 @@ export function FriendLinksPage() {
|
||||
<Button variant="outline" asChild>
|
||||
<a href={selectedLink.site_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Visit site
|
||||
访问站点
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedLink ? (
|
||||
<>
|
||||
<Button
|
||||
variant={form.status === 'approved' ? 'default' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
variant={form.status === 'pending' ? 'secondary' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||
>
|
||||
待审核
|
||||
</Button>
|
||||
<Button
|
||||
variant={form.status === 'rejected' ? 'danger' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!form.siteUrl.trim()) {
|
||||
toast.error('Site URL is required.')
|
||||
toast.error('站点 URL 不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -272,20 +292,18 @@ export function FriendLinksPage() {
|
||||
setSelectedId(updated.id)
|
||||
setForm(toFormState(updated))
|
||||
})
|
||||
toast.success('Friend link updated.')
|
||||
toast.success('友链已更新。')
|
||||
} else {
|
||||
const created = await adminApi.createFriendLink(payload)
|
||||
startTransition(() => {
|
||||
setSelectedId(created.id)
|
||||
setForm(toFormState(created))
|
||||
})
|
||||
toast.success('Friend link created.')
|
||||
toast.success('友链已创建。')
|
||||
}
|
||||
await loadLinks(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to save friend link.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '无法保存友链。')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -293,35 +311,33 @@ export function FriendLinksPage() {
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : selectedLink ? 'Save changes' : 'Create link'}
|
||||
{saving ? '保存中...' : selectedLink ? '保存修改' : '创建友链'}
|
||||
</Button>
|
||||
{selectedLink ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('Delete this friend link?')) {
|
||||
if (!window.confirm('确定删除这条友链吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true)
|
||||
await adminApi.deleteFriendLink(selectedLink.id)
|
||||
toast.success('Friend link deleted.')
|
||||
toast.success('友链已删除。')
|
||||
setSelectedId(null)
|
||||
setForm(defaultFriendLinkForm)
|
||||
await loadLinks(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to delete friend link.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '无法删除友链。')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -332,21 +348,21 @@ export function FriendLinksPage() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Selected record
|
||||
当前记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Created {formatDateTime(selectedLink.created_at)}
|
||||
创建于 {formatDateTime(selectedLink.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(selectedLink.status)}>
|
||||
{selectedLink.status ?? 'pending'}
|
||||
{formatFriendLinkStatus(selectedLink.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FormField label="Site name">
|
||||
<FormField label="站点名称">
|
||||
<Input
|
||||
value={form.siteName}
|
||||
onChange={(event) =>
|
||||
@@ -354,7 +370,7 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Site URL">
|
||||
<FormField label="站点 URL">
|
||||
<Input
|
||||
value={form.siteUrl}
|
||||
onChange={(event) =>
|
||||
@@ -362,7 +378,7 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Avatar URL">
|
||||
<FormField label="头像 URL">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
@@ -370,7 +386,7 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Category">
|
||||
<FormField label="分类">
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(event) =>
|
||||
@@ -379,21 +395,49 @@ export function FriendLinksPage() {
|
||||
/>
|
||||
</FormField>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="Status">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</Select>
|
||||
<FormField label="状态">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'pending'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">待审核</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">保留在队列里继续观察。</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'approved'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">通过</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">前台会按已通过友链展示。</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'rejected'
|
||||
? 'border-rose-500/40 bg-rose-500/10 text-rose-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">拒绝</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">保留记录,但不在前台展示。</p>
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="Description">
|
||||
<FormField label="简介">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -23,23 +23,22 @@ export function LoginPage({
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Termi admin
|
||||
Termi 后台
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-4xl leading-tight">
|
||||
Separate the dashboard from the public site without losing momentum.
|
||||
将后台从前台中拆分出来,同时保持迭代节奏不掉线。
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xl text-base leading-7">
|
||||
This new workspace is where operations, moderation, and AI controls will migrate
|
||||
out of the old server-rendered admin.
|
||||
新工作台会逐步承接运营、审核与 AI 配置,把旧的服务端渲染后台平滑替换掉。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
['React app', 'Independent admin surface'],
|
||||
['shadcn/ui', 'Consistent component foundation'],
|
||||
['Loco API', 'Backend stays focused on data and rules'],
|
||||
['React 应用', '独立后台界面'],
|
||||
['shadcn/ui', '统一的组件基础'],
|
||||
['Loco API', '后端继续专注数据与规则'],
|
||||
].map(([title, description]) => (
|
||||
<div
|
||||
key={title}
|
||||
@@ -58,11 +57,10 @@ export function LoginPage({
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<LockKeyhole className="h-5 w-5" />
|
||||
</span>
|
||||
Sign in to the control room
|
||||
登录管理后台
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The login bridge still uses the current backend admin credentials so we can migrate
|
||||
screens incrementally without stopping delivery.
|
||||
当前登录仍复用后端已有管理员账号,这样可以一边迁移页面,一边保证功能持续可用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -74,7 +72,7 @@ export function LoginPage({
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
@@ -85,7 +83,7 @@ export function LoginPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -97,7 +95,7 @@ export function LoginPage({
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? 'Signing in...' : 'Unlock admin'}
|
||||
{submitting ? '登录中...' : '进入后台'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
166
admin/src/pages/post-compare-page.tsx
Normal file
166
admin/src/pages/post-compare-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { countLineDiff } from '@/lib/markdown-diff'
|
||||
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||
|
||||
type CompareState = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
savedMarkdown: string
|
||||
draftMarkdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<CompareState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
])
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
|
||||
draftMarkdown: draft?.markdown ?? markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setError(loadError instanceof ApiError ? loadError.message : '无法加载改动对比。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
const diffStats = useMemo(() => {
|
||||
if (!state) {
|
||||
return { additions: 0, deletions: 0 }
|
||||
}
|
||||
|
||||
return countLineDiff(state.savedMarkdown, state.draftMarkdown)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1480px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">独立对比窗口</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{state?.title || '草稿改动对比'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
左侧是当前已保存的正文,右侧是你正在编辑的草稿。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="success">+{diffStats.additions} 行</Badge>
|
||||
<Badge variant="danger">-{diffStats.deletions} 行</Badge>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载改动内容...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>改动对比加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : state ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GitCompareArrows className="h-4 w-4" />
|
||||
保存版本 vs 当前草稿
|
||||
</CardTitle>
|
||||
<CardDescription>{state.path}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<MarkdownWorkbench
|
||||
value={state.draftMarkdown}
|
||||
originalValue={state.savedMarkdown}
|
||||
path={state.path}
|
||||
mode="workspace"
|
||||
visiblePanels={['diff']}
|
||||
availablePanels={['diff']}
|
||||
readOnly
|
||||
preview={<></>}
|
||||
originalLabel="已保存版本"
|
||||
modifiedLabel="当前草稿"
|
||||
onModeChange={() => {}}
|
||||
onVisiblePanelsChange={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
admin/src/pages/post-polish-page.tsx
Normal file
302
admin/src/pages/post-polish-page.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { DiffEditor } from '@monaco-editor/react'
|
||||
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
configureMonaco,
|
||||
editorTheme,
|
||||
sharedOptions,
|
||||
} from '@/components/markdown-workbench'
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { computeDiffHunks, applySelectedDiffHunks } from '@/lib/markdown-merge'
|
||||
import {
|
||||
loadDraftWindowSnapshot,
|
||||
savePolishWindowResult,
|
||||
type DraftWindowSnapshot,
|
||||
} from '@/lib/post-draft-window'
|
||||
|
||||
type PolishTarget = 'editor' | 'create'
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
function getTarget(): PolishTarget {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'editor'
|
||||
}
|
||||
|
||||
const value = new URLSearchParams(window.location.search).get('target')
|
||||
return value === 'create' ? 'create' : 'editor'
|
||||
}
|
||||
|
||||
function buildApplyMessage(draftKey: string, markdown: string, target: PolishTarget) {
|
||||
return {
|
||||
type: 'termi-admin-post-polish-apply',
|
||||
draftKey,
|
||||
markdown,
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
export function PostPolishPage() {
|
||||
const draftKey = getDraftKey()
|
||||
const target = getTarget()
|
||||
const [snapshot, setSnapshot] = useState<DraftWindowSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [polishing, setPolishing] = useState(false)
|
||||
const [polishedMarkdown, setPolishedMarkdown] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraftWindowSnapshot(draftKey)
|
||||
if (!draft) {
|
||||
setError('没有找到要润色的草稿快照,请从文章编辑页重新打开 AI 润色窗口。')
|
||||
} else {
|
||||
startTransition(() => {
|
||||
setSnapshot(draft)
|
||||
})
|
||||
}
|
||||
setLoading(false)
|
||||
}, [draftKey])
|
||||
|
||||
const originalMarkdown = snapshot?.markdown ?? ''
|
||||
const hunks = useMemo(
|
||||
() => (polishedMarkdown ? computeDiffHunks(originalMarkdown, polishedMarkdown) : []),
|
||||
[originalMarkdown, polishedMarkdown],
|
||||
)
|
||||
const mergedMarkdown = useMemo(
|
||||
() => applySelectedDiffHunks(originalMarkdown, hunks, selectedIds),
|
||||
[hunks, originalMarkdown, selectedIds],
|
||||
)
|
||||
|
||||
const applyAll = () => {
|
||||
setSelectedIds(new Set(hunks.map((hunk) => hunk.id)))
|
||||
}
|
||||
|
||||
const keepOriginal = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const applyToParent = () => {
|
||||
if (!draftKey) {
|
||||
toast.error('当前窗口缺少草稿标识,无法回填。')
|
||||
return
|
||||
}
|
||||
|
||||
const result = savePolishWindowResult(draftKey, mergedMarkdown, target)
|
||||
window.opener?.postMessage(buildApplyMessage(draftKey, mergedMarkdown, target), window.location.origin)
|
||||
toast.success('已把 AI 润色结果回填到原编辑器。')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1560px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">AI 润色工作台</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{snapshot?.title || 'AI 润色与选择性合并'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
左边保留润色前的原稿,右边是当前选中的合并结果。你可以先生成 AI 润色稿,再按改动块决定要保留哪些内容。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!snapshot || polishing}
|
||||
onClick={async () => {
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPolishing(true)
|
||||
const result = await adminApi.polishPostMarkdown(snapshot.markdown)
|
||||
const nextHunks = computeDiffHunks(snapshot.markdown, result.polished_markdown)
|
||||
startTransition(() => {
|
||||
setPolishedMarkdown(result.polished_markdown)
|
||||
setSelectedIds(new Set(nextHunks.map((hunk) => hunk.id)))
|
||||
})
|
||||
toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`)
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : 'AI 润色失败。')
|
||||
} finally {
|
||||
setPolishing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{polishing ? '润色中...' : polishedMarkdown ? '重新生成润色稿' : '生成 AI 润色稿'}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={!hunks.length} onClick={applyAll}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
全部采用
|
||||
</Button>
|
||||
<Button variant="outline" disabled={!hunks.length} onClick={keepOriginal}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
全部还原
|
||||
</Button>
|
||||
<Button disabled={!hunks.length} onClick={applyToParent}>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
应用到原编辑器
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载草稿快照...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 润色窗口加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : snapshot ? (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.14fr_0.86fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>润色前 vs 当前合并结果</CardTitle>
|
||||
<CardDescription>{snapshot.path}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="secondary">改动块 {hunks.length}</Badge>
|
||||
<Badge variant="success">已采用 {selectedIds.size}</Badge>
|
||||
<Badge variant="outline">目标 {target === 'create' ? '新建草稿' : '现有文章'}</Badge>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e]">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||
<span>润色前原稿</span>
|
||||
<span>当前合并结果</span>
|
||||
</div>
|
||||
<div className="h-[560px]">
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
original={originalMarkdown}
|
||||
modified={mergedMarkdown}
|
||||
originalModelPath={`${snapshot.path}#ai-original`}
|
||||
modifiedModelPath={`${snapshot.path}#ai-merged`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前合并结果预览</CardTitle>
|
||||
<CardDescription>边挑选改动,边查看最终会保存成什么样。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[420px] overflow-hidden rounded-[28px] border border-slate-200 bg-white">
|
||||
<MarkdownPreview markdown={mergedMarkdown || originalMarkdown} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>改动块选择</CardTitle>
|
||||
<CardDescription>
|
||||
每一块都可以单独采用或保留原文,合并结果会立即同步到右侧 diff 和预览。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!polishedMarkdown ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||
先点“生成 AI 润色稿”,这里才会出现可选的改动块。
|
||||
</div>
|
||||
) : hunks.length ? (
|
||||
hunks.map((hunk, index) => {
|
||||
const accepted = selectedIds.has(hunk.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hunk.id}
|
||||
className={`rounded-3xl border p-4 transition ${
|
||||
accepted
|
||||
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">改动块 {index + 1}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
原文 {hunk.originalStart}-{Math.max(hunk.originalEnd, hunk.originalStart - 1)} 行
|
||||
,润色稿 {hunk.modifiedStart}-{Math.max(hunk.modifiedEnd, hunk.modifiedStart - 1)} 行
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={accepted ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(hunk.id)) {
|
||||
next.delete(hunk.id)
|
||||
} else {
|
||||
next.add(hunk.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{accepted ? '已采用' : '采用这块'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 rounded-2xl border border-border/60 bg-background/70 px-3 py-2 text-xs leading-6 text-muted-foreground">
|
||||
{hunk.preview}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-3xl border border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||
AI 已返回结果,但没有检测到行级差异。可以直接应用,或者重新生成一次。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
admin/src/pages/post-preview-page.tsx
Normal file
165
admin/src/pages/post-preview-page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ExternalLink, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||
|
||||
type PreviewState = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<PreviewState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
|
||||
if (draft && draft.slug === slug) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
path: draft.path,
|
||||
markdown: draft.markdown,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
])
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
markdown: markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setError(loadError instanceof ApiError ? loadError.message : '无法加载预览内容。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">独立预览窗口</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{state?.title || '文章预览'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
这里展示的是当前草稿的渲染效果,不会打断主编辑器里的输入位置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
{slug ? (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开前台页面
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载预览内容...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>预览加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : state ? (
|
||||
<MarkdownWorkbench
|
||||
value={state.markdown}
|
||||
originalValue=""
|
||||
path={state.path}
|
||||
mode="workspace"
|
||||
visiblePanels={['preview']}
|
||||
availablePanels={['preview']}
|
||||
readOnly
|
||||
preview={<MarkdownPreview markdown={state.markdown} />}
|
||||
onModeChange={() => {}}
|
||||
onVisiblePanelsChange={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,13 @@ import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { csvToList, formatDateTime, reviewTagsToList } from '@/lib/admin-format'
|
||||
import {
|
||||
csvToList,
|
||||
formatDateTime,
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
@@ -23,6 +29,7 @@ type ReviewFormState = {
|
||||
description: string
|
||||
tags: string
|
||||
cover: string
|
||||
linkUrl: string
|
||||
}
|
||||
|
||||
const defaultReviewForm: ReviewFormState = {
|
||||
@@ -34,6 +41,7 @@ const defaultReviewForm: ReviewFormState = {
|
||||
description: '',
|
||||
tags: '',
|
||||
cover: '',
|
||||
linkUrl: '',
|
||||
}
|
||||
|
||||
function toFormState(review: ReviewRecord): ReviewFormState {
|
||||
@@ -46,6 +54,7 @@ function toFormState(review: ReviewRecord): ReviewFormState {
|
||||
description: review.description ?? '',
|
||||
tags: reviewTagsToList(review.tags).join(', '),
|
||||
cover: review.cover ?? '',
|
||||
linkUrl: review.link_url ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +68,7 @@ function toCreatePayload(form: ReviewFormState): CreateReviewPayload {
|
||||
description: form.description.trim(),
|
||||
tags: csvToList(form.tags),
|
||||
cover: form.cover.trim(),
|
||||
link_url: form.linkUrl.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +82,7 @@ function toUpdatePayload(form: ReviewFormState): UpdateReviewPayload {
|
||||
description: form.description.trim(),
|
||||
tags: csvToList(form.tags),
|
||||
cover: form.cover.trim(),
|
||||
link_url: form.linkUrl.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +109,13 @@ export function ReviewsPage() {
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Reviews refreshed.')
|
||||
toast.success('评测列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load reviews.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载评测列表。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -146,12 +157,11 @@ export function ReviewsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Reviews</Badge>
|
||||
<Badge variant="secondary">评测</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Review library</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">评测内容库</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
Manage the review catalog that powers the public review index, including score,
|
||||
medium, cover art, and publication state.
|
||||
管理前台评测页依赖的评测目录,包括评分、媒介类型、封面和发布状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,11 +174,11 @@ export function ReviewsPage() {
|
||||
setForm(defaultReviewForm)
|
||||
}}
|
||||
>
|
||||
New review
|
||||
新建评测
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadReviews(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,15 +186,15 @@ export function ReviewsPage() {
|
||||
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Review list</CardTitle>
|
||||
<CardTitle>评测列表</CardTitle>
|
||||
<CardDescription>
|
||||
Select an existing review to edit it, or start a new entry from the editor.
|
||||
选择已有评测进行编辑,或者在右侧直接创建新条目。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||
<Input
|
||||
placeholder="Search by title, medium, description, tags, or status"
|
||||
placeholder="按标题、媒介、简介、标签或状态搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
@@ -192,10 +202,10 @@ export function ReviewsPage() {
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -220,20 +230,20 @@ export function ReviewsPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{review.title ?? 'Untitled review'}</span>
|
||||
<Badge variant="outline">{review.review_type ?? 'unknown'}</Badge>
|
||||
<span className="font-medium">{review.title ?? '未命名评测'}</span>
|
||||
<Badge variant="outline">{formatReviewType(review.review_type)}</Badge>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{review.description ?? 'No description yet.'}
|
||||
{review.description ?? '暂无简介。'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{reviewTagsToList(review.tags).join(', ') || 'No tags'}
|
||||
{reviewTagsToList(review.tags).join(', ') || '暂无标签'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold">{review.rating ?? 0}/5</div>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{review.status ?? 'published'}
|
||||
{formatReviewStatus(review.status)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatDateTime(review.created_at)}
|
||||
@@ -246,7 +256,7 @@ export function ReviewsPage() {
|
||||
{!filteredReviews.length ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||
<BookOpenText className="h-8 w-8" />
|
||||
<p>No reviews match the current filters.</p>
|
||||
<p>当前筛选条件下没有匹配的评测。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -257,22 +267,21 @@ export function ReviewsPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedReview ? 'Edit review' : 'Create review'}</CardTitle>
|
||||
<CardTitle>{selectedReview ? '编辑评测' : '新建评测'}</CardTitle>
|
||||
<CardDescription>
|
||||
Control the presentation fields the public reviews page reads directly from the
|
||||
backend.
|
||||
维护前台评测页直接读取的展示字段。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!form.title.trim()) {
|
||||
toast.error('Title is required.')
|
||||
toast.error('标题不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.reviewDate) {
|
||||
toast.error('Review date is required.')
|
||||
toast.error('评测日期不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,18 +296,18 @@ export function ReviewsPage() {
|
||||
setSelectedId(updated.id)
|
||||
setForm(toFormState(updated))
|
||||
})
|
||||
toast.success('Review updated.')
|
||||
toast.success('评测已更新。')
|
||||
} else {
|
||||
const created = await adminApi.createReview(toCreatePayload(form))
|
||||
startTransition(() => {
|
||||
setSelectedId(created.id)
|
||||
setForm(toFormState(created))
|
||||
})
|
||||
toast.success('Review created.')
|
||||
toast.success('评测已创建。')
|
||||
}
|
||||
await loadReviews(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to save review.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法保存评测。')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -306,35 +315,33 @@ export function ReviewsPage() {
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : selectedReview ? 'Save changes' : 'Create review'}
|
||||
{saving ? '保存中...' : selectedReview ? '保存修改' : '创建评测'}
|
||||
</Button>
|
||||
{selectedReview ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('Delete this review?')) {
|
||||
if (!window.confirm('确定删除这条评测吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true)
|
||||
await adminApi.deleteReview(selectedReview.id)
|
||||
toast.success('Review deleted.')
|
||||
toast.success('评测已删除。')
|
||||
setSelectedId(null)
|
||||
setForm(defaultReviewForm)
|
||||
await loadReviews(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to delete review.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -343,16 +350,16 @@ export function ReviewsPage() {
|
||||
{selectedReview ? (
|
||||
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Selected record
|
||||
当前记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Created {formatDateTime(selectedReview.created_at)}
|
||||
创建于 {formatDateTime(selectedReview.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FormField label="Title">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(event) =>
|
||||
@@ -360,21 +367,21 @@ export function ReviewsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Review type">
|
||||
<FormField label="评测类型">
|
||||
<Select
|
||||
value={form.reviewType}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, reviewType: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="book">Book</option>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="game">Game</option>
|
||||
<option value="anime">Anime</option>
|
||||
<option value="music">Music</option>
|
||||
<option value="book">图书</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="game">游戏</option>
|
||||
<option value="anime">动画</option>
|
||||
<option value="music">音乐</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Rating">
|
||||
<FormField label="评分">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -386,7 +393,7 @@ export function ReviewsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Review date">
|
||||
<FormField label="评测日期">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.reviewDate}
|
||||
@@ -395,19 +402,19 @@ export function ReviewsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Status">
|
||||
<FormField label="状态">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Cover URL">
|
||||
<FormField label="封面 URL">
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
@@ -415,8 +422,17 @@ export function ReviewsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
|
||||
<Input
|
||||
type="url"
|
||||
value={form.linkUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, linkUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="Tags" hint="Comma-separated tag names.">
|
||||
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(event) =>
|
||||
@@ -426,7 +442,7 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="Description">
|
||||
<FormField label="简介">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, RefreshCcw, Save } from 'lucide-react'
|
||||
import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -11,7 +11,56 @@ import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminSiteSettingsResponse, SiteSettingsPayload } from '@/lib/types'
|
||||
import type {
|
||||
AdminSiteSettingsResponse,
|
||||
AiProviderConfig,
|
||||
MusicTrack,
|
||||
SiteSettingsPayload,
|
||||
} from '@/lib/types'
|
||||
|
||||
function createEmptyMusicTrack(): MusicTrack {
|
||||
return {
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
url: '',
|
||||
cover_image_url: '',
|
||||
accent_color: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createAiProviderId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `provider-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function createEmptyAiProvider(): AiProviderConfig {
|
||||
return {
|
||||
id: createAiProviderId(),
|
||||
name: '',
|
||||
provider: 'newapi',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
chat_model: '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSettingsResponse(
|
||||
input: AdminSiteSettingsResponse,
|
||||
): AdminSiteSettingsResponse {
|
||||
const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
|
||||
|
||||
return {
|
||||
...input,
|
||||
ai_providers: aiProviders,
|
||||
ai_active_provider_id:
|
||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
@@ -49,11 +98,15 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
socialEmail: form.social_email,
|
||||
location: form.location,
|
||||
techStack: form.tech_stack,
|
||||
musicPlaylist: form.music_playlist,
|
||||
aiEnabled: form.ai_enabled,
|
||||
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
||||
aiProvider: form.ai_provider,
|
||||
aiApiBase: form.ai_api_base,
|
||||
aiApiKey: form.ai_api_key,
|
||||
aiChatModel: form.ai_chat_model,
|
||||
aiProviders: form.ai_providers,
|
||||
aiActiveProviderId: form.ai_active_provider_id,
|
||||
aiEmbeddingModel: form.ai_embedding_model,
|
||||
aiSystemPrompt: form.ai_system_prompt,
|
||||
aiTopK: form.ai_top_k,
|
||||
@@ -66,22 +119,25 @@ export function SiteSettingsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reindexing, setReindexing] = useState(false)
|
||||
const [testingProvider, setTestingProvider] = useState(false)
|
||||
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
|
||||
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
|
||||
|
||||
const loadSettings = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
const next = await adminApi.getSiteSettings()
|
||||
const next = normalizeSettingsResponse(await adminApi.getSiteSettings())
|
||||
startTransition(() => {
|
||||
setForm(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Site settings refreshed.')
|
||||
toast.success('站点设置已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load site settings.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载站点设置。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -91,6 +147,34 @@ export function SiteSettingsPage() {
|
||||
void loadSettings(false)
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!form?.music_playlist.length) {
|
||||
setSelectedTrackIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedTrackIndex((current) => Math.min(current, form.music_playlist.length - 1))
|
||||
}, [form?.music_playlist.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!form?.ai_providers.length) {
|
||||
setSelectedProviderIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedProviderIndex((current) => {
|
||||
const activeIndex = form.ai_providers.findIndex(
|
||||
(provider) => provider.id === form.ai_active_provider_id,
|
||||
)
|
||||
|
||||
if (activeIndex >= 0) {
|
||||
return activeIndex
|
||||
}
|
||||
|
||||
return Math.min(current, form.ai_providers.length - 1)
|
||||
})
|
||||
}, [form?.ai_active_provider_id, form?.ai_providers])
|
||||
|
||||
const updateField = <K extends keyof AdminSiteSettingsResponse>(
|
||||
key: K,
|
||||
value: AdminSiteSettingsResponse[K],
|
||||
@@ -98,10 +182,130 @@ export function SiteSettingsPage() {
|
||||
setForm((current) => (current ? { ...current, [key]: value } : current))
|
||||
}
|
||||
|
||||
const updateMusicTrack = <K extends keyof MusicTrack>(index: number, key: K, value: MusicTrack[K]) => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextPlaylist = current.music_playlist.map((track, trackIndex) =>
|
||||
trackIndex === index ? { ...track, [key]: value } : track,
|
||||
)
|
||||
|
||||
return { ...current, music_playlist: nextPlaylist }
|
||||
})
|
||||
}
|
||||
|
||||
const updateAiProvider = <K extends keyof AiProviderConfig>(
|
||||
index: number,
|
||||
key: K,
|
||||
value: AiProviderConfig[K],
|
||||
) => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextProviders = current.ai_providers.map((provider, providerIndex) =>
|
||||
providerIndex === index ? { ...provider, [key]: value } : provider,
|
||||
)
|
||||
|
||||
return { ...current, ai_providers: nextProviders }
|
||||
})
|
||||
}
|
||||
|
||||
const addMusicTrack = () => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()]
|
||||
setSelectedTrackIndex(nextPlaylist.length - 1)
|
||||
|
||||
return { ...current, music_playlist: nextPlaylist }
|
||||
})
|
||||
}
|
||||
|
||||
const removeMusicTrack = (index: number) => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextPlaylist = current.music_playlist.filter((_, trackIndex) => trackIndex !== index)
|
||||
setSelectedTrackIndex((currentIndex) =>
|
||||
Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextPlaylist.length - 1)),
|
||||
)
|
||||
|
||||
return {
|
||||
...current,
|
||||
music_playlist: nextPlaylist,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addAiProvider = () => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextProvider = createEmptyAiProvider()
|
||||
const nextProviders = [...current.ai_providers, nextProvider]
|
||||
setSelectedProviderIndex(nextProviders.length - 1)
|
||||
|
||||
return {
|
||||
...current,
|
||||
ai_providers: nextProviders,
|
||||
ai_active_provider_id: current.ai_active_provider_id ?? nextProvider.id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeAiProvider = (index: number) => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const removed = current.ai_providers[index]
|
||||
const nextProviders = current.ai_providers.filter((_, providerIndex) => providerIndex !== index)
|
||||
const nextActiveProviderId =
|
||||
removed?.id === current.ai_active_provider_id ? (nextProviders[0]?.id ?? null) : current.ai_active_provider_id
|
||||
|
||||
setSelectedProviderIndex((currentIndex) =>
|
||||
Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextProviders.length - 1)),
|
||||
)
|
||||
|
||||
return {
|
||||
...current,
|
||||
ai_providers: nextProviders,
|
||||
ai_active_provider_id: nextActiveProviderId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setActiveAiProvider = (providerId: string) => {
|
||||
updateField('ai_active_provider_id', providerId)
|
||||
}
|
||||
|
||||
const techStackValue = useMemo(
|
||||
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
|
||||
[form?.tech_stack],
|
||||
)
|
||||
const selectedTrack = useMemo(
|
||||
() => form?.music_playlist[selectedTrackIndex] ?? createEmptyMusicTrack(),
|
||||
[form, selectedTrackIndex],
|
||||
)
|
||||
const selectedProvider = useMemo(
|
||||
() => form?.ai_providers[selectedProviderIndex] ?? createEmptyAiProvider(),
|
||||
[form, selectedProviderIndex],
|
||||
)
|
||||
const activeProvider = useMemo(
|
||||
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
|
||||
[form],
|
||||
)
|
||||
|
||||
if (loading || !form) {
|
||||
return (
|
||||
@@ -116,14 +320,11 @@ export function SiteSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Site settings</Badge>
|
||||
<Badge variant="secondary">站点设置</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">
|
||||
Brand, profile, and AI controls
|
||||
</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">品牌、资料与 AI 控制</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
This page keeps the public brand, owner profile, and AI configuration aligned with
|
||||
the same backend data model the site already depends on.
|
||||
这里统一维护前台站点使用的品牌信息、站长资料与 AI 配置,确保和后端数据模型一致。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +332,7 @@ export function SiteSettingsPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadSettings(true)}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -140,17 +341,17 @@ export function SiteSettingsPage() {
|
||||
try {
|
||||
setReindexing(true)
|
||||
const result = await adminApi.reindexAi()
|
||||
toast.success(`AI index rebuilt with ${result.indexed_chunks} chunks.`)
|
||||
toast.success(`AI 索引已重建,共生成 ${result.indexed_chunks} 个分块。`)
|
||||
await loadSettings(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : 'AI reindex failed.')
|
||||
toast.error(error instanceof ApiError ? error.message : 'AI 重建索引失败。')
|
||||
} finally {
|
||||
setReindexing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{reindexing ? 'Reindexing...' : 'Rebuild AI index'}
|
||||
{reindexing ? '重建中...' : '重建 AI 索引'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
@@ -159,102 +360,102 @@ export function SiteSettingsPage() {
|
||||
setSaving(true)
|
||||
const updated = await adminApi.updateSiteSettings(toPayload(form))
|
||||
startTransition(() => {
|
||||
setForm(updated)
|
||||
setForm(normalizeSettingsResponse(updated))
|
||||
})
|
||||
toast.success('Site settings saved.')
|
||||
toast.success('站点设置已保存。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : 'Save failed.')
|
||||
toast.error(error instanceof ApiError ? error.message : '保存失败。')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<Card>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Public identity</CardTitle>
|
||||
<CardTitle>前台站点资料</CardTitle>
|
||||
<CardDescription>
|
||||
Everything the public site reads for brand, hero copy, owner profile, and social
|
||||
metadata.
|
||||
前台站点用于展示品牌、首页文案、站长资料和社交信息的全部字段。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="Site name">
|
||||
<Field label="站点名称">
|
||||
<Input
|
||||
value={form.site_name ?? ''}
|
||||
onChange={(event) => updateField('site_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Short name">
|
||||
<Field label="站点短名">
|
||||
<Input
|
||||
value={form.site_short_name ?? ''}
|
||||
onChange={(event) => updateField('site_short_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Site URL">
|
||||
<Field label="站点 URL">
|
||||
<Input
|
||||
value={form.site_url ?? ''}
|
||||
onChange={(event) => updateField('site_url', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location">
|
||||
<Field label="所在地">
|
||||
<Input
|
||||
value={form.location ?? ''}
|
||||
onChange={(event) => updateField('location', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Site title" hint="Used in the main document title and SEO surface.">
|
||||
<Field label="站点标题" hint="用于页面标题与 SEO 展示。">
|
||||
<Input
|
||||
value={form.site_title ?? ''}
|
||||
onChange={(event) => updateField('site_title', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Owner title">
|
||||
<Field label="站长头衔">
|
||||
<Input
|
||||
value={form.owner_title ?? ''}
|
||||
onChange={(event) => updateField('owner_title', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Site description">
|
||||
<Field label="站点简介">
|
||||
<Textarea
|
||||
value={form.site_description ?? ''}
|
||||
onChange={(event) => updateField('site_description', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Hero title">
|
||||
<Field label="首页主标题">
|
||||
<Input
|
||||
value={form.hero_title ?? ''}
|
||||
onChange={(event) => updateField('hero_title', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Hero subtitle">
|
||||
<Field label="首页副标题">
|
||||
<Input
|
||||
value={form.hero_subtitle ?? ''}
|
||||
onChange={(event) => updateField('hero_subtitle', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Owner name">
|
||||
<Field label="站长名称">
|
||||
<Input
|
||||
value={form.owner_name ?? ''}
|
||||
onChange={(event) => updateField('owner_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Avatar URL">
|
||||
<Field label="头像 URL">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Owner bio">
|
||||
<Field label="站长简介">
|
||||
<Textarea
|
||||
value={form.owner_bio ?? ''}
|
||||
onChange={(event) => updateField('owner_bio', event.target.value)}
|
||||
@@ -274,7 +475,7 @@ export function SiteSettingsPage() {
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Email / mailto">
|
||||
<Field label="邮箱 / mailto">
|
||||
<Input
|
||||
value={form.social_email ?? ''}
|
||||
onChange={(event) => updateField('social_email', event.target.value)}
|
||||
@@ -282,7 +483,7 @@ export function SiteSettingsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Tech stack" hint="One item per line.">
|
||||
<Field label="技术栈" hint="每行填写一个项目。">
|
||||
<Textarea
|
||||
value={techStackValue}
|
||||
onChange={(event) =>
|
||||
@@ -300,12 +501,38 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI module</CardTitle>
|
||||
<CardTitle>互动功能</CardTitle>
|
||||
<CardDescription>
|
||||
Provider and retrieval controls used by the on-site AI experience.
|
||||
控制文章正文里的段落评论入口是否在前台显示。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.paragraph_comments_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('paragraph_comments_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">开启前台段落评论</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
开启后,文章段落旁会在悬停时显示评论徽标;关闭后整套段落评论入口会从前台隐藏。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 模块</CardTitle>
|
||||
<CardDescription>
|
||||
站内 AI 问答功能使用的提供方与检索控制参数。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
@@ -317,41 +544,192 @@ export function SiteSettingsPage() {
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">Enable public AI Q&A</div>
|
||||
<div className="font-medium">开启前台 AI 问答</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
When this is off, the public Ask AI entry stays visible only as a disabled
|
||||
state.
|
||||
关闭后,前台 AI 问答入口只会展示为不可用状态,用户不能直接发起提问。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Field label="Provider">
|
||||
<Input
|
||||
value={form.ai_provider ?? ''}
|
||||
onChange={(event) => updateField('ai_provider', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API base">
|
||||
<Input
|
||||
value={form.ai_api_base ?? ''}
|
||||
onChange={(event) => updateField('ai_api_base', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key">
|
||||
<Input
|
||||
value={form.ai_api_key ?? ''}
|
||||
onChange={(event) => updateField('ai_api_key', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Chat model">
|
||||
<Input
|
||||
value={form.ai_chat_model ?? ''}
|
||||
onChange={(event) => updateField('ai_chat_model', event.target.value)}
|
||||
/>
|
||||
<Field label="提供方">
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">提供商列表</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
可以同时保存多套模型渠道配置,并指定当前实际生效的那一套。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={addAiProvider}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加提供商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{form.ai_providers.length ? (
|
||||
form.ai_providers.map((provider, index) => {
|
||||
const active = provider.id === form.ai_active_provider_id
|
||||
const selected = index === selectedProviderIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProviderIndex(index)}
|
||||
className={
|
||||
selected
|
||||
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
|
||||
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{provider.name?.trim() || `提供商 ${index + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{provider.provider?.trim() || '未填写 provider'}
|
||||
</p>
|
||||
</div>
|
||||
{active ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
当前启用
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{provider.chat_model?.trim() || '未填写模型'}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
|
||||
还没有配置任何模型提供商,先添加一套即可开始切换使用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
|
||||
{form.ai_providers.length ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
当前编辑
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
保存后,系统会使用“当前启用”的提供商处理站内 AI 请求。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testingProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setTestingProvider(true)
|
||||
const result = await adminApi.testAiProvider(selectedProvider)
|
||||
toast.success(
|
||||
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : '模型连通性测试失败。',
|
||||
)
|
||||
} finally {
|
||||
setTestingProvider(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{testingProvider ? '测试中...' : '测试连通性'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
|
||||
onClick={() => setActiveAiProvider(selectedProvider.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => removeAiProvider(selectedProviderIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
|
||||
<Input
|
||||
value={selectedProvider.name ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'name', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider 标识">
|
||||
<Input
|
||||
value={selectedProvider.provider ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
|
||||
}
|
||||
placeholder="newapi / openai-compatible / 其他兼容值"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API 地址">
|
||||
<Input
|
||||
value={selectedProvider.api_base ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API 密钥">
|
||||
<Input
|
||||
value={selectedProvider.api_key ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="对话模型">
|
||||
<Input
|
||||
value={selectedProvider.chat_model ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
||||
当前生效:
|
||||
{activeProvider
|
||||
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
|
||||
: '未选择提供商'}
|
||||
</div>
|
||||
<Field
|
||||
label="Embedding model"
|
||||
hint={`Local option: ${form.ai_local_embedding}`}
|
||||
label="向量模型"
|
||||
hint={`本地选项:${form.ai_local_embedding}`}
|
||||
>
|
||||
<Input
|
||||
value={form.ai_embedding_model ?? ''}
|
||||
@@ -371,7 +749,7 @@ export function SiteSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Chunk size">
|
||||
<Field label="分块大小">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.ai_chunk_size ?? ''}
|
||||
@@ -384,7 +762,7 @@ export function SiteSettingsPage() {
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="System prompt">
|
||||
<Field label="系统提示词">
|
||||
<Textarea
|
||||
value={form.ai_system_prompt ?? ''}
|
||||
onChange={(event) => updateField('ai_system_prompt', event.target.value)}
|
||||
@@ -395,29 +773,198 @@ export function SiteSettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Index status</CardTitle>
|
||||
<CardTitle>索引状态</CardTitle>
|
||||
<CardDescription>
|
||||
Read-only signals from the current AI knowledge base.
|
||||
当前 AI 知识库的只读状态信息。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Indexed chunks
|
||||
已索引分块
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{form.ai_chunks_count}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Last indexed at
|
||||
最近索引时间
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{form.ai_last_indexed_at ?? 'The index has not been built yet.'}
|
||||
{form.ai_last_indexed_at ?? '索引尚未建立。'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:sticky xl:top-24 xl:self-start">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b border-border/70 bg-background/45">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>音乐侧栏</CardTitle>
|
||||
<CardDescription>
|
||||
把头部播放器的曲目清单和单曲属性放到独立侧边栏里维护。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<div className="space-y-3">
|
||||
{form.music_playlist.map((track, index) => {
|
||||
const active = index === selectedTrackIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${track.title}-${index}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedTrackIndex(index)}
|
||||
className={
|
||||
active
|
||||
? 'w-full rounded-[1.5rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_14px_32px_rgba(37,99,235,0.14)]'
|
||||
: 'w-full rounded-[1.5rem] border border-border/70 bg-background/60 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{track.title?.trim() ? track.title : `曲目 ${index + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{track.artist?.trim() || '未填写歌手'}
|
||||
</p>
|
||||
</div>
|
||||
{track.accent_color ? (
|
||||
<span
|
||||
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-white/60"
|
||||
style={{ backgroundColor: track.accent_color }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{track.url || '未填写音频 URL'}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button type="button" variant="outline" onClick={addMusicTrack} className="w-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加曲目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.8rem] border border-border/70 bg-background/55 p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
当前编辑
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{selectedTrack.title?.trim()
|
||||
? selectedTrack.title
|
||||
: `曲目 ${selectedTrackIndex + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
单独修改标题、资源、封面、主题色和说明。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => removeMusicTrack(selectedTrackIndex)}
|
||||
disabled={form.music_playlist.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedTrack.cover_image_url ? (
|
||||
<div className="mb-5 overflow-hidden rounded-3xl border border-border/70 bg-black/5">
|
||||
<img
|
||||
src={selectedTrack.cover_image_url}
|
||||
alt={selectedTrack.title || `曲目 ${selectedTrackIndex + 1}`}
|
||||
className="h-44 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="标题">
|
||||
<Input
|
||||
value={selectedTrack.title ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'title', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<Field label="歌手">
|
||||
<Input
|
||||
value={selectedTrack.artist ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'artist', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="专辑">
|
||||
<Input
|
||||
value={selectedTrack.album ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'album', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="音频 URL">
|
||||
<Input
|
||||
value={selectedTrack.url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'url', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="封面图 URL">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={selectedTrack.accent_color ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'accent_color', event.target.value)
|
||||
}
|
||||
placeholder="#2f6b5f"
|
||||
/>
|
||||
<span
|
||||
className="h-11 w-11 shrink-0 rounded-2xl border border-border/70 bg-background"
|
||||
style={{
|
||||
backgroundColor:
|
||||
selectedTrack.accent_color?.trim() || 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="备注">
|
||||
<Textarea
|
||||
value={selectedTrack.description ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'description', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user