chore: checkpoint admin editor and perf work
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: '设置',
|
||||
|
||||
72
admin/src/components/lazy-monaco.tsx
Normal file
72
admin/src/components/lazy-monaco.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
413
admin/src/pages/analytics-page.tsx
Normal file
413
admin/src/pages/analytics-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
admin/src/pages/media-page.tsx
Normal file
190
admin/src/pages/media-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user