chore: checkpoint admin editor and perf work

This commit is contained in:
2026-03-31 00:12:02 +08:00
parent 92a85eef20
commit 99b308e800
45 changed files with 7265 additions and 833 deletions

View File

@@ -1,5 +1,7 @@
import {
createContext,
lazy,
Suspense,
startTransition,
useContext,
useEffect,
@@ -22,13 +24,40 @@ import { Toaster, toast } from 'sonner'
import { AppShell } from '@/components/app-shell'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSessionResponse } from '@/lib/types'
import { CommentsPage } from '@/pages/comments-page'
import { DashboardPage } from '@/pages/dashboard-page'
import { FriendLinksPage } from '@/pages/friend-links-page'
import { LoginPage } from '@/pages/login-page'
import { PostsPage } from '@/pages/posts-page'
import { ReviewsPage } from '@/pages/reviews-page'
import { SiteSettingsPage } from '@/pages/site-settings-page'
const DashboardPage = lazy(async () => {
const mod = await import('@/pages/dashboard-page')
return { default: mod.DashboardPage }
})
const AnalyticsPage = lazy(async () => {
const mod = await import('@/pages/analytics-page')
return { default: mod.AnalyticsPage }
})
const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const CommentsPage = lazy(async () => {
const mod = await import('@/pages/comments-page')
return { default: mod.CommentsPage }
})
const FriendLinksPage = lazy(async () => {
const mod = await import('@/pages/friend-links-page')
return { default: mod.FriendLinksPage }
})
const MediaPage = lazy(async () => {
const mod = await import('@/pages/media-page')
return { default: mod.MediaPage }
})
const ReviewsPage = lazy(async () => {
const mod = await import('@/pages/reviews-page')
return { default: mod.ReviewsPage }
})
const SiteSettingsPage = lazy(async () => {
const mod = await import('@/pages/site-settings-page')
return { default: mod.SiteSettingsPage }
})
type SessionContextValue = {
session: AdminSessionResponse
@@ -69,6 +98,26 @@ function AppLoadingScreen() {
)
}
function RouteLoadingScreen() {
return (
<div className="flex min-h-[320px] items-center justify-center rounded-3xl border border-border/70 bg-card/60 px-6 py-10 text-center text-muted-foreground">
<div className="space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LoaderCircle className="h-5 w-5 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 text-sm"></p>
</div>
</div>
</div>
)
}
function LazyRoute({ children }: { children: ReactNode }) {
return <Suspense fallback={<RouteLoadingScreen />}>{children}</Suspense>
}
function RequireAuth({ children }: { children: ReactNode }) {
const { session } = useSession()
@@ -151,14 +200,79 @@ function AppRoutes() {
<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
index
element={
<LazyRoute>
<DashboardPage />
</LazyRoute>
}
/>
<Route
path="analytics"
element={
<LazyRoute>
<AnalyticsPage />
</LazyRoute>
}
/>
<Route
path="posts"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="posts/:slug"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="comments"
element={
<LazyRoute>
<CommentsPage />
</LazyRoute>
}
/>
<Route
path="friend-links"
element={
<LazyRoute>
<FriendLinksPage />
</LazyRoute>
}
/>
<Route
path="media"
element={
<LazyRoute>
<MediaPage />
</LazyRoute>
}
/>
<Route
path="reviews"
element={
<LazyRoute>
<ReviewsPage />
</LazyRoute>
}
/>
<Route
path="settings"
element={
<LazyRoute>
<SiteSettingsPage />
</LazyRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,6 +1,8 @@
import {
BarChart3,
BookOpenText,
ExternalLink,
Image as ImageIcon,
LayoutDashboard,
Link2,
LogOut,
@@ -25,6 +27,12 @@ const primaryNav = [
description: '站点运营总览',
icon: LayoutDashboard,
},
{
to: '/analytics',
label: '数据分析',
description: '搜索词与 AI 问答洞察',
icon: BarChart3,
},
{
to: '/posts',
label: '文章',
@@ -49,6 +57,12 @@ const primaryNav = [
description: '评测内容库',
icon: BookOpenText,
},
{
to: '/media',
label: '媒体库',
description: '对象存储图片管理',
icon: ImageIcon,
},
{
to: '/settings',
label: '设置',

View File

@@ -0,0 +1,72 @@
import { lazy, Suspense } from 'react'
import type { DiffEditorProps, EditorProps } from '@monaco-editor/react'
const MonacoEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.default }
})
const MonacoDiffEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.DiffEditor }
})
function MonacoLoading({
height,
width,
className,
loading,
}: {
height?: string | number
width?: string | number
className?: string
loading?: React.ReactNode
}) {
return (
<div
className={className}
style={{ height: height ?? '100%', width: width ?? '100%' }}
>
{loading ?? (
<div className="flex h-full min-h-[280px] items-center justify-center bg-[#111111] text-sm text-slate-400">
...
</div>
)}
</div>
)
}
export function LazyEditor(props: EditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoEditor {...props} />
</Suspense>
)
}
export function LazyDiffEditor(props: DiffEditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoDiffEditor {...props} />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { useMemo } from 'react'
import { useDeferredValue, useMemo } from 'react'
import { cn } from '@/lib/utils'
@@ -15,10 +15,11 @@ marked.setOptions({
})
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
const deferredMarkdown = useDeferredValue(markdown)
const html = useMemo(() => {
const rendered = marked.parse(markdown || '暂无内容。')
const rendered = marked.parse(deferredMarkdown || '暂无内容。')
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
}, [markdown])
}, [deferredMarkdown])
return (
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>

View File

@@ -2,9 +2,10 @@ 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 type { BeforeMount } from '@monaco-editor/react'
import { Expand, Minimize2, Sparkles } from 'lucide-react'
import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -16,6 +17,7 @@ type MarkdownWorkbenchProps = {
originalValue: string
diffValue?: string
path: string
workspaceHeightClassName?: string
readOnly?: boolean
mode: MarkdownWorkbenchMode
visiblePanels: MarkdownWorkbenchPanel[]
@@ -114,6 +116,7 @@ export function MarkdownWorkbench({
originalValue,
diffValue,
path,
workspaceHeightClassName = 'h-[560px]',
readOnly = false,
mode,
visiblePanels,
@@ -128,7 +131,7 @@ export function MarkdownWorkbench({
onVisiblePanelsChange,
}: MarkdownWorkbenchProps) {
const [fullscreen, setFullscreen] = useState(false)
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : 'h-[560px]'
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName
const diffContent = diffValue ?? value
const polishEnabled = allowPolish ?? Boolean(polishPanel)
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
@@ -262,7 +265,7 @@ export function MarkdownWorkbench({
{panel === 'edit' ? (
<div className="min-h-0 flex-1">
<Editor
<LazyEditor
height="100%"
language="markdown"
path={path}
@@ -286,7 +289,7 @@ export function MarkdownWorkbench({
{panel === 'diff' ? (
<div className="min-h-0 flex-1">
<DiffEditor
<LazyDiffEditor
height="100%"
language="markdown"
original={originalValue}

View File

@@ -1,18 +1,473 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
({ className, ...props }, ref) => (
<select
ref={ref}
className={cn(
'flex h-11 w-full rounded-xl border border-input bg-background/80 px-3 py-2 text-sm shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
type NativeSelectProps = React.ComponentProps<'select'>
type SelectOption = {
value: string
label: React.ReactNode
disabled: boolean
}
type MenuPlacement = 'top' | 'bottom'
function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
if (Array.isArray(value)) {
return value[0] == null ? '' : String(value[0])
}
return value == null ? '' : String(value)
}
function extractOptions(children: React.ReactNode) {
const options: SelectOption[] = []
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child) || child.type !== 'option') {
return
}
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
children?: React.ReactNode
}
options.push({
value: normalizeValue(props.value),
label: props.children,
disabled: Boolean(props.disabled),
})
})
return options
}
function getFirstEnabledIndex(options: SelectOption[]) {
return options.findIndex((option) => !option.disabled)
}
function getLastEnabledIndex(options: SelectOption[]) {
for (let index = options.length - 1; index >= 0; index -= 1) {
if (!options[index]?.disabled) {
return index
}
}
return -1
}
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
if (options.length === 0) {
return -1
}
let index = currentIndex
for (let step = 0; step < options.length; step += 1) {
index = (index + direction + options.length) % options.length
if (!options[index]?.disabled) {
return index
}
}
return -1
}
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{
children,
className,
defaultValue,
disabled = false,
id,
onBlur,
onClick,
onFocus,
onKeyDown,
value,
...props
},
forwardedRef,
) => {
const options = React.useMemo(() => extractOptions(children), [children])
const isControlled = value !== undefined
const initialValue = React.useMemo(() => {
if (defaultValue !== undefined) {
return normalizeValue(defaultValue)
}
return options[0]?.value ?? ''
}, [defaultValue, options])
const [internalValue, setInternalValue] = React.useState(initialValue)
const [open, setOpen] = React.useState(false)
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
const menuRef = React.useRef<HTMLDivElement>(null)
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const menuId = React.useId()
const currentValue = isControlled ? normalizeValue(value) : internalValue
const selectedIndex = options.findIndex((option) => option.value === currentValue)
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
React.useEffect(() => {
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
setInternalValue(options[0]?.value ?? '')
}
}, [internalValue, isControlled, options])
const updateMenuPosition = React.useCallback(() => {
const trigger = triggerRef.current
if (!trigger) {
return
}
const rect = trigger.getBoundingClientRect()
const viewportPadding = 12
const gutter = 6
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
const spaceAbove = rect.top - viewportPadding
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
setMenuPlacement(openToTop ? 'top' : 'bottom')
setMenuStyle(
openToTop
? {
left,
width,
maxHeight,
bottom: window.innerHeight - rect.top + gutter,
}
: {
left,
width,
maxHeight,
top: rect.bottom + gutter,
},
)
}, [options.length])
const setOpenWithHighlight = React.useCallback(
(nextOpen: boolean, preferredIndex?: number) => {
if (disabled) {
return
}
if (nextOpen) {
const fallbackIndex =
preferredIndex ??
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
? selectedIndex
: getFirstEnabledIndex(options))
setHighlightedIndex(fallbackIndex)
updateMenuPosition()
setOpen(true)
return
}
setOpen(false)
},
[disabled, options, selectedIndex, updateMenuPosition],
)
const commitValue = React.useCallback(
(nextIndex: number) => {
const option = options[nextIndex]
const nativeSelect = nativeSelectRef.current
if (!option || option.disabled) {
return
}
if (!isControlled) {
setInternalValue(option.value)
}
if (nativeSelect && currentValue !== option.value) {
nativeSelect.value = option.value
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
}
setOpen(false)
window.requestAnimationFrame(() => {
triggerRef.current?.focus()
})
},
[currentValue, isControlled, options],
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
if (event.defaultPrevented || disabled) {
return
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, 1)
: getFirstEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
return
}
case 'ArrowUp': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, -1)
: getLastEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
return
}
case 'Home': {
event.preventDefault()
const firstIndex = getFirstEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, firstIndex)
return
}
setHighlightedIndex(firstIndex)
return
}
case 'End': {
event.preventDefault()
const lastIndex = getLastEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, lastIndex)
return
}
setHighlightedIndex(lastIndex)
return
}
case 'Enter':
case ' ': {
event.preventDefault()
if (!open) {
setOpenWithHighlight(true)
return
}
if (highlightedIndex >= 0) {
commitValue(highlightedIndex)
}
return
}
case 'Escape': {
if (!open) {
return
}
event.preventDefault()
setOpen(false)
return
}
case 'Tab': {
setOpen(false)
return
}
default:
return
}
},
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
)
React.useLayoutEffect(() => {
if (!open) {
return
}
updateMenuPosition()
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open) {
return
}
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
return
}
setOpen(false)
}
const handleWindowChange = () => updateMenuPosition()
document.addEventListener('mousedown', handlePointerDown)
window.addEventListener('resize', handleWindowChange)
window.addEventListener('scroll', handleWindowChange, true)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
window.removeEventListener('resize', handleWindowChange)
window.removeEventListener('scroll', handleWindowChange, true)
}
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open || highlightedIndex < 0) {
return
}
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex, open])
const triggerClasses = cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
className,
)
const menu = open && menuStyle
? ReactDOM.createPortal(
<div
ref={menuRef}
aria-orientation="vertical"
className={cn(
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
)}
id={menuId}
onKeyDown={handleKeyDown}
role="listbox"
style={menuStyle}
tabIndex={-1}
>
<div className="max-h-full overflow-y-auto pr-0.5">
{options.map((option, index) => {
const selected = option.value === currentValue
const highlighted = index === highlightedIndex
return (
<button
key={`${option.value}-${index}`}
ref={(node) => {
optionRefs.current[index] = node
}}
aria-selected={selected}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
selected
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
: highlighted
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
)}
disabled={option.disabled}
onClick={() => commitValue(index)}
onMouseEnter={() => {
if (!option.disabled) {
setHighlightedIndex(index)
}
}}
role="option"
type="button"
>
<span className="truncate">{option.label}</span>
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
</button>
)
})}
</div>
</div>,
document.body,
)
: null
return (
<div className="relative w-full" ref={wrapperRef}>
<select
{...props}
aria-hidden="true"
className="pointer-events-none absolute h-0 w-0 opacity-0"
defaultValue={defaultValue}
disabled={disabled}
id={id}
onBlur={onBlur}
onFocus={onFocus}
ref={(node) => {
nativeSelectRef.current = node
if (typeof forwardedRef === 'function') {
forwardedRef(node)
} else if (forwardedRef) {
forwardedRef.current = node
}
}}
tabIndex={-1}
value={isControlled ? currentValue : internalValue}
>
{children}
</select>
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="listbox"
className={triggerClasses}
data-state={open ? 'open' : 'closed'}
disabled={disabled}
onBlur={(event) => {
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onClick={(event) => {
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
}}
onPointerDown={(event) => {
if (event.button !== 0 || disabled) {
return
}
event.preventDefault()
triggerRef.current?.focus()
setOpenWithHighlight(!open)
}}
onFocus={(event) => {
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onKeyDown={handleKeyDown}
ref={triggerRef}
role="combobox"
type="button"
>
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
</span>
</button>
{menu}
</div>
)
},
)
Select.displayName = 'Select'

View File

@@ -116,6 +116,23 @@ a {
button,
input,
textarea {
textarea,
select {
font: inherit;
}
@keyframes custom-select-pop {
from {
opacity: 0;
transform: translateY(-2px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.custom-select-popover {
animation: custom-select-pop 0.1s ease-out;
}

View File

@@ -1,9 +1,19 @@
import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse,
AdminPostMetadataResponse,
AdminPostPolishResponse,
AdminReviewPolishRequest,
AdminReviewPolishResponse,
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
CommentListQuery,
@@ -117,6 +127,7 @@ export const adminApi = {
method: 'DELETE',
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
@@ -139,6 +150,48 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ provider }),
}),
testAiImageProvider: (provider: {
provider: string
api_base: string | null
api_key: string | null
image_model: string | null
}) =>
request<AdminAiImageProviderTestResponse>('/api/admin/ai/test-image-provider', {
method: 'POST',
body: JSON.stringify({
provider: provider.provider,
api_base: provider.api_base,
api_key: provider.api_key,
image_model: provider.image_model,
}),
}),
uploadReviewCoverImage: (file: File) => {
const formData = new FormData()
formData.append('file', file, file.name)
return request<AdminImageUploadResponse>('/api/admin/storage/review-cover', {
method: 'POST',
body: formData,
})
},
testR2Storage: () =>
request<AdminR2ConnectivityResponse>('/api/admin/storage/r2/test', {
method: 'POST',
}),
listMediaObjects: (query?: { prefix?: string; limit?: number }) =>
request<AdminMediaListResponse>(
appendQueryParams('/api/admin/storage/media', {
prefix: query?.prefix,
limit: query?.limit,
}),
),
deleteMediaObject: (key: string) =>
request<AdminMediaDeleteResponse>(
`/api/admin/storage/media?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
},
),
generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST',
@@ -149,6 +202,32 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ markdown }),
}),
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
review_type: payload.reviewType,
rating: payload.rating,
review_date: payload.reviewDate,
status: payload.status,
tags: payload.tags,
description: payload.description,
}),
}),
generatePostCoverImage: (payload: AdminPostCoverImageRequest) =>
request<AdminPostCoverImageResponse>('/api/admin/ai/post-cover', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
description: payload.description,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
slug: payload.slug,
markdown: payload.markdown,
}),
}),
listPosts: (query?: PostListQuery) =>
request<PostRecord[]>(
appendQueryParams('/api/posts', {

View File

@@ -71,6 +71,58 @@ export interface AdminDashboardResponse {
recent_reviews: DashboardReviewItem[]
}
export interface AnalyticsOverview {
total_searches: number
total_ai_questions: number
searches_last_24h: number
ai_questions_last_24h: number
searches_last_7d: number
ai_questions_last_7d: number
unique_search_terms_last_7d: number
unique_ai_questions_last_7d: number
avg_search_results_last_7d: number
avg_ai_latency_ms_last_7d: number | null
}
export interface AnalyticsTopQuery {
query: string
count: number
last_seen_at: string
}
export interface AnalyticsRecentEvent {
id: number
event_type: string
query: string
result_count: number | null
success: boolean | null
response_mode: string | null
provider: string | null
chat_model: string | null
latency_ms: number | null
created_at: string
}
export interface AnalyticsProviderBucket {
provider: string
count: number
}
export interface AnalyticsDailyBucket {
date: string
searches: number
ai_questions: number
}
export interface AdminAnalyticsResponse {
overview: AnalyticsOverview
top_search_terms: AnalyticsTopQuery[]
top_ai_questions: AnalyticsTopQuery[]
recent_events: AnalyticsRecentEvent[]
providers_last_7d: AnalyticsProviderBucket[]
daily_activity: AnalyticsDailyBucket[]
}
export interface AdminSiteSettingsResponse {
id: number
site_name: string | null
@@ -96,6 +148,10 @@ export interface AdminSiteSettingsResponse {
ai_api_base: string | null
ai_api_key: string | null
ai_chat_model: string | null
ai_image_provider: string | null
ai_image_api_base: string | null
ai_image_api_key: string | null
ai_image_model: string | null
ai_providers: AiProviderConfig[]
ai_active_provider_id: string | null
ai_embedding_model: string | null
@@ -105,6 +161,12 @@ export interface AdminSiteSettingsResponse {
ai_last_indexed_at: string | null
ai_chunks_count: number
ai_local_embedding: string
media_storage_provider: string | null
media_r2_account_id: string | null
media_r2_bucket: string | null
media_r2_public_base_url: string | null
media_r2_access_key_id: string | null
media_r2_secret_access_key: string | null
}
export interface AiProviderConfig {
@@ -114,6 +176,7 @@ export interface AiProviderConfig {
api_base: string | null
api_key: string | null
chat_model: string | null
image_model: string | null
}
export interface SiteSettingsPayload {
@@ -140,12 +203,22 @@ export interface SiteSettingsPayload {
aiApiBase?: string | null
aiApiKey?: string | null
aiChatModel?: string | null
aiImageProvider?: string | null
aiImageApiBase?: string | null
aiImageApiKey?: string | null
aiImageModel?: string | null
aiProviders?: AiProviderConfig[]
aiActiveProviderId?: string | null
aiEmbeddingModel?: string | null
aiSystemPrompt?: string | null
aiTopK?: number | null
aiChunkSize?: number | null
mediaStorageProvider?: string | null
mediaR2AccountId?: string | null
mediaR2Bucket?: string | null
mediaR2PublicBaseUrl?: string | null
mediaR2AccessKeyId?: string | null
mediaR2SecretAccessKey?: string | null
}
export interface AdminAiReindexResponse {
@@ -160,6 +233,42 @@ export interface AdminAiProviderTestResponse {
reply_preview: string
}
export interface AdminAiImageProviderTestResponse {
provider: string
endpoint: string
image_model: string
result_preview: string
}
export interface AdminImageUploadResponse {
url: string
key: string
}
export interface AdminR2ConnectivityResponse {
bucket: string
public_base_url: string
}
export interface AdminMediaObjectResponse {
key: string
url: string
size_bytes: number
last_modified: string | null
}
export interface AdminMediaListResponse {
provider: string
bucket: string
public_base_url: string
items: AdminMediaObjectResponse[]
}
export interface AdminMediaDeleteResponse {
deleted: boolean
key: string
}
export interface MusicTrack {
title: string
artist?: string | null
@@ -182,6 +291,35 @@ export interface AdminPostPolishResponse {
polished_markdown: string
}
export interface AdminReviewPolishRequest {
title: string
reviewType: string
rating: number
reviewDate?: string | null
status: string
tags: string[]
description: string
}
export interface AdminReviewPolishResponse {
polished_description: string
}
export interface AdminPostCoverImageRequest {
title: string
description?: string | null
category?: string | null
tags: string[]
postType: string
slug?: string | null
markdown: string
}
export interface AdminPostCoverImageResponse {
image_url: string
prompt: string
}
export interface PostRecord {
created_at: string
updated_at: string

View File

@@ -0,0 +1,413 @@
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
label,
value,
note,
icon: Icon,
}: {
label: string
value: string
note: string
icon: typeof Search
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
}
function formatEventType(value: string) {
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
}
function formatSuccess(value: boolean | null) {
if (value === null) {
return '未记录'
}
return value ? '成功' : '失败'
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadAnalytics = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.analytics()
startTransition(() => {
setData(next)
})
if (showToast) {
toast.success('数据分析已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadAnalytics(false)
}, [loadAnalytics])
const maxDailyTotal = useMemo(() => {
if (!data?.daily_activity.length) {
return 1
}
return Math.max(
...data.daily_activity.map((item) => item.searches + item.ai_questions),
1,
)
}, [data])
if (loading || !data) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Skeleton className="h-[520px] rounded-3xl" />
<Skeleton className="h-[520px] rounded-3xl" />
</div>
</div>
)
}
const statCards = [
{
label: '累计搜索',
value: String(data.overview.total_searches),
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)}`,
icon: Search,
},
{
label: '累计 AI 提问',
value: String(data.overview.total_ai_questions),
note: `近 7 天 ${data.overview.ai_questions_last_7d}`,
icon: BrainCircuit,
},
{
label: '24 小时活跃',
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
icon: Clock3,
},
{
label: '近 7 天去重词',
value: String(
data.overview.unique_search_terms_last_7d +
data.overview.unique_ai_questions_last_7d,
),
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
icon: BarChart3,
},
]
return (
<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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
</Button>
<Button
variant="secondary"
onClick={() => void loadAnalytics(true)}
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{statCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
</div>
<Badge variant="outline">{data.recent_events.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recent_events.map((event) => (
<TableRow key={event.id}>
<TableCell>
<div className="space-y-1">
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
{formatEventType(event.event_type)}
</Badge>
{event.response_mode ? (
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{event.response_mode}
</p>
) : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<p className="line-clamp-2 font-medium">{event.query}</p>
<p className="text-xs text-muted-foreground">
{event.provider ? `${event.provider}` : '未记录渠道'}
{event.chat_model ? ` / ${event.chat_model}` : ''}
</p>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div>{formatSuccess(event.success)}</div>
<div className="mt-1">
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
</div>
{event.latency_ms !== null ? (
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
{event.latency_ms} ms
</div>
) : null}
</TableCell>
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_search_terms.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_search_terms.length ? (
data.top_search_terms.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle> AI </CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_ai_questions.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_ai_questions.length ? (
data.top_ai_questions.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
24 7
</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">
24
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</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">
24 AI
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</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">
AI
</p>
<p className="mt-3 text-3xl font-semibold">
{data.overview.avg_ai_latency_ms_last_7d !== null
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
: '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 AI 使 provider
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.providers_last_7d.length ? (
data.providers_last_7d.map((item) => (
<div
key={item.provider}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="font-medium">{item.provider}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7 </CardTitle>
<CardDescription>
AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data.daily_activity.map((item) => {
const total = item.searches + item.ai_questions
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
return (
<div key={item.date} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{item.date}</span>
<span className="text-muted-foreground">
{item.searches} / AI {item.ai_questions}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminMediaObjectResponse } from '@/lib/types'
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
}
export function MediaPage() {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const [prefixFilter, setPrefixFilter] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const [provider, setProvider] = useState<string | null>(null)
const [bucket, setBucket] = useState<string | null>(null)
const loadItems = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
startTransition(() => {
setItems(result.items)
setProvider(result.provider)
setBucket(result.bucket)
})
if (showToast) {
toast.success('媒体对象列表已刷新。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [prefixFilter])
useEffect(() => {
void loadItems(false)
}, [loadItems])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) => item.key.toLowerCase().includes(keyword))
}, [items, searchTerm])
return (
<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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Provider{provider ?? '未配置'} / Bucket{bucket ?? '未配置'}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</CardContent>
</Card>
{loading ? (
<Skeleton className="h-[520px] rounded-3xl" />
) : (
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{filteredItems.map((item) => (
<Card key={item.key} className="overflow-hidden">
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key ? '删除中...' : '删除'}
</Button>
</div>
</CardContent>
</Card>
))}
{!filteredItems.length ? (
<Card className="xl:col-span-2 2xl:col-span-3">
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<p></p>
</CardContent>
</Card>
) : null}
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,3 @@
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'
@@ -8,6 +7,7 @@ import {
editorTheme,
sharedOptions,
} from '@/components/markdown-workbench'
import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MarkdownPreview } from '@/components/markdown-preview'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -191,7 +191,7 @@ export function PostPolishPage() {
<span></span>
</div>
<div className="h-[560px]">
<DiffEditor
<LazyDiffEditor
height="100%"
language="markdown"
original={originalMarkdown}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { BookOpenText, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
@@ -32,6 +32,11 @@ type ReviewFormState = {
linkUrl: string
}
type ReviewDescriptionPolishState = {
originalDescription: string
polishedDescription: string
}
const defaultReviewForm: ReviewFormState = {
title: '',
reviewType: 'book',
@@ -94,8 +99,14 @@ export function ReviewsPage() {
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [polishingDescription, setPolishingDescription] = useState(false)
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
null,
)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
const loadReviews = useCallback(async (showToast = false) => {
try {
@@ -153,6 +164,70 @@ export function ReviewsPage() {
[reviews, selectedId],
)
const requestDescriptionPolish = useCallback(async () => {
if (!form.description.trim()) {
toast.error('请先写一点点评内容,再让 AI 帮你润色。')
return
}
try {
setPolishingDescription(true)
const result = await adminApi.polishReviewDescription({
title: form.title.trim() || '未命名评测',
reviewType: form.reviewType,
rating: Number(form.rating) || 0,
reviewDate: form.reviewDate || null,
status: form.status,
tags: csvToList(form.tags),
description: form.description,
})
const polishedDescription =
typeof result.polished_description === 'string' ? result.polished_description : ''
if (!polishedDescription.trim()) {
throw new Error('AI 润色返回为空。')
}
startTransition(() => {
setDescriptionPolish({
originalDescription: form.description,
polishedDescription,
})
})
if (polishedDescription.trim() === form.description.trim()) {
toast.success('AI 已检查这段点评,当前文案已经比较完整。')
} else {
toast.success('AI 已生成一版更顺的点评文案,可以先对比再决定是否采用。')
}
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: 'AI 润色点评失败。',
)
} finally {
setPolishingDescription(false)
}
}, [form])
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const result = await adminApi.uploadReviewCoverImage(file)
startTransition(() => {
setForm((current) => ({ ...current, cover: result.url }))
})
toast.success('评测封面已上传到 R2。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
} finally {
setUploadingCover(false)
}
}, [])
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -172,6 +247,7 @@ export function ReviewsPage() {
onClick={() => {
setSelectedId(null)
setForm(defaultReviewForm)
setDescriptionPolish(null)
}}
>
@@ -220,6 +296,7 @@ export function ReviewsPage() {
onClick={() => {
setSelectedId(review.id)
setForm(toFormState(review))
setDescriptionPolish(null)
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === review.id
@@ -295,6 +372,7 @@ export function ReviewsPage() {
startTransition(() => {
setSelectedId(updated.id)
setForm(toFormState(updated))
setDescriptionPolish(null)
})
toast.success('评测已更新。')
} else {
@@ -302,6 +380,7 @@ export function ReviewsPage() {
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
setDescriptionPolish(null)
})
toast.success('评测已创建。')
}
@@ -332,6 +411,7 @@ export function ReviewsPage() {
toast.success('评测已删除。')
setSelectedId(null)
setForm(defaultReviewForm)
setDescriptionPolish(null)
await loadReviews(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
@@ -414,13 +494,49 @@ export function ReviewsPage() {
<option value="archived"></option>
</Select>
</FormField>
<FormField label="封面 URL">
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row">
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<input
ref={reviewCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadReviewCover(file)
}
event.target.value = ''
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingCover}
onClick={() => reviewCoverInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploadingCover ? '上传中...' : '上传到 R2'}
</Button>
</div>
{form.cover ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
<img
src={form.cover}
alt={form.title || '评测封面预览'}
className="h-48 w-full object-cover"
/>
</div>
) : null}
</div>
</FormField>
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
<Input
@@ -442,13 +558,113 @@ export function ReviewsPage() {
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="简介">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
<FormField
label="简介 / 点评"
hint="可以先写你的原始观感,再用 AI 帮你把这段点评润得更顺。"
>
<div className="space-y-3">
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm leading-6 text-muted-foreground">
AI
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
{polishingDescription ? '润色中...' : 'AI 润色点评'}
</Button>
{descriptionPolish ? (
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<Textarea
value={form.description}
onChange={(event) => {
const nextDescription = event.target.value
setForm((current) => ({ ...current, description: nextDescription }))
setDescriptionPolish((current) =>
current && current.originalDescription === nextDescription ? current : null,
)
}}
/>
{descriptionPolish ? (
<div className="overflow-hidden rounded-[1.8rem] border border-border/70 bg-background/80">
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-base font-semibold">AI </p>
<p className="mt-1 text-sm text-muted-foreground">
AI
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setForm((current) => ({
...current,
description: descriptionPolish.polishedDescription,
}))
setDescriptionPolish(null)
toast.success('AI 润色点评已回填到评测简介。')
}}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
</Button>
</div>
</div>
<div className="grid gap-4 p-5 xl:grid-cols-2">
<div className="rounded-[1.4rem] border border-border/70 bg-muted/20 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.originalDescription.trim() || '未填写'}
</p>
</div>
<div className="rounded-[1.4rem] border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
AI
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.polishedDescription.trim() || '未填写'}
</p>
</div>
</div>
</div>
) : null}
</div>
</FormField>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
@@ -42,10 +43,39 @@ function createEmptyAiProvider(): AiProviderConfig {
return {
id: createAiProviderId(),
name: '',
provider: 'newapi',
provider: 'openai',
api_base: '',
api_key: '',
chat_model: '',
image_model: '',
}
}
const AI_PROVIDER_OPTIONS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'cloudflare', label: 'Cloudflare Workers AI' },
{ value: 'newapi', label: 'NewAPI / Responses' },
{ value: 'openai-compatible', label: 'OpenAI Compatible' },
] as const
const MEDIA_STORAGE_PROVIDER_OPTIONS = [
{ value: 'r2', label: 'Cloudflare R2' },
{ value: 'minio', label: 'MinIO' },
] as const
function isCloudflareProvider(provider: string | null | undefined) {
const normalized = provider?.trim().toLowerCase()
return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
}
function buildCloudflareAiPreset(current: AiProviderConfig): AiProviderConfig {
return {
...current,
name: current.name?.trim() ? current.name : 'Cloudflare Workers AI',
provider: 'cloudflare',
chat_model: current.chat_model?.trim() || '@cf/meta/llama-3.1-8b-instruct',
}
}
@@ -105,12 +135,22 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
aiApiBase: form.ai_api_base,
aiApiKey: form.ai_api_key,
aiChatModel: form.ai_chat_model,
aiImageProvider: form.ai_image_provider,
aiImageApiBase: form.ai_image_api_base,
aiImageApiKey: form.ai_image_api_key,
aiImageModel: form.ai_image_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,
aiChunkSize: form.ai_chunk_size,
mediaStorageProvider: form.media_storage_provider,
mediaR2AccountId: form.media_r2_account_id,
mediaR2Bucket: form.media_r2_bucket,
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
mediaR2AccessKeyId: form.media_r2_access_key_id,
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
}
}
@@ -120,6 +160,8 @@ export function SiteSettingsPage() {
const [saving, setSaving] = useState(false)
const [reindexing, setReindexing] = useState(false)
const [testingProvider, setTestingProvider] = useState(false)
const [testingImageProvider, setTestingImageProvider] = useState(false)
const [testingR2Storage, setTestingR2Storage] = useState(false)
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
@@ -290,6 +332,38 @@ export function SiteSettingsPage() {
updateField('ai_active_provider_id', providerId)
}
const applyCloudflarePreset = (index: number) => {
setForm((current) => {
if (!current) {
return current
}
const nextProviders = current.ai_providers.map((provider, providerIndex) =>
providerIndex === index ? buildCloudflareAiPreset(provider) : provider,
)
return {
...current,
ai_providers: nextProviders,
}
})
}
const applyCloudflareImagePreset = () => {
setForm((current) => {
if (!current) {
return current
}
return {
...current,
ai_image_provider: 'cloudflare',
ai_image_model:
current.ai_image_model?.trim() || '@cf/black-forest-labs/flux-2-klein-4b',
}
})
}
const techStackValue = useMemo(
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
[form?.tech_stack],
@@ -306,6 +380,18 @@ export function SiteSettingsPage() {
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
[form],
)
const selectedProviderIsCloudflare = useMemo(
() => isCloudflareProvider(selectedProvider.provider),
[selectedProvider.provider],
)
const imageProviderIsCloudflare = useMemo(
() => isCloudflareProvider(form?.ai_image_provider),
[form?.ai_image_provider],
)
const mediaStorageProvider = useMemo(
() => (form?.media_storage_provider?.trim().toLowerCase() === 'minio' ? 'minio' : 'r2'),
[form?.media_storage_provider],
)
if (loading || !form) {
return (
@@ -532,7 +618,7 @@ export function SiteSettingsPage() {
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>
AI 使
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
@@ -551,182 +637,328 @@ export function SiteSettingsPage() {
</div>
</label>
<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 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"> Provider</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.provider?.trim() || '未填写'}
</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="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 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"
onClick={() => applyCloudflarePreset(selectedProviderIndex)}
>
<RefreshCcw className="h-4 w-4" />
Cloudflare
</Button>
<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>
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
<Input
value={selectedProvider.name ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'name', event.target.value)
}
/>
</Field>
<Field
label="Provider"
hint="选择文本模型提供方。Cloudflare 文本模型也支持。"
>
<Select
value={selectedProvider.provider ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
}
>
{AI_PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field
label="API 地址"
hint={selectedProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID或填写 https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>。' : undefined}
>
<Input
value={selectedProvider.api_base ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
}
placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
/>
</Field>
<Field
label="API 密钥"
hint={selectedProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。该 Token 需要 Workers AI Read 和 Edit 权限。' : undefined}
>
<Input
value={selectedProvider.api_key ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
}
/>
</Field>
<Field
label="对话模型"
hint={selectedProviderIsCloudflare ? '例如 @cf/meta/llama-3.1-8b-instruct用于问答与连通性测试。' : undefined}
>
<Input
value={selectedProvider.chat_model ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
}
/>
</Field>
{selectedProviderIsCloudflare ? (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"> / Cloudflare </p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>API Cloudflare Account ID</li>
<li> AI </li>
</ul>
</div>
) : null}
</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>
</Field>
</div>
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
AI
{activeProvider
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
: '未选择提供商'}
</div>
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium"></p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
AI
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={applyCloudflareImagePreset}>
<RefreshCcw className="h-4 w-4" />
Cloudflare
</Button>
<Button
type="button"
variant="outline"
disabled={testingImageProvider}
onClick={async () => {
try {
setTestingImageProvider(true)
const result = await adminApi.testAiImageProvider({
provider: form.ai_image_provider ?? '',
api_base: form.ai_image_api_base,
api_key: form.ai_image_api_key,
image_model: form.ai_image_model,
})
toast.success(
`图片连通成功:${result.provider} / ${result.image_model} / ${result.result_preview}`,
)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : '图片模型连通性测试失败。',
)
} finally {
setTestingImageProvider(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingImageProvider ? '测试中...' : '测试图片连通性'}
</Button>
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<Field
label="图片 Provider"
hint="选择图片模型提供方。这里专门用于封面图生成。"
>
<Select
value={form.ai_image_provider ?? ''}
onChange={(event) => updateField('ai_image_provider', event.target.value)}
>
{AI_PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field
label="图片模型"
hint={imageProviderIsCloudflare ? 'Cloudflare 建议使用 @cf/black-forest-labs/flux-2-klein-4b。' : '这里填写图片模型名。'}
>
<Input
value={form.ai_image_model ?? ''}
onChange={(event) => updateField('ai_image_model', event.target.value)}
placeholder={imageProviderIsCloudflare ? '@cf/black-forest-labs/flux-2-klein-4b' : undefined}
/>
</Field>
<div className="lg:col-span-2">
<Field
label="图片 API 地址"
hint={imageProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID或填写完整 accounts URL。' : '填写图片服务的 API 地址。'}
>
<Input
value={form.ai_image_api_base ?? ''}
onChange={(event) => updateField('ai_image_api_base', event.target.value)}
placeholder={imageProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
/>
</Field>
</div>
<div className="lg:col-span-2">
<Field
label="图片 API 密钥"
hint={imageProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。' : '填写图片服务的 API Key。'}
>
<Input
value={form.ai_image_api_key ?? ''}
onChange={(event) => updateField('ai_image_api_key', event.target.value)}
/>
</Field>
</div>
</div>
<div className="mt-4 rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
AI
{form.ai_image_provider?.trim()
? `${form.ai_image_provider} / ${form.ai_image_model || '未填写模型'}`
: '未填写,封面图会回退到旧配置'}
</div>
{imageProviderIsCloudflare ? (
<div className="mt-4 rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"> / Cloudflare </p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>API Cloudflare Account ID</li>
<li>AI </li>
<li></li>
</ul>
</div>
) : null}
</div>
<Field
label="向量模型"
hint={`本地选项:${form.ai_local_embedding}`}
@@ -771,6 +1003,128 @@ export function SiteSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI Cloudflare R2 / MinIO
</CardDescription>
</div>
<Button
type="button"
variant="outline"
disabled={testingR2Storage}
onClick={async () => {
try {
setTestingR2Storage(true)
const result = await adminApi.testR2Storage()
toast.success(`存储连通成功:${result.bucket} / ${result.public_base_url}`)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '对象存储连通性测试失败。')
} finally {
setTestingR2Storage(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingR2Storage ? '测试中...' : '测试存储连通性'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 lg:grid-cols-2">
<Field
label="存储 Provider"
hint="选择媒体资源存储后端。"
>
<Select
value={form.media_storage_provider ?? 'r2'}
onChange={(event) => updateField('media_storage_provider', event.target.value)}
>
{MEDIA_STORAGE_PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</Field>
<Field
label={mediaStorageProvider === 'minio' ? 'Endpoint' : 'Account ID'}
hint={
mediaStorageProvider === 'minio'
? '例如 http://10.0.0.2:9100 或你的 MinIO API 地址。'
: 'Cloudflare 账户 ID用来拼接 R2 S3 兼容 endpoint。'
}
>
<Input
value={form.media_r2_account_id ?? ''}
onChange={(event) => updateField('media_r2_account_id', event.target.value)}
placeholder={mediaStorageProvider === 'minio' ? 'http://10.0.0.2:9100' : undefined}
/>
</Field>
<Field
label="Bucket"
hint={mediaStorageProvider === 'minio' ? '存放封面图的 MinIO bucket 名称。' : '存放封面图的 R2 bucket 名称。'}
>
<Input
value={form.media_r2_bucket ?? ''}
onChange={(event) => updateField('media_r2_bucket', event.target.value)}
/>
</Field>
<div className="lg:col-span-2">
<Field
label="Public Base URL"
hint={
mediaStorageProvider === 'minio'
? '例如 https://s3.init.cool/你的bucket 或 http://10.0.0.2:9100/你的bucket。系统会把对象 key 拼到这个地址后面。'
: '例如 https://image.init.cool 或你的 R2 公网域名。系统会把对象 key 拼到这个地址后面。'
}
>
<Input
value={form.media_r2_public_base_url ?? ''}
onChange={(event) =>
updateField('media_r2_public_base_url', event.target.value)
}
/>
</Field>
</div>
<Field
label="Access Key ID"
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Access Key ID。' : 'R2 S3 API 的 Access Key ID。'}
>
<Input
value={form.media_r2_access_key_id ?? ''}
onChange={(event) =>
updateField('media_r2_access_key_id', event.target.value)
}
/>
</Field>
<Field
label="Secret Access Key"
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Secret Access Key。后端会用 Rust SDK 上传图片。' : 'R2 S3 API 的 Secret Access Key。后端会用 Rust SDK 上传图片。'}
>
<Input
value={form.media_r2_secret_access_key ?? ''}
onChange={(event) =>
updateField('media_r2_secret_access_key', event.target.value)
}
/>
</Field>
</div>
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
<p className="font-medium text-foreground"></p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li> AI `post-covers/`</li>
<li> `review-covers/`</li>
<li>{mediaStorageProvider === 'minio' ? '当前会按 MinIO / S3 兼容方式上传。' : '当前会按 Cloudflare R2 方式上传。'}</li>
</ul>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>