diff --git a/README.md b/README.md
index 274cb4c..035e7c1 100644
--- a/README.md
+++ b/README.md
@@ -16,45 +16,48 @@ Monorepo for the Termi blog system.
## Run
-### Monorepo scripts
+### Recommended
From the repository root:
+```powershell
+npm run dev
+```
+
+This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
+
+Common shortcuts:
+
+```powershell
+npm run dev:mcp
+npm run dev:frontend
+npm run dev:admin
+npm run dev:backend
+npm run dev:mcp-only
+npm run stop
+npm run restart
+```
+
+### PowerShell entrypoint
+
+If you prefer direct scripts, use the single root entrypoint:
+
```powershell
.\dev.ps1
-```
-
-Frontend + backend + MCP:
-
-```powershell
.\dev.ps1 -WithMcp
+.\dev.ps1 -Only frontend
+.\dev.ps1 -Only admin
+.\dev.ps1 -Only backend
+.\dev.ps1 -Only mcp
```
-Only frontend:
+If you want a single service to be opened as a new Windows Terminal tab instead of running in the current shell:
```powershell
-.\dev.ps1 -FrontendOnly
+.\dev.ps1 -Only frontend -Spawn
```
-Only backend:
-
-```powershell
-.\dev.ps1 -BackendOnly
-```
-
-Only admin:
-
-```powershell
-.\dev.ps1 -AdminOnly
-```
-
-Only MCP:
-
-```powershell
-.\dev.ps1 -McpOnly
-```
-
-Direct scripts:
+Legacy aliases are still available and now just forward to `dev.ps1`:
```powershell
.\start-frontend.ps1
@@ -90,7 +93,7 @@ cargo loco start 2>&1
### MCP Server
```powershell
-.\start-mcp.ps1
+.\dev.ps1 -Only mcp
```
Default MCP endpoint:
diff --git a/admin/package-lock.json b/admin/package-lock.json
index 1361386..6929b5e 100644
--- a/admin/package-lock.json
+++ b/admin/package-lock.json
@@ -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",
diff --git a/admin/package.json b/admin/package.json
index 2ef9c84..7281a6a 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -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",
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index 00f4ed2..0d6163f 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -6,6 +6,7 @@ import {
useCallback,
useMemo,
useState,
+ type ReactNode,
} from 'react'
import {
BrowserRouter,
@@ -56,11 +57,11 @@ function AppLoadingScreen() {
- Termi admin
+ Termi 后台
-
Booting control room
+
正在进入管理后台
- Checking the current admin session and preparing the new React workspace.
+ 正在检查当前登录状态,并准备新的 React 管理工作台。
@@ -68,14 +69,14 @@ function AppLoadingScreen() {
)
}
-function SessionGuard() {
+function RequireAuth({ children }: { children: ReactNode }) {
const { session } = useSession()
if (!session.authenticated) {
return
}
- return
+ 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 (
} />
- }>
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
@@ -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)
diff --git a/admin/src/components/app-shell.tsx b/admin/src/components/app-shell.tsx
index 681dcd5..eae44c4 100644
--- a/admin/src/components/app-shell.tsx
+++ b/admin/src/components/app-shell.tsx
@@ -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({
- Termi admin
+ Termi 后台
-
- Control room for the blog system
-
+
博客系统控制台
- A dedicated React workspace for publishing, moderation, operations, and
- AI-related site controls.
+ 一个独立的 React 管理工作台,用来处理发布、审核、运营以及站内 AI 配置。
@@ -141,20 +138,20 @@ export function AppShell({
- Workspace status
+ 工作台状态
- Core admin flows are now available in the standalone app.
+ 核心后台流程已经迁移到独立管理端。
-
live
+
运行中
- Public site and admin stay decoupled.
+ 前台站点与后台管理保持解耦。
- Backend remains the shared auth and data layer.
+ 后端继续作为统一认证与数据层。
@@ -168,12 +165,14 @@ export function AppShell({
- New admin workspace
+ 新版管理工作台
-
Signed in as {username ?? 'admin'}
+
+ 当前登录:{username ?? 'admin'}
+
- React + shadcn/ui foundation
+ React + shadcn/ui 基础架构
@@ -202,12 +201,12 @@ export function AppShell({
- Open site
+ 打开前台
void onLogout()} disabled={loggingOut}>
- {loggingOut ? 'Signing out...' : 'Sign out'}
+ {loggingOut ? '退出中...' : '退出登录'}
diff --git a/admin/src/components/markdown-preview.tsx b/admin/src/components/markdown-preview.tsx
new file mode 100644
index 0000000..12756ce
--- /dev/null
+++ b/admin/src/components/markdown-preview.tsx
@@ -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 (
+
+ )
+}
diff --git a/admin/src/components/markdown-workbench.tsx b/admin/src/components/markdown-workbench.tsx
new file mode 100644
index 0000000..778928f
--- /dev/null
+++ b/admin/src/components/markdown-workbench.tsx
@@ -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 = (
+
+
+
+
+
+ {availablePanels.map((panel) => {
+ const active = mode === 'workspace' && workspacePanels.includes(panel)
+
+ return (
+ 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)}
+
+ )
+ })}
+ {polishEnabled ? (
+ 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'
+ }
+ >
+
+ AI 润色
+
+ ) : null}
+ setFullscreen((current) => !current)}
+ className="border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white"
+ >
+ {fullscreen ? (
+ <>
+
+ 退出全屏
+ >
+ ) : (
+ <>
+
+ 全屏
+ >
+ )}
+
+
+
+
+
+ {mode === 'polish' ? (
+
{polishPanel}
+ ) : (
+
+ {workspacePanels.map((panel, index) => (
+
+
+ {formatPanelLabel(panel)}
+ {panel === 'diff' ? (
+
+ {originalLabel} / {modifiedLabel}
+
+ ) : (
+ {path}
+ )}
+
+
+ {panel === 'edit' ? (
+
+ onChange(next ?? '')}
+ />
+
+ ) : null}
+
+ {panel === 'preview' ? (
+ {preview}
+ ) : null}
+
+ {panel === 'diff' ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ )
+
+ if (!fullscreen) {
+ return workbench
+ }
+
+ if (typeof document === 'undefined') {
+ return workbench
+ }
+
+ return createPortal(
+ <>
+
+ {workbench}
+ >,
+ document.body,
+ )
+}
diff --git a/admin/src/lib/admin-format.ts b/admin/src/lib/admin-format.ts
index 4fced2d..d03e3e0 100644
--- a/admin/src/lib/admin-format.ts
+++ b/admin/src/lib/admin-format.ts
@@ -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
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts
index f1c3b41..0860ddf 100644
--- a/admin/src/lib/api.ts
+++ b/admin/src/lib/api.ts
@@ -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('/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('/api/admin/ai/test-provider', {
+ method: 'POST',
+ body: JSON.stringify({ provider }),
+ }),
+ generatePostMetadata: (markdown: string) =>
+ request('/api/admin/ai/post-metadata', {
+ method: 'POST',
+ body: JSON.stringify({ markdown }),
+ }),
+ polishPostMarkdown: (markdown: string) =>
+ request('/api/admin/ai/polish-post', {
+ method: 'POST',
+ body: JSON.stringify({ markdown }),
+ }),
listPosts: (query?: PostListQuery) =>
request(
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(`/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('/api/posts/markdown/import', {
+ method: 'POST',
+ body: formData,
+ })
+ },
updatePostMarkdown: (slug: string, markdown: string) =>
request(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
method: 'PATCH',
diff --git a/admin/src/lib/markdown-diff.ts b/admin/src/lib/markdown-diff.ts
new file mode 100644
index 0000000..55a463c
--- /dev/null
+++ b/admin/src/lib/markdown-diff.ts
@@ -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),
+ }
+}
diff --git a/admin/src/lib/markdown-document.ts b/admin/src/lib/markdown-document.ts
new file mode 100644
index 0000000..6060993
--- /dev/null
+++ b/admin/src/lib/markdown-document.ts
@@ -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`
+}
diff --git a/admin/src/lib/markdown-merge.ts b/admin/src/lib/markdown-merge.ts
new file mode 100644
index 0000000..5c34f20
--- /dev/null
+++ b/admin/src/lib/markdown-merge.ts
@@ -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 & {
+ 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,
+) {
+ 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')
+}
diff --git a/admin/src/lib/post-draft-window.ts b/admin/src/lib/post-draft-window.ts
new file mode 100644
index 0000000..353d907
--- /dev/null
+++ b/admin/src/lib/post-draft-window.ts
@@ -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) {
+ 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
+ }
+}
diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts
index 868f735..8ed7c64 100644
--- a/admin/src/lib/types.ts
+++ b/admin/src/lib/types.ts
@@ -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
}
diff --git a/admin/src/pages/comments-page.tsx b/admin/src/pages/comments-page.tsx
index 51d3877..80472b3 100644
--- a/admin/src/pages/comments-page.tsx
+++ b/admin/src/pages/comments-page.tsx
@@ -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() {
-
Comments
+
评论
-
Moderation queue
+
评论审核队列
- Review article comments and paragraph-specific responses from one place, with fast
- approval controls for the public discussion layer.
+ 在一个页面中处理全文评论与段落评论,快速完成公开讨论区的审核工作。
void loadComments(true)} disabled={refreshing}>
- {refreshing ? 'Refreshing...' : 'Refresh'}
+ {refreshing ? '刷新中...' : '刷新'}
- Pending
+ 待审核
{pendingCount}
- Needs moderation attention.
+ 需要人工审核处理。
- Paragraph replies
+ 段落评论
{paragraphCount}
- Scoped to paragraph anchors.
+ 挂载到具体段落锚点。
- Total
+ 总数
{comments.length}
- Everything currently stored.
+ 当前系统中全部评论。
- Comment list
+ 评论列表
- Filter the queue, then approve, hide, or remove entries without leaving the page.
+ 先筛选,再直接通过、隐藏或删除评论,无需离开当前页面。
setSearchTerm(event.target.value)}
/>
@@ -166,14 +165,14 @@ export function CommentsPage() {
value={approvalFilter}
onChange={(event) => setApprovalFilter(event.target.value)}
>
- All approval states
- Pending only
- Approved only
+ 全部状态
+ 仅看待审核
+ 仅看已通过
setScopeFilter(event.target.value)}>
- All scopes
- Article
- Paragraph
+ 全部范围
+ 全文
+ 段落
@@ -183,10 +182,10 @@ export function CommentsPage() {
- Comment
- Status
- Context
- Actions
+ 评论内容
+ 状态
+ 上下文
+ 操作
@@ -195,20 +194,20 @@ export function CommentsPage() {
- {comment.author ?? 'Anonymous'}
- {comment.scope}
+ {comment.author ?? '匿名用户'}
+ {formatCommentScope(comment.scope)}
{formatDateTime(comment.created_at)}
- {comment.content ?? 'No content provided.'}
+ {comment.content ?? '暂无评论内容。'}
{comment.scope === 'paragraph' ? (
-
{comment.paragraph_key ?? 'missing-key'}
+
{comment.paragraph_key ?? '缺少段落键'}
- {comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
+ {comment.paragraph_excerpt ?? '没有保存段落摘录。'}
) : null}
@@ -216,16 +215,16 @@ export function CommentsPage() {
- {comment.approved ? 'Approved' : 'Pending'}
+ {comment.approved ? '已通过' : '待审核'}
-
{comment.post_slug ?? 'unknown-post'}
+
{comment.post_slug ?? '未知文章'}
{comment.reply_to_comment_id ? (
-
Replying to #{comment.reply_to_comment_id}
+
回复评论 #{comment.reply_to_comment_id}
) : (
-
Top-level comment
+
顶级评论
)}
@@ -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() {
}}
>
- Approve
+ 通过
- Hide
+ 隐藏
{
- 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() {
}}
>
- Delete
+ 删除
@@ -316,7 +309,7 @@ export function CommentsPage() {
-
No comments match the current moderation filters.
+
当前筛选条件下没有匹配的评论。
diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx
index 2828711..e5f04c3 100644
--- a/admin/src/pages/dashboard-page.tsx
+++ b/admin/src/pages/dashboard-page.tsx
@@ -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() {
-
Dashboard
+
仪表盘
-
Operations overview
+
运营总览
- 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 信号,让日常运营在一个独立后台里完成闭环。
@@ -145,7 +151,7 @@ export function DashboardPage() {
- Open Ask AI
+ 打开 AI 问答
- {refreshing ? 'Refreshing...' : 'Refresh'}
+ {refreshing ? '刷新中...' : '刷新'}
@@ -169,21 +175,21 @@ export function DashboardPage() {
- Recent posts
+ 最近文章
- Freshly imported or updated content flowing into the public site.
+ 最近同步到前台的文章内容。
- {data.recent_posts.length} rows
+ {data.recent_posts.length} 条
- Title
- Type
- Category
- Created
+ 标题
+ 类型
+ 分类
+ 创建时间
@@ -193,13 +199,13 @@ export function DashboardPage() {
{post.title}
- {post.pinned ? pinned : null}
+ {post.pinned ? 置顶 : null}
{post.slug}
- {post.post_type}
+ {formatPostType(post.post_type)}
{post.category}
{post.created_at}
@@ -212,9 +218,9 @@ export function DashboardPage() {
- Site heartbeat
+ 站点状态
- A quick read on the public-facing site and the AI index state.
+ 快速查看前台站点与 AI 索引状态。
@@ -225,7 +231,7 @@ export function DashboardPage() {
{data.site.site_url}
- {data.site.ai_enabled ? 'AI on' : 'AI off'}
+ {data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
@@ -233,7 +239,7 @@ export function DashboardPage() {
- Reviews
+ 评测
{data.stats.total_reviews}
@@ -242,7 +248,7 @@ export function DashboardPage() {
- Friend links
+ 友链
{data.stats.total_links}
@@ -253,10 +259,10 @@ export function DashboardPage() {
- Last AI index
+ 最近一次 AI 索引
- {data.site.ai_last_indexed_at ?? 'The site has not been indexed yet.'}
+ {data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
@@ -267,21 +273,21 @@ export function DashboardPage() {
- Pending comments
+ 待审核评论
- Queue visibility without opening the old moderation page.
+ 不进入旧后台也能查看审核队列。
- {data.pending_comments.length} queued
+ {data.pending_comments.length} 条待处理
- Author
- Scope
- Post
- Created
+ 作者
+ 范围
+ 文章
+ 创建时间
@@ -296,7 +302,7 @@ export function DashboardPage() {
- {comment.scope}
+ {formatCommentScope(comment.scope)}
{comment.post_slug}
@@ -313,12 +319,12 @@ export function DashboardPage() {
- Pending friend links
+ 待审核友链
- Requests waiting for review and reciprocal checks.
+ 等待审核和互链确认的申请。
- {data.pending_friend_links.length} pending
+ {data.pending_friend_links.length} 条待处理
{data.pending_friend_links.map((link) => (
@@ -335,6 +341,9 @@ export function DashboardPage() {
{link.category}
+
+ 状态:{formatFriendLinkStatus(link.status)}
+
{link.created_at}
@@ -345,9 +354,9 @@ export function DashboardPage() {
- Recent reviews
+ 最近评测
- The latest review entries flowing into the public reviews page.
+ 最近同步到前台评测页的内容。
@@ -359,7 +368,7 @@ export function DashboardPage() {
{review.title}
- {review.review_type} · {review.status}
+ {formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
diff --git a/admin/src/pages/friend-links-page.tsx b/admin/src/pages/friend-links-page.tsx
index fb28a7f..f0ae866 100644
--- a/admin/src/pages/friend-links-page.tsx
+++ b/admin/src/pages/friend-links-page.tsx
@@ -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() {
-
Friend links
+
友链
-
Partner site queue
+
友链申请队列
- Review inbound link exchanges, keep metadata accurate, and move requests through
- pending, approved, or rejected states in one dedicated workspace.
+ 审核前台提交的友链申请,维护站点信息,并在待审核、已通过、已拒绝之间完成流转。
@@ -153,11 +152,11 @@ export function FriendLinksPage() {
setForm(defaultFriendLinkForm)
}}
>
- New link
+ 新建友链
void loadLinks(true)} disabled={refreshing}>
- {refreshing ? 'Refreshing...' : 'Refresh'}
+ {refreshing ? '刷新中...' : '刷新'}
@@ -165,15 +164,15 @@ export function FriendLinksPage() {
- Link inventory
+ 友链列表
- Pick an item to edit it, or start a new record from the right-hand form.
+ 选择一条友链进行编辑,或者直接在右侧创建新记录。
setSearchTerm(event.target.value)}
/>
@@ -181,10 +180,10 @@ export function FriendLinksPage() {
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
- All statuses
- Pending
- Approved
- Rejected
+ 全部状态
+ 待审核
+ 已通过
+ 已拒绝
@@ -209,18 +208,18 @@ export function FriendLinksPage() {
- {link.site_name ?? 'Untitled partner'}
+ {link.site_name ?? '未命名站点'}
- {link.status ?? 'pending'}
+ {formatFriendLinkStatus(link.status)}
{link.site_url}
- {link.description ?? 'No description yet.'}
+ {link.description ?? '暂无简介。'}
-
{link.category ?? 'uncategorized'}
+
{link.category ?? '未分类'}
{formatDateTime(link.created_at)}
@@ -230,7 +229,7 @@ export function FriendLinksPage() {
{!filteredLinks.length ? (
-
No friend links match the current filters.
+
当前筛选条件下没有匹配的友链。
) : null}
@@ -241,10 +240,9 @@ export function FriendLinksPage() {
- {selectedLink ? 'Edit friend link' : 'Create friend link'}
+ {selectedLink ? '编辑友链' : '新建友链'}
- Capture the reciprocal URL, classification, and moderation status the public link
- page depends on.
+ 维护前台友链页依赖的互链地址、分类和审核状态。
@@ -252,14 +250,36 @@ export function FriendLinksPage() {
- Visit site
+ 访问站点
) : null}
+ {selectedLink ? (
+ <>
+
setForm((current) => ({ ...current, status: 'approved' }))}
+ >
+ 通过
+
+
setForm((current) => ({ ...current, status: 'pending' }))}
+ >
+ 待审核
+
+
setForm((current) => ({ ...current, status: 'rejected' }))}
+ >
+ 拒绝
+
+ >
+ ) : null}
{
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}
>
- {saving ? 'Saving...' : selectedLink ? 'Save changes' : 'Create link'}
+ {saving ? '保存中...' : selectedLink ? '保存修改' : '创建友链'}
{selectedLink ? (
{
- 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)
}
}}
>
- {deleting ? 'Deleting...' : 'Delete'}
+ {deleting ? '删除中...' : '删除'}
) : null}
@@ -332,21 +348,21 @@ export function FriendLinksPage() {
- Selected record
+ 当前记录
- Created {formatDateTime(selectedLink.created_at)}
+ 创建于 {formatDateTime(selectedLink.created_at)}
- {selectedLink.status ?? 'pending'}
+ {formatFriendLinkStatus(selectedLink.status)}
) : null}
-
+
@@ -354,7 +370,7 @@ export function FriendLinksPage() {
}
/>
-
+
@@ -362,7 +378,7 @@ export function FriendLinksPage() {
}
/>
-
+
@@ -370,7 +386,7 @@ export function FriendLinksPage() {
}
/>
-
+
@@ -379,21 +395,49 @@ export function FriendLinksPage() {
/>
-
-
- setForm((current) => ({ ...current, status: event.target.value }))
- }
- >
- Pending
- Approved
- Rejected
-
+
+
+
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'
+ }`}
+ >
+ 待审核
+ 保留在队列里继续观察。
+
+
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'
+ }`}
+ >
+ 通过
+ 前台会按已通过友链展示。
+
+
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'
+ }`}
+ >
+ 拒绝
+ 保留记录,但不在前台展示。
+
+