chore: checkpoint admin editor and perf work
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
startTransition,
|
startTransition,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -22,13 +24,40 @@ import { Toaster, toast } from 'sonner'
|
|||||||
import { AppShell } from '@/components/app-shell'
|
import { AppShell } from '@/components/app-shell'
|
||||||
import { adminApi, ApiError } from '@/lib/api'
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
import type { AdminSessionResponse } from '@/lib/types'
|
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 { LoginPage } from '@/pages/login-page'
|
||||||
import { PostsPage } from '@/pages/posts-page'
|
|
||||||
import { ReviewsPage } from '@/pages/reviews-page'
|
const DashboardPage = lazy(async () => {
|
||||||
import { SiteSettingsPage } from '@/pages/site-settings-page'
|
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 = {
|
type SessionContextValue = {
|
||||||
session: AdminSessionResponse
|
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 }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
|
|
||||||
@@ -152,13 +201,78 @@ function AppRoutes() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route
|
||||||
<Route path="posts" element={<PostsPage />} />
|
index
|
||||||
<Route path="posts/:slug" element={<PostsPage />} />
|
element={
|
||||||
<Route path="comments" element={<CommentsPage />} />
|
<LazyRoute>
|
||||||
<Route path="friend-links" element={<FriendLinksPage />} />
|
<DashboardPage />
|
||||||
<Route path="reviews" element={<ReviewsPage />} />
|
</LazyRoute>
|
||||||
<Route path="settings" element={<SiteSettingsPage />} />
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
BarChart3,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Image as ImageIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Link2,
|
Link2,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -25,6 +27,12 @@ const primaryNav = [
|
|||||||
description: '站点运营总览',
|
description: '站点运营总览',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/analytics',
|
||||||
|
label: '数据分析',
|
||||||
|
description: '搜索词与 AI 问答洞察',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/posts',
|
to: '/posts',
|
||||||
label: '文章',
|
label: '文章',
|
||||||
@@ -49,6 +57,12 @@ const primaryNav = [
|
|||||||
description: '评测内容库',
|
description: '评测内容库',
|
||||||
icon: BookOpenText,
|
icon: BookOpenText,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/media',
|
||||||
|
label: '媒体库',
|
||||||
|
description: '对象存储图片管理',
|
||||||
|
icon: ImageIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
label: '设置',
|
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 DOMPurify from 'dompurify'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { useMemo } from 'react'
|
import { useDeferredValue, useMemo } from 'react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -15,10 +15,11 @@ marked.setOptions({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
|
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
|
||||||
|
const deferredMarkdown = useDeferredValue(markdown)
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
const rendered = marked.parse(markdown || '暂无内容。')
|
const rendered = marked.parse(deferredMarkdown || '暂无内容。')
|
||||||
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
|
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
|
||||||
}, [markdown])
|
}, [deferredMarkdown])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>
|
<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 { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { Expand, Minimize2, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ type MarkdownWorkbenchProps = {
|
|||||||
originalValue: string
|
originalValue: string
|
||||||
diffValue?: string
|
diffValue?: string
|
||||||
path: string
|
path: string
|
||||||
|
workspaceHeightClassName?: string
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
mode: MarkdownWorkbenchMode
|
mode: MarkdownWorkbenchMode
|
||||||
visiblePanels: MarkdownWorkbenchPanel[]
|
visiblePanels: MarkdownWorkbenchPanel[]
|
||||||
@@ -114,6 +116,7 @@ export function MarkdownWorkbench({
|
|||||||
originalValue,
|
originalValue,
|
||||||
diffValue,
|
diffValue,
|
||||||
path,
|
path,
|
||||||
|
workspaceHeightClassName = 'h-[560px]',
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
mode,
|
mode,
|
||||||
visiblePanels,
|
visiblePanels,
|
||||||
@@ -128,7 +131,7 @@ export function MarkdownWorkbench({
|
|||||||
onVisiblePanelsChange,
|
onVisiblePanelsChange,
|
||||||
}: MarkdownWorkbenchProps) {
|
}: MarkdownWorkbenchProps) {
|
||||||
const [fullscreen, setFullscreen] = useState(false)
|
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 diffContent = diffValue ?? value
|
||||||
const polishEnabled = allowPolish ?? Boolean(polishPanel)
|
const polishEnabled = allowPolish ?? Boolean(polishPanel)
|
||||||
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||||
@@ -262,7 +265,7 @@ export function MarkdownWorkbench({
|
|||||||
|
|
||||||
{panel === 'edit' ? (
|
{panel === 'edit' ? (
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<Editor
|
<LazyEditor
|
||||||
height="100%"
|
height="100%"
|
||||||
language="markdown"
|
language="markdown"
|
||||||
path={path}
|
path={path}
|
||||||
@@ -286,7 +289,7 @@ export function MarkdownWorkbench({
|
|||||||
|
|
||||||
{panel === 'diff' ? (
|
{panel === 'diff' ? (
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<DiffEditor
|
<LazyDiffEditor
|
||||||
height="100%"
|
height="100%"
|
||||||
language="markdown"
|
language="markdown"
|
||||||
original={originalValue}
|
original={originalValue}
|
||||||
|
|||||||
@@ -1,18 +1,473 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import * as ReactDOM from 'react-dom'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
|
type NativeSelectProps = React.ComponentProps<'select'>
|
||||||
({ className, ...props }, ref) => (
|
|
||||||
<select
|
type SelectOption = {
|
||||||
ref={ref}
|
value: string
|
||||||
className={cn(
|
label: React.ReactNode
|
||||||
'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',
|
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,
|
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}
|
{...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'
|
Select.displayName = 'Select'
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ a {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
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 {
|
import type {
|
||||||
|
AdminAnalyticsResponse,
|
||||||
|
AdminAiImageProviderTestResponse,
|
||||||
AdminAiReindexResponse,
|
AdminAiReindexResponse,
|
||||||
AdminAiProviderTestResponse,
|
AdminAiProviderTestResponse,
|
||||||
|
AdminImageUploadResponse,
|
||||||
|
AdminMediaDeleteResponse,
|
||||||
|
AdminMediaListResponse,
|
||||||
|
AdminPostCoverImageRequest,
|
||||||
|
AdminPostCoverImageResponse,
|
||||||
AdminDashboardResponse,
|
AdminDashboardResponse,
|
||||||
AdminPostMetadataResponse,
|
AdminPostMetadataResponse,
|
||||||
AdminPostPolishResponse,
|
AdminPostPolishResponse,
|
||||||
|
AdminReviewPolishRequest,
|
||||||
|
AdminReviewPolishResponse,
|
||||||
|
AdminR2ConnectivityResponse,
|
||||||
AdminSessionResponse,
|
AdminSessionResponse,
|
||||||
AdminSiteSettingsResponse,
|
AdminSiteSettingsResponse,
|
||||||
CommentListQuery,
|
CommentListQuery,
|
||||||
@@ -117,6 +127,7 @@ export const adminApi = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||||
|
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
||||||
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||||
updateSiteSettings: (payload: SiteSettingsPayload) =>
|
updateSiteSettings: (payload: SiteSettingsPayload) =>
|
||||||
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
|
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
|
||||||
@@ -139,6 +150,48 @@ export const adminApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ provider }),
|
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) =>
|
generatePostMetadata: (markdown: string) =>
|
||||||
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -149,6 +202,32 @@ export const adminApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ markdown }),
|
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) =>
|
listPosts: (query?: PostListQuery) =>
|
||||||
request<PostRecord[]>(
|
request<PostRecord[]>(
|
||||||
appendQueryParams('/api/posts', {
|
appendQueryParams('/api/posts', {
|
||||||
|
|||||||
@@ -71,6 +71,58 @@ export interface AdminDashboardResponse {
|
|||||||
recent_reviews: DashboardReviewItem[]
|
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 {
|
export interface AdminSiteSettingsResponse {
|
||||||
id: number
|
id: number
|
||||||
site_name: string | null
|
site_name: string | null
|
||||||
@@ -96,6 +148,10 @@ export interface AdminSiteSettingsResponse {
|
|||||||
ai_api_base: string | null
|
ai_api_base: string | null
|
||||||
ai_api_key: string | null
|
ai_api_key: string | null
|
||||||
ai_chat_model: 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_providers: AiProviderConfig[]
|
||||||
ai_active_provider_id: string | null
|
ai_active_provider_id: string | null
|
||||||
ai_embedding_model: string | null
|
ai_embedding_model: string | null
|
||||||
@@ -105,6 +161,12 @@ export interface AdminSiteSettingsResponse {
|
|||||||
ai_last_indexed_at: string | null
|
ai_last_indexed_at: string | null
|
||||||
ai_chunks_count: number
|
ai_chunks_count: number
|
||||||
ai_local_embedding: string
|
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 {
|
export interface AiProviderConfig {
|
||||||
@@ -114,6 +176,7 @@ export interface AiProviderConfig {
|
|||||||
api_base: string | null
|
api_base: string | null
|
||||||
api_key: string | null
|
api_key: string | null
|
||||||
chat_model: string | null
|
chat_model: string | null
|
||||||
|
image_model: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteSettingsPayload {
|
export interface SiteSettingsPayload {
|
||||||
@@ -140,12 +203,22 @@ export interface SiteSettingsPayload {
|
|||||||
aiApiBase?: string | null
|
aiApiBase?: string | null
|
||||||
aiApiKey?: string | null
|
aiApiKey?: string | null
|
||||||
aiChatModel?: string | null
|
aiChatModel?: string | null
|
||||||
|
aiImageProvider?: string | null
|
||||||
|
aiImageApiBase?: string | null
|
||||||
|
aiImageApiKey?: string | null
|
||||||
|
aiImageModel?: string | null
|
||||||
aiProviders?: AiProviderConfig[]
|
aiProviders?: AiProviderConfig[]
|
||||||
aiActiveProviderId?: string | null
|
aiActiveProviderId?: string | null
|
||||||
aiEmbeddingModel?: string | null
|
aiEmbeddingModel?: string | null
|
||||||
aiSystemPrompt?: string | null
|
aiSystemPrompt?: string | null
|
||||||
aiTopK?: number | null
|
aiTopK?: number | null
|
||||||
aiChunkSize?: 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 {
|
export interface AdminAiReindexResponse {
|
||||||
@@ -160,6 +233,42 @@ export interface AdminAiProviderTestResponse {
|
|||||||
reply_preview: string
|
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 {
|
export interface MusicTrack {
|
||||||
title: string
|
title: string
|
||||||
artist?: string | null
|
artist?: string | null
|
||||||
@@ -182,6 +291,35 @@ export interface AdminPostPolishResponse {
|
|||||||
polished_markdown: string
|
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 {
|
export interface PostRecord {
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_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 { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
|
||||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
editorTheme,
|
editorTheme,
|
||||||
sharedOptions,
|
sharedOptions,
|
||||||
} from '@/components/markdown-workbench'
|
} from '@/components/markdown-workbench'
|
||||||
|
import { LazyDiffEditor } from '@/components/lazy-monaco'
|
||||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -191,7 +191,7 @@ export function PostPolishPage() {
|
|||||||
<span>当前合并结果</span>
|
<span>当前合并结果</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[560px]">
|
<div className="h-[560px]">
|
||||||
<DiffEditor
|
<LazyDiffEditor
|
||||||
height="100%"
|
height="100%"
|
||||||
language="markdown"
|
language="markdown"
|
||||||
original={originalMarkdown}
|
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 { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
@@ -32,6 +32,11 @@ type ReviewFormState = {
|
|||||||
linkUrl: string
|
linkUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReviewDescriptionPolishState = {
|
||||||
|
originalDescription: string
|
||||||
|
polishedDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
const defaultReviewForm: ReviewFormState = {
|
const defaultReviewForm: ReviewFormState = {
|
||||||
title: '',
|
title: '',
|
||||||
reviewType: 'book',
|
reviewType: 'book',
|
||||||
@@ -94,8 +99,14 @@ export function ReviewsPage() {
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deleting, setDeleting] = 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 [searchTerm, setSearchTerm] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('all')
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const loadReviews = useCallback(async (showToast = false) => {
|
const loadReviews = useCallback(async (showToast = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -153,6 +164,70 @@ export function ReviewsPage() {
|
|||||||
[reviews, selectedId],
|
[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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
@@ -172,6 +247,7 @@ export function ReviewsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
setForm(defaultReviewForm)
|
setForm(defaultReviewForm)
|
||||||
|
setDescriptionPolish(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建评测
|
新建评测
|
||||||
@@ -220,6 +296,7 @@ export function ReviewsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(review.id)
|
setSelectedId(review.id)
|
||||||
setForm(toFormState(review))
|
setForm(toFormState(review))
|
||||||
|
setDescriptionPolish(null)
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||||
selectedId === review.id
|
selectedId === review.id
|
||||||
@@ -295,6 +372,7 @@ export function ReviewsPage() {
|
|||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setSelectedId(updated.id)
|
setSelectedId(updated.id)
|
||||||
setForm(toFormState(updated))
|
setForm(toFormState(updated))
|
||||||
|
setDescriptionPolish(null)
|
||||||
})
|
})
|
||||||
toast.success('评测已更新。')
|
toast.success('评测已更新。')
|
||||||
} else {
|
} else {
|
||||||
@@ -302,6 +380,7 @@ export function ReviewsPage() {
|
|||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setSelectedId(created.id)
|
setSelectedId(created.id)
|
||||||
setForm(toFormState(created))
|
setForm(toFormState(created))
|
||||||
|
setDescriptionPolish(null)
|
||||||
})
|
})
|
||||||
toast.success('评测已创建。')
|
toast.success('评测已创建。')
|
||||||
}
|
}
|
||||||
@@ -332,6 +411,7 @@ export function ReviewsPage() {
|
|||||||
toast.success('评测已删除。')
|
toast.success('评测已删除。')
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
setForm(defaultReviewForm)
|
setForm(defaultReviewForm)
|
||||||
|
setDescriptionPolish(null)
|
||||||
await loadReviews(false)
|
await loadReviews(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
||||||
@@ -414,13 +494,49 @@ export function ReviewsPage() {
|
|||||||
<option value="archived">已归档</option>
|
<option value="archived">已归档</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="封面 URL">
|
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<Input
|
<Input
|
||||||
value={form.cover}
|
value={form.cover}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
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>
|
||||||
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
|
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
|
||||||
<Input
|
<Input
|
||||||
@@ -442,13 +558,113 @@ export function ReviewsPage() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<FormField label="简介">
|
<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
|
<Textarea
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setForm((current) => ({ ...current, description: event.target.value }))
|
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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select } from '@/components/ui/select'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { adminApi, ApiError } from '@/lib/api'
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
@@ -42,10 +43,39 @@ function createEmptyAiProvider(): AiProviderConfig {
|
|||||||
return {
|
return {
|
||||||
id: createAiProviderId(),
|
id: createAiProviderId(),
|
||||||
name: '',
|
name: '',
|
||||||
provider: 'newapi',
|
provider: 'openai',
|
||||||
api_base: '',
|
api_base: '',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
chat_model: '',
|
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,
|
aiApiBase: form.ai_api_base,
|
||||||
aiApiKey: form.ai_api_key,
|
aiApiKey: form.ai_api_key,
|
||||||
aiChatModel: form.ai_chat_model,
|
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,
|
aiProviders: form.ai_providers,
|
||||||
aiActiveProviderId: form.ai_active_provider_id,
|
aiActiveProviderId: form.ai_active_provider_id,
|
||||||
aiEmbeddingModel: form.ai_embedding_model,
|
aiEmbeddingModel: form.ai_embedding_model,
|
||||||
aiSystemPrompt: form.ai_system_prompt,
|
aiSystemPrompt: form.ai_system_prompt,
|
||||||
aiTopK: form.ai_top_k,
|
aiTopK: form.ai_top_k,
|
||||||
aiChunkSize: form.ai_chunk_size,
|
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 [saving, setSaving] = useState(false)
|
||||||
const [reindexing, setReindexing] = useState(false)
|
const [reindexing, setReindexing] = useState(false)
|
||||||
const [testingProvider, setTestingProvider] = useState(false)
|
const [testingProvider, setTestingProvider] = useState(false)
|
||||||
|
const [testingImageProvider, setTestingImageProvider] = useState(false)
|
||||||
|
const [testingR2Storage, setTestingR2Storage] = useState(false)
|
||||||
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
|
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
|
||||||
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
|
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
|
||||||
|
|
||||||
@@ -290,6 +332,38 @@ export function SiteSettingsPage() {
|
|||||||
updateField('ai_active_provider_id', providerId)
|
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(
|
const techStackValue = useMemo(
|
||||||
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
|
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
|
||||||
[form?.tech_stack],
|
[form?.tech_stack],
|
||||||
@@ -306,6 +380,18 @@ export function SiteSettingsPage() {
|
|||||||
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
|
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
|
||||||
[form],
|
[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) {
|
if (loading || !form) {
|
||||||
return (
|
return (
|
||||||
@@ -532,7 +618,7 @@ export function SiteSettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>AI 模块</CardTitle>
|
<CardTitle>AI 模块</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
站内 AI 问答功能使用的提供方与检索控制参数。
|
把文本问答和封面图生成拆成两套配置,检索参数仍在这里统一管理。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
@@ -551,13 +637,12 @@ export function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Field label="提供方">
|
|
||||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
|
<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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">提供商列表</p>
|
<p className="text-sm font-medium">文本问答 Provider</p>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
可以同时保存多套模型渠道配置,并指定当前实际生效的那一套。
|
这里用于站内问答、文章元数据生成和文案润色。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={addAiProvider}>
|
<Button type="button" variant="outline" onClick={addAiProvider}>
|
||||||
@@ -590,7 +675,7 @@ export function SiteSettingsPage() {
|
|||||||
{provider.name?.trim() || `提供商 ${index + 1}`}
|
{provider.name?.trim() || `提供商 ${index + 1}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
{provider.provider?.trim() || '未填写 provider'}
|
Provider:{provider.provider?.trim() || '未填写'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{active ? (
|
{active ? (
|
||||||
@@ -624,10 +709,18 @@ export function SiteSettingsPage() {
|
|||||||
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
|
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
保存后,系统会使用“当前启用”的提供商处理站内 AI 请求。
|
保存后,系统会使用“当前启用”的提供商处理文本 AI 请求。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => applyCloudflarePreset(selectedProviderIndex)}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
套用 Cloudflare
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -678,24 +771,39 @@ export function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Provider 标识">
|
<Field
|
||||||
<Input
|
label="Provider"
|
||||||
|
hint="选择文本模型提供方。Cloudflare 文本模型也支持。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
value={selectedProvider.provider ?? ''}
|
value={selectedProvider.provider ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
|
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder="newapi / openai-compatible / 其他兼容值"
|
>
|
||||||
/>
|
{AI_PROVIDER_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="API 地址">
|
<Field
|
||||||
|
label="API 地址"
|
||||||
|
hint={selectedProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID,或填写 https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>。' : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={selectedProvider.api_base ?? ''}
|
value={selectedProvider.api_base ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
|
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
|
||||||
}
|
}
|
||||||
|
placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="API 密钥">
|
<Field
|
||||||
|
label="API 密钥"
|
||||||
|
hint={selectedProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。该 Token 需要 Workers AI Read 和 Edit 权限。' : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={selectedProvider.api_key ?? ''}
|
value={selectedProvider.api_key ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -703,7 +811,10 @@ export function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="对话模型">
|
<Field
|
||||||
|
label="对话模型"
|
||||||
|
hint={selectedProviderIsCloudflare ? '例如 @cf/meta/llama-3.1-8b-instruct,用于问答与连通性测试。' : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={selectedProvider.chat_model ?? ''}
|
value={selectedProvider.chat_model ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -711,6 +822,15 @@ export function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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">
|
||||||
@@ -720,13 +840,125 @@ export function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
||||||
当前生效:
|
文本 AI 当前生效:
|
||||||
{activeProvider
|
{activeProvider
|
||||||
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
|
? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
|
||||||
: '未选择提供商'}
|
: '未选择提供商'}
|
||||||
</div>
|
</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
|
<Field
|
||||||
label="向量模型"
|
label="向量模型"
|
||||||
hint={`本地选项:${form.ai_local_embedding}`}
|
hint={`本地选项:${form.ai_local_embedding}`}
|
||||||
@@ -771,6 +1003,128 @@ export function SiteSettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>索引状态</CardTitle>
|
<CardTitle>索引状态</CardTitle>
|
||||||
|
|||||||
966
backend/Cargo.lock
generated
966
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -42,10 +42,12 @@ unic-langid = { version = "0.9" }
|
|||||||
# /view engine
|
# /view engine
|
||||||
axum-extra = { version = "0.10", features = ["form"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
|
||||||
fastembed = "5.1"
|
fastembed = "5.1"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
aws-config = "1"
|
||||||
|
aws-sdk-s3 = "1"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "termi_api-cli"
|
name = "termi_api-cli"
|
||||||
|
|||||||
@@ -83,8 +83,9 @@
|
|||||||
<div class="field-hint">关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
|
<div class="field-hint">关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>聊天 Provider</label>
|
<label>接入类型 / 协议</label>
|
||||||
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
||||||
|
<div class="field-hint">这里是后端适配器类型,不是模型厂商名。`newapi` 表示走 NewAPI 兼容的 Responses 接口;厂商和型号建议写在你的通道备注与模型名里。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>聊天 API Base</label>
|
<label>聊天 API Base</label>
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||||
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div>
|
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。前台用户提交过的搜索词和 AI 问题会单独写入分析日志,方便在新版后台里查看。</div>
|
||||||
<div id="notice" class="notice"></div>
|
<div id="notice" class="notice"></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: "Redis常用命令"
|
title: "Redis 安装与常用命令整理"
|
||||||
description:
|
|
||||||
date: 2022-04-21T09:42:24+08:00
|
|
||||||
draft: false
|
|
||||||
slug: redis
|
slug: redis
|
||||||
image:
|
description: "文章介绍了 Redis 在 Debian 下的安装方法、Windows 图形客户端的安装方式,以及监听端口修改、BitMap、消息队列、LREM 和 Pipeline 等常用操作示例。"
|
||||||
categories:
|
category: "数据库"
|
||||||
- Database
|
post_type: "article"
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
tags:
|
tags:
|
||||||
- Database
|
- "Redis安装"
|
||||||
- Redis
|
- "Debian"
|
||||||
|
- "BitMap"
|
||||||
|
- "消息队列"
|
||||||
|
- "Pipeline"
|
||||||
|
- "go-redis"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 安装`Redis`
|
# 安装`Redis`
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: "如何在 Tmux 会话窗格中发送命令"
|
title: "在 Tmux 会话窗格中发送命令的方法"
|
||||||
description: 本文介绍了在 Tmux 中发送命令的步骤,包括新建分离会话、发送命令至会话窗格、连接会话窗格、以及发送特殊命令。通过本文,读者将了解如何在 Tmux 中发送命令,并能够更加高效地使用 Tmux。
|
|
||||||
date: 2022-08-02T14:54:08+08:00
|
|
||||||
draft: false
|
|
||||||
slug: tmux
|
slug: tmux
|
||||||
image:
|
description: "介绍如何在 Tmux 中创建分离会话、向指定窗格发送命令并执行回车,同时说明连接会话和发送特殊按键的基本用法。"
|
||||||
categories:
|
category: "Linux"
|
||||||
- Linux
|
post_type: "article"
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
tags:
|
tags:
|
||||||
- Linux
|
- "Tmux"
|
||||||
- Tmux
|
- "终端复用"
|
||||||
|
- "send-keys"
|
||||||
|
- "会话管理"
|
||||||
|
- "命令行"
|
||||||
---
|
---
|
||||||
|
|
||||||
## 在 Tmux 会话窗格中发送命令的方法
|
## 在 Tmux 会话窗格中发送命令的方法
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ mod m20260328_000010_add_paragraph_comments_toggle_to_site_settings;
|
|||||||
mod m20260328_000011_add_post_images_and_music_playlist;
|
mod m20260328_000011_add_post_images_and_music_playlist;
|
||||||
mod m20260329_000012_add_link_url_to_reviews;
|
mod m20260329_000012_add_link_url_to_reviews;
|
||||||
mod m20260329_000013_add_ai_provider_presets_to_site_settings;
|
mod m20260329_000013_add_ai_provider_presets_to_site_settings;
|
||||||
|
mod m20260329_000014_create_query_events;
|
||||||
|
mod m20260330_000015_add_image_ai_settings_to_site_settings;
|
||||||
|
mod m20260330_000016_add_r2_media_settings_to_site_settings;
|
||||||
|
mod m20260330_000017_add_media_storage_provider_to_site_settings;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -46,6 +50,10 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration),
|
Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration),
|
||||||
Box::new(m20260329_000012_add_link_url_to_reviews::Migration),
|
Box::new(m20260329_000012_add_link_url_to_reviews::Migration),
|
||||||
Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration),
|
Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration),
|
||||||
|
Box::new(m20260329_000014_create_query_events::Migration),
|
||||||
|
Box::new(m20260330_000015_add_image_ai_settings_to_site_settings::Migration),
|
||||||
|
Box::new(m20260330_000016_add_r2_media_settings_to_site_settings::Migration),
|
||||||
|
Box::new(m20260330_000017_add_media_storage_provider_to_site_settings::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
manager,
|
||||||
|
"query_events",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("event_type", ColType::String),
|
||||||
|
("query_text", ColType::Text),
|
||||||
|
("normalized_query", ColType::Text),
|
||||||
|
("request_path", ColType::StringNull),
|
||||||
|
("referrer", ColType::StringNull),
|
||||||
|
("user_agent", ColType::TextNull),
|
||||||
|
("result_count", ColType::IntegerNull),
|
||||||
|
("success", ColType::BooleanNull),
|
||||||
|
("response_mode", ColType::StringNull),
|
||||||
|
("provider", ColType::StringNull),
|
||||||
|
("chat_model", ColType::StringNull),
|
||||||
|
("latency_ms", ColType::IntegerNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_query_events_event_type_created_at")
|
||||||
|
.table(Alias::new("query_events"))
|
||||||
|
.col(Alias::new("event_type"))
|
||||||
|
.col(Alias::new("created_at"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_query_events_normalized_query")
|
||||||
|
.table(Alias::new("query_events"))
|
||||||
|
.col(Alias::new("normalized_query"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
for index_name in [
|
||||||
|
"idx_query_events_normalized_query",
|
||||||
|
"idx_query_events_event_type_created_at",
|
||||||
|
] {
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name(index_name)
|
||||||
|
.table(Alias::new("query_events"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
drop_table(manager, "query_events").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_image_provider")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("ai_image_provider"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_image_api_base")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("ai_image_api_base"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_image_api_key")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_image_api_key")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_image_model")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_image_model")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"ai_image_model",
|
||||||
|
"ai_image_api_key",
|
||||||
|
"ai_image_api_base",
|
||||||
|
"ai_image_provider",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_r2_account_id")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("media_r2_account_id"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_r2_bucket")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("media_r2_bucket"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_r2_public_base_url")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("media_r2_public_base_url"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_r2_access_key_id")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("media_r2_access_key_id"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_r2_secret_access_key")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("media_r2_secret_access_key"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"media_r2_secret_access_key",
|
||||||
|
"media_r2_access_key_id",
|
||||||
|
"media_r2_public_base_url",
|
||||||
|
"media_r2_bucket",
|
||||||
|
"media_r2_account_id",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if manager
|
||||||
|
.has_column("site_settings", "media_storage_provider")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SiteSettings::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(SiteSettings::MediaStorageProvider)
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "media_storage_provider")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(SiteSettings::Table)
|
||||||
|
.drop_column(SiteSettings::MediaStorageProvider)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum SiteSettings {
|
||||||
|
Table,
|
||||||
|
MediaStorageProvider,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use axum::extract::{Multipart, Query};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||||
@@ -14,7 +15,7 @@ use crate::{
|
|||||||
site_settings::{self, SiteSettingsPayload},
|
site_settings::{self, SiteSettingsPayload},
|
||||||
},
|
},
|
||||||
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
|
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
|
||||||
services::{ai, content},
|
services::{ai, analytics, content, storage},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -130,6 +131,10 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub ai_api_base: Option<String>,
|
pub ai_api_base: Option<String>,
|
||||||
pub ai_api_key: Option<String>,
|
pub ai_api_key: Option<String>,
|
||||||
pub ai_chat_model: Option<String>,
|
pub ai_chat_model: Option<String>,
|
||||||
|
pub ai_image_provider: Option<String>,
|
||||||
|
pub ai_image_api_base: Option<String>,
|
||||||
|
pub ai_image_api_key: Option<String>,
|
||||||
|
pub ai_image_model: Option<String>,
|
||||||
pub ai_providers: Vec<site_settings::AiProviderConfig>,
|
pub ai_providers: Vec<site_settings::AiProviderConfig>,
|
||||||
pub ai_active_provider_id: Option<String>,
|
pub ai_active_provider_id: Option<String>,
|
||||||
pub ai_embedding_model: Option<String>,
|
pub ai_embedding_model: Option<String>,
|
||||||
@@ -139,6 +144,12 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub ai_last_indexed_at: Option<String>,
|
pub ai_last_indexed_at: Option<String>,
|
||||||
pub ai_chunks_count: u64,
|
pub ai_chunks_count: u64,
|
||||||
pub ai_local_embedding: String,
|
pub ai_local_embedding: String,
|
||||||
|
pub media_storage_provider: Option<String>,
|
||||||
|
pub media_r2_account_id: Option<String>,
|
||||||
|
pub media_r2_bucket: Option<String>,
|
||||||
|
pub media_r2_public_base_url: Option<String>,
|
||||||
|
pub media_r2_access_key_id: Option<String>,
|
||||||
|
pub media_r2_secret_access_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -160,6 +171,67 @@ pub struct AdminAiProviderTestResponse {
|
|||||||
pub reply_preview: String,
|
pub reply_preview: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminAiImageProviderTestRequest {
|
||||||
|
pub provider: String,
|
||||||
|
pub api_base: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub image_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminAiImageProviderTestResponse {
|
||||||
|
pub provider: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub image_model: String,
|
||||||
|
pub result_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminImageUploadResponse {
|
||||||
|
pub url: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminR2ConnectivityResponse {
|
||||||
|
pub bucket: String,
|
||||||
|
pub public_base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminMediaObjectResponse {
|
||||||
|
pub key: String,
|
||||||
|
pub url: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminMediaListResponse {
|
||||||
|
pub provider: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub public_base_url: String,
|
||||||
|
pub items: Vec<AdminMediaObjectResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminMediaDeleteResponse {
|
||||||
|
pub deleted: bool,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminMediaListQuery {
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminMediaDeleteQuery {
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AdminPostMetadataRequest {
|
pub struct AdminPostMetadataRequest {
|
||||||
pub markdown: String,
|
pub markdown: String,
|
||||||
@@ -170,6 +242,30 @@ pub struct AdminPostPolishRequest {
|
|||||||
pub markdown: String,
|
pub markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminReviewPolishRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub review_type: String,
|
||||||
|
pub rating: i32,
|
||||||
|
pub review_date: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminPostCoverImageRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub post_type: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn format_timestamp(
|
fn format_timestamp(
|
||||||
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
|
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
|
||||||
pattern: &str,
|
pattern: &str,
|
||||||
@@ -242,6 +338,10 @@ fn build_settings_response(
|
|||||||
ai_api_base: item.ai_api_base,
|
ai_api_base: item.ai_api_base,
|
||||||
ai_api_key: item.ai_api_key,
|
ai_api_key: item.ai_api_key,
|
||||||
ai_chat_model: item.ai_chat_model,
|
ai_chat_model: item.ai_chat_model,
|
||||||
|
ai_image_provider: item.ai_image_provider,
|
||||||
|
ai_image_api_base: item.ai_image_api_base,
|
||||||
|
ai_image_api_key: item.ai_image_api_key,
|
||||||
|
ai_image_model: item.ai_image_model,
|
||||||
ai_providers,
|
ai_providers,
|
||||||
ai_active_provider_id,
|
ai_active_provider_id,
|
||||||
ai_embedding_model: item.ai_embedding_model,
|
ai_embedding_model: item.ai_embedding_model,
|
||||||
@@ -251,6 +351,12 @@ fn build_settings_response(
|
|||||||
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
|
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
|
||||||
ai_chunks_count,
|
ai_chunks_count,
|
||||||
ai_local_embedding: ai::local_embedding_label().to_string(),
|
ai_local_embedding: ai::local_embedding_label().to_string(),
|
||||||
|
media_storage_provider: item.media_storage_provider,
|
||||||
|
media_r2_account_id: item.media_r2_account_id,
|
||||||
|
media_r2_bucket: item.media_r2_bucket,
|
||||||
|
media_r2_public_base_url: item.media_r2_public_base_url,
|
||||||
|
media_r2_access_key_id: item.media_r2_access_key_id,
|
||||||
|
media_r2_secret_access_key: item.media_r2_secret_access_key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +516,12 @@ pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn analytics_overview(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
format::json(analytics::build_admin_analytics(&ctx).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
check_auth()?;
|
check_auth()?;
|
||||||
@@ -428,7 +540,7 @@ pub async fn update_site_settings(
|
|||||||
let current = site_settings::load_current(&ctx).await?;
|
let current = site_settings::load_current(&ctx).await?;
|
||||||
let mut item = current;
|
let mut item = current;
|
||||||
params.apply(&mut item);
|
params.apply(&mut item);
|
||||||
let item = item.into_active_model();
|
let item = item.into_active_model().reset_all();
|
||||||
let updated = item.update(&ctx.db).await?;
|
let updated = item.update(&ctx.db).await?;
|
||||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||||
|
|
||||||
@@ -469,6 +581,88 @@ pub async fn test_ai_provider(Json(payload): Json<AdminAiProviderTestRequest>) -
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn test_ai_image_provider(
|
||||||
|
Json(payload): Json<AdminAiImageProviderTestRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
|
let result = ai::test_image_provider_connectivity(
|
||||||
|
&payload.provider,
|
||||||
|
&payload.api_base,
|
||||||
|
&payload.api_key,
|
||||||
|
&payload.image_model,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(AdminAiImageProviderTestResponse {
|
||||||
|
provider: result.provider,
|
||||||
|
endpoint: result.endpoint,
|
||||||
|
image_model: result.image_model,
|
||||||
|
result_preview: result.result_preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn test_r2_storage(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
|
let settings = storage::require_r2_settings(&ctx).await?;
|
||||||
|
let bucket = storage::test_r2_connectivity(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(AdminR2ConnectivityResponse {
|
||||||
|
bucket,
|
||||||
|
public_base_url: settings.public_base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list_media_objects(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Query(query): Query<AdminMediaListQuery>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
|
let settings = storage::require_r2_settings(&ctx).await?;
|
||||||
|
let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| AdminMediaObjectResponse {
|
||||||
|
key: item.key,
|
||||||
|
url: item.url,
|
||||||
|
size_bytes: item.size_bytes,
|
||||||
|
last_modified: item.last_modified,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
format::json(AdminMediaListResponse {
|
||||||
|
provider: settings.provider_name,
|
||||||
|
bucket: settings.bucket,
|
||||||
|
public_base_url: settings.public_base_url,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn delete_media_object(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Query(query): Query<AdminMediaDeleteQuery>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
|
let key = query.key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(Error::BadRequest("缺少对象 key".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
storage::delete_object(&ctx, key).await?;
|
||||||
|
|
||||||
|
format::json(AdminMediaDeleteResponse {
|
||||||
|
deleted: true,
|
||||||
|
key: key.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn generate_post_metadata(
|
pub async fn generate_post_metadata(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
@@ -487,6 +681,127 @@ pub async fn polish_post_markdown(
|
|||||||
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
|
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn polish_review_description(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<AdminReviewPolishRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
format::json(
|
||||||
|
ai::polish_review_description(
|
||||||
|
&ctx,
|
||||||
|
&payload.title,
|
||||||
|
&payload.review_type,
|
||||||
|
payload.rating,
|
||||||
|
payload.review_date.as_deref(),
|
||||||
|
&payload.status,
|
||||||
|
&payload.tags,
|
||||||
|
&payload.description,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn generate_post_cover_image(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<AdminPostCoverImageRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
format::json(
|
||||||
|
ai::generate_post_cover_image(
|
||||||
|
&ctx,
|
||||||
|
&payload.title,
|
||||||
|
payload.description.as_deref(),
|
||||||
|
payload.category.as_deref(),
|
||||||
|
&payload.tags,
|
||||||
|
&payload.post_type,
|
||||||
|
payload.slug.as_deref(),
|
||||||
|
&payload.markdown,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_cover_extension(
|
||||||
|
file_name: Option<&str>,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
let from_file_name = file_name
|
||||||
|
.and_then(|name| name.rsplit('.').next())
|
||||||
|
.map(|ext| ext.trim().to_ascii_lowercase());
|
||||||
|
|
||||||
|
match from_file_name.as_deref() {
|
||||||
|
Some("png") => return Some("png"),
|
||||||
|
Some("jpg") | Some("jpeg") => return Some("jpg"),
|
||||||
|
Some("webp") => return Some("webp"),
|
||||||
|
Some("gif") => return Some("gif"),
|
||||||
|
Some("avif") => return Some("avif"),
|
||||||
|
Some("svg") => return Some("svg"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match content_type
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"image/png" => Some("png"),
|
||||||
|
"image/jpeg" => Some("jpg"),
|
||||||
|
"image/webp" => Some("webp"),
|
||||||
|
"image/gif" => Some("gif"),
|
||||||
|
"image/avif" => Some("avif"),
|
||||||
|
"image/svg+xml" => Some("svg"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn upload_review_cover_image(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
|
let field = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
|
||||||
|
let file_name = field.file_name().map(ToString::to_string);
|
||||||
|
let content_type = field.content_type().map(ToString::to_string);
|
||||||
|
let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
|
||||||
|
.ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(error.to_string()))?;
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(Error::BadRequest("上传的图片内容为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = crate::services::storage::build_object_key(
|
||||||
|
"review-covers",
|
||||||
|
file_name.as_deref().unwrap_or("review-cover"),
|
||||||
|
extension,
|
||||||
|
);
|
||||||
|
let stored = crate::services::storage::upload_bytes_to_r2(
|
||||||
|
&ctx,
|
||||||
|
&key,
|
||||||
|
bytes.to_vec(),
|
||||||
|
content_type.as_deref(),
|
||||||
|
Some("public, max-age=31536000, immutable"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(AdminImageUploadResponse {
|
||||||
|
url: stored.url,
|
||||||
|
key: stored.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("/api/admin")
|
.prefix("/api/admin")
|
||||||
@@ -494,11 +809,21 @@ pub fn routes() -> Routes {
|
|||||||
.add("/session", delete(session_logout))
|
.add("/session", delete(session_logout))
|
||||||
.add("/session/login", post(session_login))
|
.add("/session/login", post(session_login))
|
||||||
.add("/dashboard", get(dashboard))
|
.add("/dashboard", get(dashboard))
|
||||||
|
.add("/analytics", get(analytics_overview))
|
||||||
.add("/site-settings", get(get_site_settings))
|
.add("/site-settings", get(get_site_settings))
|
||||||
.add("/site-settings", patch(update_site_settings))
|
.add("/site-settings", patch(update_site_settings))
|
||||||
.add("/site-settings", put(update_site_settings))
|
.add("/site-settings", put(update_site_settings))
|
||||||
.add("/ai/reindex", post(reindex_ai))
|
.add("/ai/reindex", post(reindex_ai))
|
||||||
.add("/ai/test-provider", post(test_ai_provider))
|
.add("/ai/test-provider", post(test_ai_provider))
|
||||||
|
.add("/ai/test-image-provider", post(test_ai_image_provider))
|
||||||
|
.add("/storage/r2/test", post(test_r2_storage))
|
||||||
|
.add(
|
||||||
|
"/storage/media",
|
||||||
|
get(list_media_objects).delete(delete_media_object),
|
||||||
|
)
|
||||||
.add("/ai/post-metadata", post(generate_post_metadata))
|
.add("/ai/post-metadata", post(generate_post_metadata))
|
||||||
.add("/ai/polish-post", post(polish_post_markdown))
|
.add("/ai/polish-post", post(polish_post_markdown))
|
||||||
|
.add("/ai/polish-review", post(polish_review_description))
|
||||||
|
.add("/ai/post-cover", post(generate_post_cover_image))
|
||||||
|
.add("/storage/review-cover", post(upload_review_cover_image))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ use axum::{
|
|||||||
body::{Body, Bytes},
|
body::{Body, Bytes},
|
||||||
http::{
|
http::{
|
||||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||||
HeaderValue,
|
HeaderMap, HeaderValue,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::{controllers::admin::check_auth, services::ai};
|
use crate::{
|
||||||
|
controllers::{admin::check_auth, site_settings},
|
||||||
|
services::{ai, analytics},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AskPayload {
|
pub struct AskPayload {
|
||||||
@@ -55,6 +59,30 @@ fn format_timestamp(value: Option<DateTime<Utc>>) -> Option<String> {
|
|||||||
value.map(|item| item.to_rfc3339())
|
value.map(|item| item.to_rfc3339())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_provider_metadata(ctx: &AppContext) -> (Option<String>, Option<String>) {
|
||||||
|
match site_settings::load_current(ctx).await {
|
||||||
|
Ok(settings) => (
|
||||||
|
trim_to_option(settings.ai_provider),
|
||||||
|
trim_to_option(settings.ai_chat_model),
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("failed to load ai provider metadata for analytics: {error}");
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes {
|
fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes {
|
||||||
let data = serde_json::to_string(payload)
|
let data = serde_json::to_string(payload)
|
||||||
.unwrap_or_else(|_| "{\"message\":\"failed to serialize SSE payload\"}".to_string());
|
.unwrap_or_else(|_| "{\"message\":\"failed to serialize SSE payload\"}".to_string());
|
||||||
@@ -178,24 +206,66 @@ fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskRes
|
|||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn ask(
|
pub async fn ask(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<AskPayload>,
|
Json(payload): Json<AskPayload>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let result = ai::answer_question(&ctx, &payload.question).await?;
|
let started_at = Instant::now();
|
||||||
|
let question = payload.question.trim().to_string();
|
||||||
|
let (provider, chat_model) = current_provider_metadata(&ctx).await;
|
||||||
|
|
||||||
|
match ai::answer_question(&ctx, &payload.question).await {
|
||||||
|
Ok(result) => {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&headers,
|
||||||
|
true,
|
||||||
|
"sync",
|
||||||
|
provider,
|
||||||
|
chat_model,
|
||||||
|
Some(result.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
format::json(AskResponse {
|
format::json(AskResponse {
|
||||||
question: payload.question.trim().to_string(),
|
question,
|
||||||
answer: result.answer,
|
answer: result.answer,
|
||||||
sources: result.sources,
|
sources: result.sources,
|
||||||
indexed_chunks: result.indexed_chunks,
|
indexed_chunks: result.indexed_chunks,
|
||||||
last_indexed_at: format_timestamp(result.last_indexed_at),
|
last_indexed_at: format_timestamp(result.last_indexed_at),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&headers,
|
||||||
|
false,
|
||||||
|
"sync",
|
||||||
|
provider,
|
||||||
|
chat_model,
|
||||||
|
None,
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn ask_stream(
|
pub async fn ask_stream(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(payload): Json<AskPayload>,
|
Json(payload): Json<AskPayload>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
|
let request_headers = headers.clone();
|
||||||
|
let question = payload.question.trim().to_string();
|
||||||
|
let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await;
|
||||||
|
|
||||||
let stream = stream! {
|
let stream = stream! {
|
||||||
|
let started_at = Instant::now();
|
||||||
yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent {
|
yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent {
|
||||||
phase: "retrieving".to_string(),
|
phase: "retrieving".to_string(),
|
||||||
message: "正在检索知识库上下文...".to_string(),
|
message: "正在检索知识库上下文...".to_string(),
|
||||||
@@ -204,6 +274,18 @@ pub async fn ask_stream(
|
|||||||
let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
|
let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
|
||||||
Ok(prepared) => prepared,
|
Ok(prepared) => prepared,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
false,
|
||||||
|
"stream",
|
||||||
|
fallback_provider.clone(),
|
||||||
|
fallback_chat_model.clone(),
|
||||||
|
None,
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
}));
|
}));
|
||||||
@@ -212,6 +294,16 @@ pub async fn ask_stream(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut accumulated_answer = String::new();
|
let mut accumulated_answer = String::new();
|
||||||
|
let active_provider = prepared
|
||||||
|
.provider_request
|
||||||
|
.as_ref()
|
||||||
|
.map(|request| request.provider.clone())
|
||||||
|
.or_else(|| fallback_provider.clone());
|
||||||
|
let active_chat_model = prepared
|
||||||
|
.provider_request
|
||||||
|
.as_ref()
|
||||||
|
.map(|request| request.chat_model.clone())
|
||||||
|
.or_else(|| fallback_chat_model.clone());
|
||||||
|
|
||||||
if let Some(answer) = prepared.immediate_answer.as_deref() {
|
if let Some(answer) = prepared.immediate_answer.as_deref() {
|
||||||
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
||||||
@@ -241,6 +333,18 @@ pub async fn ask_stream(
|
|||||||
let mut response = match response {
|
let mut response = match response {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
false,
|
||||||
|
"stream",
|
||||||
|
active_provider.clone(),
|
||||||
|
active_chat_model.clone(),
|
||||||
|
Some(prepared.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
message: format!("AI request failed: {error}"),
|
message: format!("AI request failed: {error}"),
|
||||||
}));
|
}));
|
||||||
@@ -251,6 +355,18 @@ pub async fn ask_stream(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
false,
|
||||||
|
"stream",
|
||||||
|
active_provider.clone(),
|
||||||
|
active_chat_model.clone(),
|
||||||
|
Some(prepared.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
message: format!("AI provider returned {status}: {body}"),
|
message: format!("AI provider returned {status}: {body}"),
|
||||||
}));
|
}));
|
||||||
@@ -265,6 +381,18 @@ pub async fn ask_stream(
|
|||||||
let Some(chunk) = (match next_chunk {
|
let Some(chunk) = (match next_chunk {
|
||||||
Ok(chunk) => chunk,
|
Ok(chunk) => chunk,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
false,
|
||||||
|
"stream",
|
||||||
|
active_provider.clone(),
|
||||||
|
active_chat_model.clone(),
|
||||||
|
Some(prepared.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
message: format!("AI stream read failed: {error}"),
|
message: format!("AI stream read failed: {error}"),
|
||||||
}));
|
}));
|
||||||
@@ -323,6 +451,18 @@ pub async fn ask_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if accumulated_answer.is_empty() {
|
if accumulated_answer.is_empty() {
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
false,
|
||||||
|
"stream",
|
||||||
|
active_provider.clone(),
|
||||||
|
active_chat_model.clone(),
|
||||||
|
Some(prepared.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
message: "AI chat response did not contain readable content".to_string(),
|
message: "AI chat response did not contain readable content".to_string(),
|
||||||
}));
|
}));
|
||||||
@@ -330,6 +470,19 @@ pub async fn ask_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
analytics::record_ai_question_event(
|
||||||
|
&ctx,
|
||||||
|
&question,
|
||||||
|
&request_headers,
|
||||||
|
true,
|
||||||
|
"stream",
|
||||||
|
active_provider,
|
||||||
|
active_chat_model,
|
||||||
|
Some(prepared.sources.len()),
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let final_payload = build_ask_response(&prepared, accumulated_answer);
|
let final_payload = build_ask_response(&prepared, accumulated_answer);
|
||||||
yield Ok(sse_bytes("complete", &final_payload));
|
yield Ok(sse_bytes("complete", &final_payload));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use loco_rs::prelude::*;
|
|||||||
use sea_orm::{EntityTrait, QueryOrder, Set};
|
use sea_orm::{EntityTrait, QueryOrder, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::_entities::reviews::{self, Entity as ReviewEntity};
|
use crate::{
|
||||||
|
models::_entities::reviews::{self, Entity as ReviewEntity},
|
||||||
|
services::storage,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct CreateReviewRequest {
|
pub struct CreateReviewRequest {
|
||||||
@@ -83,9 +86,11 @@ pub async fn update(
|
|||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
|
||||||
let Some(mut review) = review.map(|r| r.into_active_model()) else {
|
let Some(existing_review) = review else {
|
||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
};
|
};
|
||||||
|
let old_cover = existing_review.cover.clone();
|
||||||
|
let mut review = existing_review.into_active_model();
|
||||||
|
|
||||||
if let Some(title) = req.title {
|
if let Some(title) = req.title {
|
||||||
review.title = Set(Some(title));
|
review.title = Set(Some(title));
|
||||||
@@ -108,7 +113,9 @@ pub async fn update(
|
|||||||
if let Some(tags) = req.tags {
|
if let Some(tags) = req.tags {
|
||||||
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
|
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
|
||||||
}
|
}
|
||||||
|
let mut next_cover = old_cover.clone();
|
||||||
if let Some(cover) = req.cover {
|
if let Some(cover) = req.cover {
|
||||||
|
next_cover = Some(cover.clone());
|
||||||
review.cover = Set(Some(cover));
|
review.cover = Set(Some(cover));
|
||||||
}
|
}
|
||||||
if let Some(link_url) = req.link_url {
|
if let Some(link_url) = req.link_url {
|
||||||
@@ -117,6 +124,14 @@ pub async fn update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let review = review.update(&ctx.db).await?;
|
let review = review.update(&ctx.db).await?;
|
||||||
|
if let Some(old_cover) = old_cover
|
||||||
|
.filter(|old| Some(old.clone()) != next_cover)
|
||||||
|
.filter(|old| !old.trim().is_empty())
|
||||||
|
{
|
||||||
|
if let Err(error) = storage::delete_managed_url(&ctx, &old_cover).await {
|
||||||
|
tracing::warn!("failed to cleanup replaced review cover: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
format::json(review)
|
format::json(review)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +143,13 @@ pub async fn remove(
|
|||||||
|
|
||||||
match review {
|
match review {
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
|
let cover = r.cover.clone();
|
||||||
r.delete(&ctx.db).await?;
|
r.delete(&ctx.db).await?;
|
||||||
|
if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) {
|
||||||
|
if let Err(error) = storage::delete_managed_url(&ctx, &cover).await {
|
||||||
|
tracing::warn!("failed to cleanup deleted review cover: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
format::empty()
|
format::empty()
|
||||||
}
|
}
|
||||||
None => Err(Error::NotFound),
|
None => Err(Error::NotFound),
|
||||||
|
|||||||
@@ -1,15 +1,46 @@
|
|||||||
|
use axum::http::HeaderMap;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
|
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::models::_entities::posts;
|
use crate::models::_entities::posts;
|
||||||
use crate::services::content;
|
use crate::services::{analytics, content};
|
||||||
|
|
||||||
|
fn deserialize_boolish_option<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Option<bool>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<String>::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
raw.map(|value| match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"1" | "true" | "yes" | "on" => Ok(true),
|
||||||
|
"0" | "false" | "no" | "off" => Ok(false),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid boolean value `{other}`"
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
||||||
|
query.preview.unwrap_or(false)
|
||||||
|
|| headers
|
||||||
|
.get("x-termi-search-mode")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(|value| value.eq_ignore_ascii_case("preview"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
pub struct SearchQuery {
|
pub struct SearchQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
pub limit: Option<u64>,
|
pub limit: Option<u64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||||
|
pub preview: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, FromQueryResult)]
|
#[derive(Clone, Debug, Serialize, FromQueryResult)]
|
||||||
@@ -157,7 +188,10 @@ async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<Se
|
|||||||
pub async fn search(
|
pub async fn search(
|
||||||
Query(query): Query<SearchQuery>,
|
Query(query): Query<SearchQuery>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let preview_search = is_preview_search(&query, &headers);
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
let q = query.q.unwrap_or_default().trim().to_string();
|
let q = query.q.unwrap_or_default().trim().to_string();
|
||||||
@@ -186,6 +220,17 @@ pub async fn search(
|
|||||||
fallback_search(&ctx, &q, limit).await?
|
fallback_search(&ctx, &q, limit).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !preview_search {
|
||||||
|
analytics::record_search_event(
|
||||||
|
&ctx,
|
||||||
|
&q,
|
||||||
|
results.len(),
|
||||||
|
&headers,
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
format::json(results)
|
format::json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ pub struct AiProviderConfig {
|
|||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
#[serde(default, alias = "chatModel")]
|
#[serde(default, alias = "chatModel")]
|
||||||
pub chat_model: Option<String>,
|
pub chat_model: Option<String>,
|
||||||
|
#[serde(default, alias = "imageModel")]
|
||||||
|
pub image_model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
@@ -94,6 +96,14 @@ pub struct SiteSettingsPayload {
|
|||||||
pub ai_api_key: Option<String>,
|
pub ai_api_key: Option<String>,
|
||||||
#[serde(default, alias = "aiChatModel")]
|
#[serde(default, alias = "aiChatModel")]
|
||||||
pub ai_chat_model: Option<String>,
|
pub ai_chat_model: Option<String>,
|
||||||
|
#[serde(default, alias = "aiImageProvider")]
|
||||||
|
pub ai_image_provider: Option<String>,
|
||||||
|
#[serde(default, alias = "aiImageApiBase")]
|
||||||
|
pub ai_image_api_base: Option<String>,
|
||||||
|
#[serde(default, alias = "aiImageApiKey")]
|
||||||
|
pub ai_image_api_key: Option<String>,
|
||||||
|
#[serde(default, alias = "aiImageModel")]
|
||||||
|
pub ai_image_model: Option<String>,
|
||||||
#[serde(default, alias = "aiProviders")]
|
#[serde(default, alias = "aiProviders")]
|
||||||
pub ai_providers: Option<Vec<AiProviderConfig>>,
|
pub ai_providers: Option<Vec<AiProviderConfig>>,
|
||||||
#[serde(default, alias = "aiActiveProviderId")]
|
#[serde(default, alias = "aiActiveProviderId")]
|
||||||
@@ -106,6 +116,18 @@ pub struct SiteSettingsPayload {
|
|||||||
pub ai_top_k: Option<i32>,
|
pub ai_top_k: Option<i32>,
|
||||||
#[serde(default, alias = "aiChunkSize")]
|
#[serde(default, alias = "aiChunkSize")]
|
||||||
pub ai_chunk_size: Option<i32>,
|
pub ai_chunk_size: Option<i32>,
|
||||||
|
#[serde(default, alias = "mediaR2AccountId")]
|
||||||
|
pub media_r2_account_id: Option<String>,
|
||||||
|
#[serde(default, alias = "mediaStorageProvider")]
|
||||||
|
pub media_storage_provider: Option<String>,
|
||||||
|
#[serde(default, alias = "mediaR2Bucket")]
|
||||||
|
pub media_r2_bucket: Option<String>,
|
||||||
|
#[serde(default, alias = "mediaR2PublicBaseUrl")]
|
||||||
|
pub media_r2_public_base_url: Option<String>,
|
||||||
|
#[serde(default, alias = "mediaR2AccessKeyId")]
|
||||||
|
pub media_r2_access_key_id: Option<String>,
|
||||||
|
#[serde(default, alias = "mediaR2SecretAccessKey")]
|
||||||
|
pub media_r2_secret_access_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -152,13 +174,16 @@ fn create_ai_provider_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_ai_provider_config() -> AiProviderConfig {
|
fn default_ai_provider_config() -> AiProviderConfig {
|
||||||
|
let provider = ai::provider_name(None);
|
||||||
|
|
||||||
AiProviderConfig {
|
AiProviderConfig {
|
||||||
id: "default".to_string(),
|
id: "default".to_string(),
|
||||||
name: "默认提供商".to_string(),
|
name: "默认提供商".to_string(),
|
||||||
provider: ai::provider_name(None),
|
provider: provider.clone(),
|
||||||
api_base: Some(ai::default_api_base().to_string()),
|
api_base: Some(ai::default_api_base().to_string()),
|
||||||
api_key: Some(ai::default_api_key().to_string()),
|
api_key: Some(ai::default_api_key().to_string()),
|
||||||
chat_model: Some(ai::default_chat_model().to_string()),
|
chat_model: Some(ai::default_chat_model().to_string()),
|
||||||
|
image_model: Some(ai::default_image_model_for_provider(&provider).to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,11 +199,13 @@ fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProvider
|
|||||||
let api_base = normalize_optional_string(item.api_base);
|
let api_base = normalize_optional_string(item.api_base);
|
||||||
let api_key = normalize_optional_string(item.api_key);
|
let api_key = normalize_optional_string(item.api_key);
|
||||||
let chat_model = normalize_optional_string(item.chat_model);
|
let chat_model = normalize_optional_string(item.chat_model);
|
||||||
|
let image_model = normalize_optional_string(item.image_model);
|
||||||
let has_content = !item.name.trim().is_empty()
|
let has_content = !item.name.trim().is_empty()
|
||||||
|| !provider.trim().is_empty()
|
|| !provider.trim().is_empty()
|
||||||
|| api_base.is_some()
|
|| api_base.is_some()
|
||||||
|| api_key.is_some()
|
|| api_key.is_some()
|
||||||
|| chat_model.is_some();
|
|| chat_model.is_some()
|
||||||
|
|| image_model.is_some();
|
||||||
|
|
||||||
if !has_content {
|
if !has_content {
|
||||||
return None;
|
return None;
|
||||||
@@ -201,6 +228,7 @@ fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProvider
|
|||||||
api_base,
|
api_base,
|
||||||
api_key,
|
api_key,
|
||||||
chat_model,
|
chat_model,
|
||||||
|
image_model,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -216,13 +244,16 @@ fn legacy_ai_provider_config(model: &Model) -> Option<AiProviderConfig> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalized_provider = provider.unwrap_or_else(|| ai::provider_name(None));
|
||||||
|
|
||||||
Some(AiProviderConfig {
|
Some(AiProviderConfig {
|
||||||
id: "default".to_string(),
|
id: "default".to_string(),
|
||||||
name: "当前提供商".to_string(),
|
name: "当前提供商".to_string(),
|
||||||
provider: provider.unwrap_or_else(|| ai::provider_name(None)),
|
provider: normalized_provider.clone(),
|
||||||
api_base,
|
api_base,
|
||||||
api_key,
|
api_key,
|
||||||
chat_model,
|
chat_model,
|
||||||
|
image_model: Some(ai::default_image_model_for_provider(&normalized_provider).to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +332,8 @@ fn update_active_provider_from_legacy_fields(model: &mut Model) {
|
|||||||
config.api_base = api_base;
|
config.api_base = api_base;
|
||||||
config.api_key = api_key;
|
config.api_key = api_key;
|
||||||
config.chat_model = chat_model;
|
config.chat_model = chat_model;
|
||||||
|
config.image_model =
|
||||||
|
Some(ai::default_image_model_for_provider(&config.provider).to_string());
|
||||||
write_ai_provider_state(
|
write_ai_provider_state(
|
||||||
model,
|
model,
|
||||||
vec![config],
|
vec![config],
|
||||||
@@ -322,6 +355,10 @@ fn update_active_provider_from_legacy_fields(model: &mut Model) {
|
|||||||
config.api_base = api_base.clone();
|
config.api_base = api_base.clone();
|
||||||
config.api_key = api_key.clone();
|
config.api_key = api_key.clone();
|
||||||
config.chat_model = chat_model.clone();
|
config.chat_model = chat_model.clone();
|
||||||
|
if config.image_model.is_none() {
|
||||||
|
config.image_model =
|
||||||
|
Some(ai::default_image_model_for_provider(&config.provider).to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,6 +462,18 @@ impl SiteSettingsPayload {
|
|||||||
if let Some(ai_chat_model) = self.ai_chat_model {
|
if let Some(ai_chat_model) = self.ai_chat_model {
|
||||||
item.ai_chat_model = normalize_optional_string(Some(ai_chat_model));
|
item.ai_chat_model = normalize_optional_string(Some(ai_chat_model));
|
||||||
}
|
}
|
||||||
|
if let Some(ai_image_provider) = self.ai_image_provider {
|
||||||
|
item.ai_image_provider = normalize_optional_string(Some(ai_image_provider));
|
||||||
|
}
|
||||||
|
if let Some(ai_image_api_base) = self.ai_image_api_base {
|
||||||
|
item.ai_image_api_base = normalize_optional_string(Some(ai_image_api_base));
|
||||||
|
}
|
||||||
|
if let Some(ai_image_api_key) = self.ai_image_api_key {
|
||||||
|
item.ai_image_api_key = normalize_optional_string(Some(ai_image_api_key));
|
||||||
|
}
|
||||||
|
if let Some(ai_image_model) = self.ai_image_model {
|
||||||
|
item.ai_image_model = normalize_optional_string(Some(ai_image_model));
|
||||||
|
}
|
||||||
if let Some(ai_embedding_model) = self.ai_embedding_model {
|
if let Some(ai_embedding_model) = self.ai_embedding_model {
|
||||||
item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model));
|
item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model));
|
||||||
}
|
}
|
||||||
@@ -437,6 +486,26 @@ impl SiteSettingsPayload {
|
|||||||
if self.ai_chunk_size.is_some() {
|
if self.ai_chunk_size.is_some() {
|
||||||
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
|
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
|
||||||
}
|
}
|
||||||
|
if let Some(media_r2_account_id) = self.media_r2_account_id {
|
||||||
|
item.media_r2_account_id = normalize_optional_string(Some(media_r2_account_id));
|
||||||
|
}
|
||||||
|
if let Some(media_storage_provider) = self.media_storage_provider {
|
||||||
|
item.media_storage_provider = normalize_optional_string(Some(media_storage_provider));
|
||||||
|
}
|
||||||
|
if let Some(media_r2_bucket) = self.media_r2_bucket {
|
||||||
|
item.media_r2_bucket = normalize_optional_string(Some(media_r2_bucket));
|
||||||
|
}
|
||||||
|
if let Some(media_r2_public_base_url) = self.media_r2_public_base_url {
|
||||||
|
item.media_r2_public_base_url =
|
||||||
|
normalize_optional_string(Some(media_r2_public_base_url));
|
||||||
|
}
|
||||||
|
if let Some(media_r2_access_key_id) = self.media_r2_access_key_id {
|
||||||
|
item.media_r2_access_key_id = normalize_optional_string(Some(media_r2_access_key_id));
|
||||||
|
}
|
||||||
|
if let Some(media_r2_secret_access_key) = self.media_r2_secret_access_key {
|
||||||
|
item.media_r2_secret_access_key =
|
||||||
|
normalize_optional_string(Some(media_r2_secret_access_key));
|
||||||
|
}
|
||||||
|
|
||||||
if provider_list_supplied {
|
if provider_list_supplied {
|
||||||
write_ai_provider_state(
|
write_ai_provider_state(
|
||||||
@@ -524,6 +593,10 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
ai_api_base: Some(ai::default_api_base().to_string()),
|
ai_api_base: Some(ai::default_api_base().to_string()),
|
||||||
ai_api_key: Some(ai::default_api_key().to_string()),
|
ai_api_key: Some(ai::default_api_key().to_string()),
|
||||||
ai_chat_model: Some(ai::default_chat_model().to_string()),
|
ai_chat_model: Some(ai::default_chat_model().to_string()),
|
||||||
|
ai_image_provider: None,
|
||||||
|
ai_image_api_base: None,
|
||||||
|
ai_image_api_key: None,
|
||||||
|
ai_image_model: None,
|
||||||
ai_providers: Some(vec![default_ai_provider_config()]),
|
ai_providers: Some(vec![default_ai_provider_config()]),
|
||||||
ai_active_provider_id: Some("default".to_string()),
|
ai_active_provider_id: Some("default".to_string()),
|
||||||
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
|
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
|
||||||
@@ -533,6 +606,12 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
),
|
),
|
||||||
ai_top_k: Some(4),
|
ai_top_k: Some(4),
|
||||||
ai_chunk_size: Some(1200),
|
ai_chunk_size: Some(1200),
|
||||||
|
media_storage_provider: None,
|
||||||
|
media_r2_account_id: None,
|
||||||
|
media_r2_bucket: None,
|
||||||
|
media_r2_public_base_url: None,
|
||||||
|
media_r2_access_key_id: None,
|
||||||
|
media_r2_secret_access_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +632,11 @@ pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
|||||||
.await?;
|
.await?;
|
||||||
let mut model = inserted;
|
let mut model = inserted;
|
||||||
default_payload().apply(&mut model);
|
default_payload().apply(&mut model);
|
||||||
Ok(model.into_active_model().update(&ctx.db).await?)
|
Ok(model
|
||||||
|
.into_active_model()
|
||||||
|
.reset_all()
|
||||||
|
.update(&ctx.db)
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||||
@@ -596,7 +679,7 @@ pub async fn update(
|
|||||||
let current = load_current(&ctx).await?;
|
let current = load_current(&ctx).await?;
|
||||||
let mut item = current;
|
let mut item = current;
|
||||||
params.apply(&mut item);
|
params.apply(&mut item);
|
||||||
let item = item.into_active_model();
|
let item = item.into_active_model().reset_all();
|
||||||
let updated = item.update(&ctx.db).await?;
|
let updated = item.update(&ctx.db).await?;
|
||||||
format::json(public_response(updated))
|
format::json(public_response(updated))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod categories;
|
|||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod friend_links;
|
pub mod friend_links;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
pub mod query_events;
|
||||||
pub mod reviews;
|
pub mod reviews;
|
||||||
pub mod site_settings;
|
pub mod site_settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub use super::categories::Entity as Categories;
|
|||||||
pub use super::comments::Entity as Comments;
|
pub use super::comments::Entity as Comments;
|
||||||
pub use super::friend_links::Entity as FriendLinks;
|
pub use super::friend_links::Entity as FriendLinks;
|
||||||
pub use super::posts::Entity as Posts;
|
pub use super::posts::Entity as Posts;
|
||||||
|
pub use super::query_events::Entity as QueryEvents;
|
||||||
pub use super::reviews::Entity as Reviews;
|
pub use super::reviews::Entity as Reviews;
|
||||||
pub use super::site_settings::Entity as SiteSettings;
|
pub use super::site_settings::Entity as SiteSettings;
|
||||||
pub use super::tags::Entity as Tags;
|
pub use super::tags::Entity as Tags;
|
||||||
|
|||||||
33
backend/src/models/_entities/query_events.rs
Normal file
33
backend/src/models/_entities/query_events.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! `SeaORM` Entity, manually maintained
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "query_events")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub event_type: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub query_text: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub normalized_query: String,
|
||||||
|
pub request_path: Option<String>,
|
||||||
|
pub referrer: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub result_count: Option<i32>,
|
||||||
|
pub success: Option<bool>,
|
||||||
|
pub response_mode: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub chat_model: Option<String>,
|
||||||
|
pub latency_ms: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -37,6 +37,11 @@ pub struct Model {
|
|||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub ai_api_key: Option<String>,
|
pub ai_api_key: Option<String>,
|
||||||
pub ai_chat_model: Option<String>,
|
pub ai_chat_model: Option<String>,
|
||||||
|
pub ai_image_provider: Option<String>,
|
||||||
|
pub ai_image_api_base: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub ai_image_api_key: Option<String>,
|
||||||
|
pub ai_image_model: Option<String>,
|
||||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
pub ai_providers: Option<Json>,
|
pub ai_providers: Option<Json>,
|
||||||
pub ai_active_provider_id: Option<String>,
|
pub ai_active_provider_id: Option<String>,
|
||||||
@@ -46,6 +51,13 @@ pub struct Model {
|
|||||||
pub ai_top_k: Option<i32>,
|
pub ai_top_k: Option<i32>,
|
||||||
pub ai_chunk_size: Option<i32>,
|
pub ai_chunk_size: Option<i32>,
|
||||||
pub ai_last_indexed_at: Option<DateTimeWithTimeZone>,
|
pub ai_last_indexed_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub media_storage_provider: Option<String>,
|
||||||
|
pub media_r2_account_id: Option<String>,
|
||||||
|
pub media_r2_bucket: Option<String>,
|
||||||
|
pub media_r2_public_base_url: Option<String>,
|
||||||
|
pub media_r2_access_key_id: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub media_r2_secret_access_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use fastembed::{
|
|||||||
InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
|
InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
|
||||||
};
|
};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{header::CONTENT_TYPE, multipart, Client, Url};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||||
PaginatorTrait, QueryOrder, Set, Statement,
|
PaginatorTrait, QueryOrder, Set, Statement,
|
||||||
@@ -17,11 +17,12 @@ use std::sync::{Mutex, OnceLock};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controllers::site_settings as site_settings_controller,
|
||||||
models::_entities::{ai_chunks, site_settings},
|
models::_entities::{ai_chunks, site_settings},
|
||||||
services::content,
|
services::{content, storage},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_AI_PROVIDER: &str = "newapi";
|
const DEFAULT_AI_PROVIDER: &str = "openai";
|
||||||
const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1";
|
const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1";
|
||||||
const DEFAULT_AI_API_KEY: &str =
|
const DEFAULT_AI_API_KEY: &str =
|
||||||
"sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571";
|
"sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571";
|
||||||
@@ -29,6 +30,8 @@ const DEFAULT_CHAT_MODEL: &str = "gpt-5.4";
|
|||||||
const DEFAULT_REASONING_EFFORT: &str = "medium";
|
const DEFAULT_REASONING_EFFORT: &str = "medium";
|
||||||
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
|
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
|
||||||
const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1";
|
const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1";
|
||||||
|
const DEFAULT_CLOUDFLARE_CHAT_MODEL: &str = "@cf/meta/llama-3.1-8b-instruct";
|
||||||
|
const DEFAULT_CLOUDFLARE_IMAGE_MODEL: &str = "@cf/black-forest-labs/flux-2-klein-4b";
|
||||||
const DEFAULT_TOP_K: usize = 4;
|
const DEFAULT_TOP_K: usize = 4;
|
||||||
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
||||||
const DEFAULT_SYSTEM_PROMPT: &str =
|
const DEFAULT_SYSTEM_PROMPT: &str =
|
||||||
@@ -49,6 +52,14 @@ const LOCAL_EMBEDDING_FILES: [&str; 5] = [
|
|||||||
|
|
||||||
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AiImageRuntimeSettings {
|
||||||
|
provider: String,
|
||||||
|
api_base: Option<String>,
|
||||||
|
api_key: Option<String>,
|
||||||
|
image_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct AiRuntimeSettings {
|
struct AiRuntimeSettings {
|
||||||
raw: site_settings::Model,
|
raw: site_settings::Model,
|
||||||
@@ -56,6 +67,7 @@ struct AiRuntimeSettings {
|
|||||||
api_base: Option<String>,
|
api_base: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
chat_model: String,
|
chat_model: String,
|
||||||
|
image: AiImageRuntimeSettings,
|
||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
top_k: usize,
|
top_k: usize,
|
||||||
chunk_size: usize,
|
chunk_size: usize,
|
||||||
@@ -121,6 +133,11 @@ pub struct PolishedPostMarkdown {
|
|||||||
pub polished_markdown: String,
|
pub polished_markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct PolishedReviewDescription {
|
||||||
|
pub polished_description: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct GeneratedPostCoverImage {
|
pub struct GeneratedPostCoverImage {
|
||||||
pub image_url: String,
|
pub image_url: String,
|
||||||
@@ -135,6 +152,14 @@ pub struct AiProviderConnectivityResult {
|
|||||||
pub reply_preview: String,
|
pub reply_preview: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AiImageProviderConnectivityResult {
|
||||||
|
pub provider: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub image_model: String,
|
||||||
|
pub result_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
struct GeneratedPostMetadataDraft {
|
struct GeneratedPostMetadataDraft {
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
@@ -227,16 +252,57 @@ fn build_endpoint(api_base: &str, path: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_uses_cloudflare(provider: &str) -> bool {
|
||||||
|
provider.eq_ignore_ascii_case("cloudflare")
|
||||||
|
|| provider.eq_ignore_ascii_case("cloudflare-workers-ai")
|
||||||
|
|| provider.eq_ignore_ascii_case("workers-ai")
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_uses_openai_api_prefix(provider: &str) -> bool {
|
fn provider_uses_openai_api_prefix(provider: &str) -> bool {
|
||||||
provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible")
|
provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_cloudflare_api_base(api_base: &str) -> String {
|
||||||
|
let trimmed = api_base.trim().trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
|
||||||
|
return format!(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/{}",
|
||||||
|
trimmed.trim_matches('/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(mut parsed) = Url::parse(trimmed) else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
let segments = parsed
|
||||||
|
.path_segments()
|
||||||
|
.map(|items| items.collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(account_index) = segments.iter().position(|segment| *segment == "accounts") {
|
||||||
|
if let Some(account_id) = segments.get(account_index + 1) {
|
||||||
|
parsed.set_path(&format!("/client/v4/accounts/{account_id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.to_string().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_provider_api_base(provider: &str, api_base: &str) -> String {
|
fn normalize_provider_api_base(provider: &str, api_base: &str) -> String {
|
||||||
let trimmed = api_base.trim();
|
let trimmed = api_base.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider_uses_cloudflare(provider) {
|
||||||
|
return normalize_cloudflare_api_base(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
if !provider_uses_openai_api_prefix(provider) {
|
if !provider_uses_openai_api_prefix(provider) {
|
||||||
return trimmed.trim_end_matches('/').to_string();
|
return trimmed.trim_end_matches('/').to_string();
|
||||||
}
|
}
|
||||||
@@ -604,6 +670,19 @@ fn parse_provider_sse_body(body: &str) -> Option<Value> {
|
|||||||
latest_response.or(latest_payload)
|
latest_response.or(latest_payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_json_body(body: &str) -> Result<Value> {
|
||||||
|
serde_json::from_str(body)
|
||||||
|
.or_else(|_| {
|
||||||
|
parse_provider_sse_body(body).ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"provider returned neither JSON nor SSE JSON payload",
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
|
async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
|
||||||
let response = client
|
let response = client
|
||||||
.post(url)
|
.post(url)
|
||||||
@@ -626,20 +705,56 @@ async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value)
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::from_str(&body)
|
parse_json_body(&body)
|
||||||
.or_else(|_| {
|
}
|
||||||
parse_provider_sse_body(&body).ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
async fn request_multipart_json(
|
||||||
std::io::ErrorKind::InvalidData,
|
client: &Client,
|
||||||
"provider returned neither JSON nor SSE JSON payload",
|
url: &str,
|
||||||
))
|
api_key: &str,
|
||||||
})
|
form: multipart::Form,
|
||||||
})
|
) -> Result<Value> {
|
||||||
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"AI provider returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_json_body(&body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_uses_responses(provider: &str) -> bool {
|
fn provider_uses_responses(provider: &str) -> bool {
|
||||||
provider.eq_ignore_ascii_case("newapi")
|
provider.eq_ignore_ascii_case("newapi")
|
||||||
|
|| provider.eq_ignore_ascii_case("openai")
|
||||||
|
|| provider.eq_ignore_ascii_case("anthropic")
|
||||||
|
|| provider.eq_ignore_ascii_case("gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image_model_for_provider_name(provider: &str) -> &'static str {
|
||||||
|
if provider_uses_cloudflare(provider) {
|
||||||
|
DEFAULT_CLOUDFLARE_IMAGE_MODEL
|
||||||
|
} else {
|
||||||
|
DEFAULT_IMAGE_MODEL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_image_model_for_provider(provider: &str) -> &'static str {
|
||||||
|
default_image_model_for_provider_name(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
||||||
@@ -1015,6 +1130,66 @@ fn build_polish_markdown_prompt(markdown: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_polish_review_prompt(
|
||||||
|
title: &str,
|
||||||
|
review_type: &str,
|
||||||
|
rating: i32,
|
||||||
|
review_date: Option<&str>,
|
||||||
|
status: &str,
|
||||||
|
tags: &[String],
|
||||||
|
description: &str,
|
||||||
|
) -> String {
|
||||||
|
let review_date_text = review_date
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("未填写");
|
||||||
|
let tag_text = if tags.is_empty() {
|
||||||
|
"无".to_string()
|
||||||
|
} else {
|
||||||
|
tags.join(" / ")
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"请润色一段中文作品评测简介/点评文案。\n\
|
||||||
|
要求:\n\
|
||||||
|
1. 保留原文观点、倾向和结论,不要杜撰剧情、设定或体验细节。\n\
|
||||||
|
2. 语言更凝练、更自然,适合放在评测页中展示。\n\
|
||||||
|
3. 可以优化句式与节奏,但不要改写成标题、列表或营销文案。\n\
|
||||||
|
4. 默认输出一到三段正文,总长度尽量控制在 80 到 220 字之间;如果原文本身更长,可适度保留信息密度。\n\
|
||||||
|
5. 只返回润色后的简介正文,不要附加解释。\n\n\
|
||||||
|
作品标题:{}\n\
|
||||||
|
评测类型:{}\n\
|
||||||
|
评分:{}/5\n\
|
||||||
|
评测日期:{}\n\
|
||||||
|
状态:{}\n\
|
||||||
|
标签:{}\n\n\
|
||||||
|
当前简介:\n{}",
|
||||||
|
title.trim(),
|
||||||
|
review_type.trim(),
|
||||||
|
rating,
|
||||||
|
review_date_text,
|
||||||
|
status.trim(),
|
||||||
|
tag_text,
|
||||||
|
description.trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_sentence_ending(text: &str) -> String {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
trimmed.chars().last(),
|
||||||
|
Some('。' | '!' | '?' | '.' | '!' | '?')
|
||||||
|
) {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed}。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_post_cover_prompt(
|
fn build_post_cover_prompt(
|
||||||
title: &str,
|
title: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
@@ -1063,12 +1238,40 @@ fn build_post_cover_prompt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_image_generation_url(provider: &str, api_base: &str) -> String {
|
fn build_image_generation_url(provider: &str, api_base: &str, image_model: &str) -> String {
|
||||||
let normalized = normalize_provider_api_base(provider, api_base);
|
let normalized = normalize_provider_api_base(provider, api_base);
|
||||||
|
|
||||||
|
if provider_uses_cloudflare(provider) {
|
||||||
|
return build_endpoint(&normalized, &format!("/ai/run/{}", image_model.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
build_endpoint(&normalized, "/images/generations")
|
build_endpoint(&normalized, "/images/generations")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_image_generation_result(value: &Value) -> Option<String> {
|
fn extract_image_generation_result(value: &Value) -> Option<String> {
|
||||||
|
if let Some(result) = value.get("result") {
|
||||||
|
if let Some(image) = result.get("image").and_then(Value::as_str) {
|
||||||
|
let trimmed = image.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = result.get("url").and_then(Value::as_str) {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(image) = value.get("image").and_then(Value::as_str) {
|
||||||
|
let trimmed = image.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let data = value.get("data").and_then(Value::as_array)?;
|
let data = value.get("data").and_then(Value::as_array)?;
|
||||||
|
|
||||||
for item in data {
|
for item in data {
|
||||||
@@ -1115,25 +1318,113 @@ fn generated_cover_directory() -> PathBuf {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_generated_cover_image(slug_hint: &str, base64_data: &str) -> Result<String> {
|
fn image_details_from_mime(content_type: &str) -> Option<(&'static str, &'static str)> {
|
||||||
|
match content_type
|
||||||
|
.trim()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"image/png" => Some(("png", "image/png")),
|
||||||
|
"image/jpeg" | "image/jpg" => Some(("jpg", "image/jpeg")),
|
||||||
|
"image/webp" => Some(("webp", "image/webp")),
|
||||||
|
"image/gif" => Some(("gif", "image/gif")),
|
||||||
|
"image/svg+xml" => Some(("svg", "image/svg+xml")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_details_from_extension(extension: &str) -> Option<(&'static str, &'static str)> {
|
||||||
|
match extension
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('.')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"png" => Some(("png", "image/png")),
|
||||||
|
"jpg" | "jpeg" => Some(("jpg", "image/jpeg")),
|
||||||
|
"webp" => Some(("webp", "image/webp")),
|
||||||
|
"gif" => Some(("gif", "image/gif")),
|
||||||
|
"svg" => Some(("svg", "image/svg+xml")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_details_from_url(url: &str) -> Option<(&'static str, &'static str)> {
|
||||||
|
let parsed = Url::parse(url).ok()?;
|
||||||
|
let path = parsed.path();
|
||||||
|
let extension = path.rsplit_once('.')?.1;
|
||||||
|
image_details_from_extension(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_base64_image_payload(base64_data: &str) -> Result<(Vec<u8>, &'static str, &'static str)> {
|
||||||
|
let trimmed = base64_data.trim();
|
||||||
|
let (payload, extension, content_type) = if let Some(rest) = trimmed.strip_prefix("data:") {
|
||||||
|
let (metadata, encoded) = rest
|
||||||
|
.split_once(',')
|
||||||
|
.ok_or_else(|| Error::BadRequest("AI 封面图 data URL 格式不正确".to_string()))?;
|
||||||
|
let mime = metadata
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let (extension, content_type) =
|
||||||
|
image_details_from_mime(mime).unwrap_or(("png", "image/png"));
|
||||||
|
(encoded, extension, content_type)
|
||||||
|
} else {
|
||||||
|
(trimmed, "png", "image/png")
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_bytes = BASE64_STANDARD
|
||||||
|
.decode(payload)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok((image_bytes, extension, content_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_generated_cover_image_bytes(
|
||||||
|
ctx: &AppContext,
|
||||||
|
slug_hint: &str,
|
||||||
|
image_bytes: Vec<u8>,
|
||||||
|
extension: &str,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if storage::optional_r2_settings(ctx).await?.is_some() {
|
||||||
|
let key = storage::build_object_key("post-covers", slug_hint, extension);
|
||||||
|
let stored = storage::upload_bytes_to_r2(
|
||||||
|
ctx,
|
||||||
|
&key,
|
||||||
|
image_bytes,
|
||||||
|
Some(content_type),
|
||||||
|
Some("public, max-age=31536000, immutable"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
return Ok(stored.url);
|
||||||
|
}
|
||||||
|
|
||||||
let directory = generated_cover_directory();
|
let directory = generated_cover_directory();
|
||||||
fs::create_dir_all(&directory)
|
fs::create_dir_all(&directory)
|
||||||
.map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?;
|
||||||
|
|
||||||
let safe_slug = metadata_slugify(slug_hint);
|
let safe_slug = metadata_slugify(slug_hint);
|
||||||
let file_name = format!(
|
let file_name = format!(
|
||||||
"{}-{}.png",
|
"{}-{}.{}",
|
||||||
if safe_slug.is_empty() {
|
if safe_slug.is_empty() {
|
||||||
"cover".to_string()
|
"cover".to_string()
|
||||||
} else {
|
} else {
|
||||||
safe_slug
|
safe_slug
|
||||||
},
|
},
|
||||||
Uuid::new_v4().simple()
|
Uuid::new_v4().simple(),
|
||||||
|
extension
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('.')
|
||||||
|
.to_ascii_lowercase()
|
||||||
);
|
);
|
||||||
let file_path = directory.join(&file_name);
|
let file_path = directory.join(&file_name);
|
||||||
let image_bytes = BASE64_STANDARD
|
|
||||||
.decode(base64_data)
|
|
||||||
.map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?;
|
|
||||||
|
|
||||||
fs::write(&file_path, image_bytes)
|
fs::write(&file_path, image_bytes)
|
||||||
.map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?;
|
||||||
@@ -1141,6 +1432,49 @@ fn persist_generated_cover_image(slug_hint: &str, base64_data: &str) -> Result<S
|
|||||||
Ok(format!("/generated-covers/{file_name}"))
|
Ok(format!("/generated-covers/{file_name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn persist_generated_cover_image(
|
||||||
|
ctx: &AppContext,
|
||||||
|
slug_hint: &str,
|
||||||
|
image_result: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if image_result.starts_with("http://") || image_result.starts_with("https://") {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(image_result)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?;
|
||||||
|
let content_type_header = response
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let image_bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取 AI 封面图失败: {error}")))?;
|
||||||
|
let (extension, content_type) = content_type_header
|
||||||
|
.as_deref()
|
||||||
|
.and_then(image_details_from_mime)
|
||||||
|
.or_else(|| image_details_from_url(image_result))
|
||||||
|
.unwrap_or(("png", "image/png"));
|
||||||
|
|
||||||
|
return persist_generated_cover_image_bytes(
|
||||||
|
ctx,
|
||||||
|
slug_hint,
|
||||||
|
image_bytes.to_vec(),
|
||||||
|
extension,
|
||||||
|
content_type,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (image_bytes, extension, content_type) = decode_base64_image_payload(image_result)?;
|
||||||
|
persist_generated_cover_image_bytes(ctx, slug_hint, image_bytes, extension, content_type).await
|
||||||
|
}
|
||||||
|
|
||||||
fn fallback_polished_markdown(markdown: &str) -> String {
|
fn fallback_polished_markdown(markdown: &str) -> String {
|
||||||
let metadata = fallback_generated_metadata(markdown);
|
let metadata = fallback_generated_metadata(markdown);
|
||||||
let body = strip_markdown_frontmatter(markdown)
|
let body = strip_markdown_frontmatter(markdown)
|
||||||
@@ -1176,6 +1510,17 @@ tags:\n{}\n\
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fallback_polished_review_description(description: &str) -> String {
|
||||||
|
let normalized = normalize_newlines(description)
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
ensure_sentence_ending(&normalized)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn generate_post_metadata(
|
pub async fn generate_post_metadata(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
markdown: &str,
|
markdown: &str,
|
||||||
@@ -1303,6 +1648,85 @@ pub async fn polish_post_markdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn polish_review_description(
|
||||||
|
ctx: &AppContext,
|
||||||
|
title: &str,
|
||||||
|
review_type: &str,
|
||||||
|
rating: i32,
|
||||||
|
review_date: Option<&str>,
|
||||||
|
status: &str,
|
||||||
|
tags: &[String],
|
||||||
|
description: &str,
|
||||||
|
) -> Result<PolishedReviewDescription> {
|
||||||
|
let trimmed_description = description.trim();
|
||||||
|
if trimmed_description.is_empty() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"请先填写点评内容,再进行润色。".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = load_runtime_settings(ctx, false).await?;
|
||||||
|
let remote_result: Result<PolishedReviewDescription> = match (
|
||||||
|
settings.api_base.clone(),
|
||||||
|
settings.api_key.clone(),
|
||||||
|
) {
|
||||||
|
(Some(api_base), Some(api_key)) => {
|
||||||
|
let request = AiProviderRequest {
|
||||||
|
provider: settings.provider.clone(),
|
||||||
|
api_base,
|
||||||
|
api_key,
|
||||||
|
chat_model: settings.chat_model.clone(),
|
||||||
|
system_prompt: "你是中文内容平台里的资深评测编辑。你只负责润色用户已有的作品点评文案,不要改写核心观点,不要虚构事实,不要输出额外解释。".to_string(),
|
||||||
|
prompt: build_polish_review_prompt(
|
||||||
|
title,
|
||||||
|
review_type,
|
||||||
|
rating,
|
||||||
|
review_date,
|
||||||
|
status,
|
||||||
|
tags,
|
||||||
|
trimmed_description,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let response = request_json(
|
||||||
|
&client,
|
||||||
|
&build_provider_url(&request),
|
||||||
|
&request.api_key,
|
||||||
|
build_provider_payload(&request, false),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(response) => {
|
||||||
|
let polished_description =
|
||||||
|
extract_provider_text(&response).ok_or_else(|| {
|
||||||
|
Error::BadRequest("AI 润色响应里没有可读取内容。".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(PolishedReviewDescription {
|
||||||
|
polished_description: normalize_newlines(polished_description.trim()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(Error::BadRequest(
|
||||||
|
"AI 服务未配置完整,已自动切换为本地智能润色。".to_string(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
match remote_result {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("AI review polish fallback: {error}");
|
||||||
|
Ok(PolishedReviewDescription {
|
||||||
|
polished_description: fallback_polished_review_description(trimmed_description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn generate_post_cover_image(
|
pub async fn generate_post_cover_image(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -1323,14 +1747,14 @@ pub async fn generate_post_cover_image(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = load_runtime_settings(ctx, false).await?;
|
let settings = load_runtime_settings(ctx, false).await?;
|
||||||
let api_base = settings
|
let image_settings = settings.image.clone();
|
||||||
.api_base
|
let api_base = image_settings.api_base.clone().ok_or_else(|| {
|
||||||
.clone()
|
Error::BadRequest("图片 AI API Base 未配置,无法生成封面图。".to_string())
|
||||||
.ok_or_else(|| Error::BadRequest("AI API Base 未配置,无法生成封面图。".to_string()))?;
|
})?;
|
||||||
let api_key = settings
|
let api_key = image_settings
|
||||||
.api_key
|
.api_key
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| Error::BadRequest("AI API Key 未配置,无法生成封面图。".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("图片 AI API Key 未配置,无法生成封面图。".to_string()))?;
|
||||||
let prompt = build_post_cover_prompt(
|
let prompt = build_post_cover_prompt(
|
||||||
if trimmed_title.is_empty() {
|
if trimmed_title.is_empty() {
|
||||||
"未命名文章"
|
"未命名文章"
|
||||||
@@ -1343,33 +1767,22 @@ pub async fn generate_post_cover_image(
|
|||||||
post_type,
|
post_type,
|
||||||
trimmed_markdown,
|
trimmed_markdown,
|
||||||
);
|
);
|
||||||
let payload = json!({
|
let image_model = image_settings.image_model.clone();
|
||||||
"model": DEFAULT_IMAGE_MODEL,
|
let response = request_image_generation(
|
||||||
"prompt": prompt,
|
&image_settings.provider,
|
||||||
"size": "1536x1024",
|
&api_base,
|
||||||
"quality": "high",
|
|
||||||
"output_format": "png"
|
|
||||||
});
|
|
||||||
|
|
||||||
let client = Client::new();
|
|
||||||
let response = request_json(
|
|
||||||
&client,
|
|
||||||
&build_image_generation_url(&settings.provider, &api_base),
|
|
||||||
&api_key,
|
&api_key,
|
||||||
payload,
|
&image_model,
|
||||||
|
&prompt,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let image_result = extract_image_generation_result(&response)
|
let image_result = extract_image_generation_result(&response)
|
||||||
.ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?;
|
||||||
let image_url = if image_result.starts_with("http://") || image_result.starts_with("https://") {
|
|
||||||
image_result
|
|
||||||
} else {
|
|
||||||
let slug_hint = slug
|
let slug_hint = slug
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or(trimmed_title);
|
.unwrap_or(trimmed_title);
|
||||||
persist_generated_cover_image(slug_hint, &image_result)?
|
let image_url = persist_generated_cover_image(ctx, slug_hint, &image_result).await?;
|
||||||
};
|
|
||||||
|
|
||||||
Ok(GeneratedPostCoverImage { image_url, prompt })
|
Ok(GeneratedPostCoverImage { image_url, prompt })
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1802,12 @@ fn extract_response_output(value: &Value) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(result) = value.get("result") {
|
||||||
|
if let Some(text) = extract_response_output(result) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(response) = value.get("response") {
|
if let Some(response) = value.get("response") {
|
||||||
if let Some(text) = extract_response_output(response) {
|
if let Some(text) = extract_response_output(response) {
|
||||||
return Some(text);
|
return Some(text);
|
||||||
@@ -1590,7 +2009,16 @@ fn build_sources(matches: &[ScoredChunk]) -> Vec<AiSource> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
|
pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
|
||||||
if provider_uses_responses(&request.provider) {
|
if provider_uses_cloudflare(&request.provider) {
|
||||||
|
json!({
|
||||||
|
"prompt": format!(
|
||||||
|
"系统指令:{}
|
||||||
|
|
||||||
|
用户请求:{}",
|
||||||
|
request.system_prompt, request.prompt,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else if provider_uses_responses(&request.provider) {
|
||||||
json!({
|
json!({
|
||||||
"model": request.chat_model,
|
"model": request.chat_model,
|
||||||
"input": [
|
"input": [
|
||||||
@@ -1640,13 +2068,15 @@ pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool)
|
|||||||
|
|
||||||
pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
|
pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
|
||||||
let api_base = normalize_provider_api_base(&request.provider, &request.api_base);
|
let api_base = normalize_provider_api_base(&request.provider, &request.api_base);
|
||||||
let path = if provider_uses_responses(&request.provider) {
|
let path = if provider_uses_cloudflare(&request.provider) {
|
||||||
"/responses"
|
format!("/ai/run/{}", request.chat_model.trim())
|
||||||
|
} else if provider_uses_responses(&request.provider) {
|
||||||
|
"/responses".to_string()
|
||||||
} else {
|
} else {
|
||||||
"/chat/completions"
|
"/chat/completions".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
build_endpoint(&api_base, path)
|
build_endpoint(&api_base, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1701,6 +2131,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_provider_api_base_supports_cloudflare_account_id() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_provider_api_base("cloudflare", "test-account-id"),
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/test-account-id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_provider_url_uses_cloudflare_run_endpoint() {
|
||||||
|
let mut request = build_request("cloudflare", "test-account-id");
|
||||||
|
request.chat_model = "@cf/meta/llama-3.1-8b-instruct".to_string();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
build_provider_url(&request),
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/@cf/meta/llama-3.1-8b-instruct"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_question_detects_owner_keywords() {
|
fn profile_question_detects_owner_keywords() {
|
||||||
assert!(is_profile_question("站长的技术栈和个人介绍是什么?"));
|
assert!(is_profile_question("站长的技术栈和个人介绍是什么?"));
|
||||||
@@ -1780,6 +2229,59 @@ async fn request_chat_answer(request: &AiProviderRequest) -> Result<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn request_image_generation(
|
||||||
|
provider: &str,
|
||||||
|
api_base: &str,
|
||||||
|
api_key: &str,
|
||||||
|
image_model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
if provider_uses_cloudflare(provider) {
|
||||||
|
if image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL) {
|
||||||
|
let form = multipart::Form::new()
|
||||||
|
.text("prompt", prompt.to_string())
|
||||||
|
.text("width", "1024")
|
||||||
|
.text("height", "576")
|
||||||
|
.text("steps", "16");
|
||||||
|
|
||||||
|
return request_multipart_json(
|
||||||
|
&client,
|
||||||
|
&build_image_generation_url(provider, api_base, image_model),
|
||||||
|
api_key,
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request_json(
|
||||||
|
&client,
|
||||||
|
&build_image_generation_url(provider, api_base, image_model),
|
||||||
|
api_key,
|
||||||
|
json!({
|
||||||
|
"prompt": prompt,
|
||||||
|
"steps": 4
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
request_json(
|
||||||
|
&client,
|
||||||
|
&build_image_generation_url(provider, api_base, image_model),
|
||||||
|
api_key,
|
||||||
|
json!({
|
||||||
|
"model": image_model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": "1536x1024",
|
||||||
|
"quality": "high",
|
||||||
|
"output_format": "png"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn test_provider_connectivity(
|
pub async fn test_provider_connectivity(
|
||||||
provider: &str,
|
provider: &str,
|
||||||
api_base: &str,
|
api_base: &str,
|
||||||
@@ -1814,6 +2316,59 @@ pub async fn test_provider_connectivity(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn test_image_provider_connectivity(
|
||||||
|
provider: &str,
|
||||||
|
api_base: &str,
|
||||||
|
api_key: &str,
|
||||||
|
image_model: &str,
|
||||||
|
) -> Result<AiImageProviderConnectivityResult> {
|
||||||
|
let provider = trim_to_option(Some(provider.to_string()))
|
||||||
|
.unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string());
|
||||||
|
let api_base = trim_to_option(Some(api_base.to_string()))
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先填写图片 API 地址".to_string()))?;
|
||||||
|
let api_key = trim_to_option(Some(api_key.to_string()))
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先填写图片 API 密钥".to_string()))?;
|
||||||
|
let image_model = trim_to_option(Some(image_model.to_string()))
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先填写图片模型".to_string()))?;
|
||||||
|
|
||||||
|
let prompt = "Minimal abstract technology cover art, blue gradient, no text, no watermark";
|
||||||
|
let response = if provider_uses_cloudflare(&provider)
|
||||||
|
&& image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL)
|
||||||
|
{
|
||||||
|
let client = Client::new();
|
||||||
|
let form = multipart::Form::new()
|
||||||
|
.text("prompt", prompt.to_string())
|
||||||
|
.text("width", "512")
|
||||||
|
.text("height", "288")
|
||||||
|
.text("steps", "4");
|
||||||
|
|
||||||
|
request_multipart_json(
|
||||||
|
&client,
|
||||||
|
&build_image_generation_url(&provider, &api_base, &image_model),
|
||||||
|
&api_key,
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
request_image_generation(&provider, &api_base, &api_key, &image_model, prompt).await?
|
||||||
|
};
|
||||||
|
let image_result = extract_image_generation_result(&response)
|
||||||
|
.ok_or_else(|| Error::BadRequest("图片接口响应里没有可读取的图片结果".to_string()))?;
|
||||||
|
let result_preview =
|
||||||
|
if image_result.starts_with("http://") || image_result.starts_with("https://") {
|
||||||
|
image_result
|
||||||
|
} else {
|
||||||
|
format!("base64 image ok ({} chars)", image_result.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AiImageProviderConnectivityResult {
|
||||||
|
provider: provider.clone(),
|
||||||
|
endpoint: build_image_generation_url(&provider, &api_base, &image_model),
|
||||||
|
image_model,
|
||||||
|
result_preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
|
pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
|
||||||
let trimmed_question = question.trim();
|
let trimmed_question = question.trim();
|
||||||
if trimmed_question.is_empty() {
|
if trimmed_question.is_empty() {
|
||||||
@@ -1907,12 +2462,72 @@ async fn load_runtime_settings(
|
|||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let active_provider =
|
||||||
|
site_settings_controller::active_ai_provider_id(&raw).and_then(|active_id| {
|
||||||
|
site_settings_controller::ai_provider_configs(&raw)
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| item.id == active_id)
|
||||||
|
});
|
||||||
|
let provider = active_provider
|
||||||
|
.as_ref()
|
||||||
|
.map(|item| provider_name(Some(item.provider.as_str())))
|
||||||
|
.unwrap_or_else(|| provider_name(raw.ai_provider.as_deref()));
|
||||||
|
let api_base = active_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.api_base.clone()))
|
||||||
|
.or_else(|| trim_to_option(raw.ai_api_base.clone()));
|
||||||
|
let api_key = active_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.api_key.clone()))
|
||||||
|
.or_else(|| trim_to_option(raw.ai_api_key.clone()));
|
||||||
|
let chat_model = active_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.chat_model.clone()))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if provider_uses_cloudflare(&provider) {
|
||||||
|
DEFAULT_CLOUDFLARE_CHAT_MODEL.to_string()
|
||||||
|
} else {
|
||||||
|
DEFAULT_CHAT_MODEL.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let legacy_image_model = active_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.image_model.clone()))
|
||||||
|
.unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());
|
||||||
|
let image_provider = trim_to_option(raw.ai_image_provider.clone());
|
||||||
|
let image_api_base = trim_to_option(raw.ai_image_api_base.clone());
|
||||||
|
let image_api_key = trim_to_option(raw.ai_image_api_key.clone());
|
||||||
|
let image_model = trim_to_option(raw.ai_image_model.clone());
|
||||||
|
let has_dedicated_image_settings = image_provider.is_some()
|
||||||
|
|| image_api_base.is_some()
|
||||||
|
|| image_api_key.is_some()
|
||||||
|
|| image_model.is_some();
|
||||||
|
let image = if has_dedicated_image_settings {
|
||||||
|
let provider = image_provider.unwrap_or_else(|| provider.clone());
|
||||||
|
let image_model = image_model
|
||||||
|
.unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());
|
||||||
|
|
||||||
|
AiImageRuntimeSettings {
|
||||||
|
provider,
|
||||||
|
api_base: image_api_base,
|
||||||
|
api_key: image_api_key,
|
||||||
|
image_model,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AiImageRuntimeSettings {
|
||||||
|
provider: provider.clone(),
|
||||||
|
api_base: api_base.clone(),
|
||||||
|
api_key: api_key.clone(),
|
||||||
|
image_model: legacy_image_model,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(AiRuntimeSettings {
|
Ok(AiRuntimeSettings {
|
||||||
provider: provider_name(raw.ai_provider.as_deref()),
|
provider,
|
||||||
api_base: trim_to_option(raw.ai_api_base.clone()),
|
api_base,
|
||||||
api_key: trim_to_option(raw.ai_api_key.clone()),
|
api_key,
|
||||||
chat_model: trim_to_option(raw.ai_chat_model.clone())
|
chat_model,
|
||||||
.unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()),
|
image,
|
||||||
system_prompt: trim_to_option(raw.ai_system_prompt.clone())
|
system_prompt: trim_to_option(raw.ai_system_prompt.clone())
|
||||||
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
|
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
|
||||||
top_k: raw
|
top_k: raw
|
||||||
|
|||||||
441
backend/src/services/analytics.rs
Normal file
441
backend/src/services/analytics.rs
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use chrono::{DateTime, Duration, NaiveDate, Utc};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
|
||||||
|
QuerySelect, Set,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::models::_entities::query_events;
|
||||||
|
|
||||||
|
const EVENT_TYPE_SEARCH: &str = "search";
|
||||||
|
const EVENT_TYPE_AI_QUESTION: &str = "ai_question";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct QueryEventRequestContext {
|
||||||
|
pub request_path: Option<String>,
|
||||||
|
pub referrer: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct QueryEventDraft {
|
||||||
|
pub event_type: String,
|
||||||
|
pub query_text: String,
|
||||||
|
pub request_context: QueryEventRequestContext,
|
||||||
|
pub result_count: Option<i32>,
|
||||||
|
pub success: Option<bool>,
|
||||||
|
pub response_mode: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub chat_model: Option<String>,
|
||||||
|
pub latency_ms: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AnalyticsOverview {
|
||||||
|
pub total_searches: u64,
|
||||||
|
pub total_ai_questions: u64,
|
||||||
|
pub searches_last_24h: u64,
|
||||||
|
pub ai_questions_last_24h: u64,
|
||||||
|
pub searches_last_7d: u64,
|
||||||
|
pub ai_questions_last_7d: u64,
|
||||||
|
pub unique_search_terms_last_7d: usize,
|
||||||
|
pub unique_ai_questions_last_7d: usize,
|
||||||
|
pub avg_search_results_last_7d: f64,
|
||||||
|
pub avg_ai_latency_ms_last_7d: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AnalyticsTopQuery {
|
||||||
|
pub query: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub last_seen_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AnalyticsRecentEvent {
|
||||||
|
pub id: i32,
|
||||||
|
pub event_type: String,
|
||||||
|
pub query: String,
|
||||||
|
pub result_count: Option<i32>,
|
||||||
|
pub success: Option<bool>,
|
||||||
|
pub response_mode: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub chat_model: Option<String>,
|
||||||
|
pub latency_ms: Option<i32>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AnalyticsProviderBucket {
|
||||||
|
pub provider: String,
|
||||||
|
pub count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AnalyticsDailyBucket {
|
||||||
|
pub date: String,
|
||||||
|
pub searches: u64,
|
||||||
|
pub ai_questions: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminAnalyticsResponse {
|
||||||
|
pub overview: AnalyticsOverview,
|
||||||
|
pub top_search_terms: Vec<AnalyticsTopQuery>,
|
||||||
|
pub top_ai_questions: Vec<AnalyticsTopQuery>,
|
||||||
|
pub recent_events: Vec<AnalyticsRecentEvent>,
|
||||||
|
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
|
||||||
|
pub daily_activity: Vec<AnalyticsDailyBucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct QueryAggregate {
|
||||||
|
query: String,
|
||||||
|
count: u64,
|
||||||
|
last_seen_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_query(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_timestamp(value: DateTime<Utc>) -> String {
|
||||||
|
value.format("%Y-%m-%d %H:%M").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(key)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.and_then(|value| trim_to_option(Some(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_latency(latency_ms: i64) -> i32 {
|
||||||
|
latency_ms.clamp(0, i64::from(i32::MAX)) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_query_aggregates(
|
||||||
|
events: &[query_events::Model],
|
||||||
|
wanted_type: &str,
|
||||||
|
) -> Vec<QueryAggregate> {
|
||||||
|
let mut grouped: HashMap<String, QueryAggregate> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.event_type == wanted_type)
|
||||||
|
{
|
||||||
|
let entry = grouped
|
||||||
|
.entry(event.normalized_query.clone())
|
||||||
|
.or_insert_with(|| QueryAggregate {
|
||||||
|
query: event.query_text.clone(),
|
||||||
|
count: 0,
|
||||||
|
last_seen_at: event.created_at.into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
|
||||||
|
let created_at = DateTime::<Utc>::from(event.created_at);
|
||||||
|
if created_at >= entry.last_seen_at {
|
||||||
|
entry.query = event.query_text.clone();
|
||||||
|
entry.last_seen_at = created_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = grouped.into_values().collect::<Vec<_>>();
|
||||||
|
items.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| right.last_seen_at.cmp(&left.last_seen_at))
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_queries(
|
||||||
|
events: &[query_events::Model],
|
||||||
|
wanted_type: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> (usize, Vec<AnalyticsTopQuery>) {
|
||||||
|
let aggregates = build_query_aggregates(events, wanted_type);
|
||||||
|
let total_unique = aggregates.len();
|
||||||
|
let items = aggregates
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(|item| AnalyticsTopQuery {
|
||||||
|
query: item.query,
|
||||||
|
count: item.count,
|
||||||
|
last_seen_at: format_timestamp(item.last_seen_at),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
(total_unique, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_context_from_headers(path: &str, headers: &HeaderMap) -> QueryEventRequestContext {
|
||||||
|
QueryEventRequestContext {
|
||||||
|
request_path: trim_to_option(Some(path.to_string())),
|
||||||
|
referrer: header_value(headers, "referer"),
|
||||||
|
user_agent: header_value(headers, "user-agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_event(ctx: &AppContext, draft: QueryEventDraft) {
|
||||||
|
let query_text = draft.query_text.trim().to_string();
|
||||||
|
if query_text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_model = query_events::ActiveModel {
|
||||||
|
event_type: Set(draft.event_type),
|
||||||
|
query_text: Set(query_text.clone()),
|
||||||
|
normalized_query: Set(normalize_query(&query_text)),
|
||||||
|
request_path: Set(trim_to_option(draft.request_context.request_path)),
|
||||||
|
referrer: Set(trim_to_option(draft.request_context.referrer)),
|
||||||
|
user_agent: Set(trim_to_option(draft.request_context.user_agent)),
|
||||||
|
result_count: Set(draft.result_count),
|
||||||
|
success: Set(draft.success),
|
||||||
|
response_mode: Set(trim_to_option(draft.response_mode)),
|
||||||
|
provider: Set(trim_to_option(draft.provider)),
|
||||||
|
chat_model: Set(trim_to_option(draft.chat_model)),
|
||||||
|
latency_ms: Set(draft.latency_ms.map(|value| value.max(0))),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = active_model.insert(&ctx.db).await {
|
||||||
|
tracing::warn!("failed to record query analytics event: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_search_event(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query_text: &str,
|
||||||
|
result_count: usize,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
latency_ms: i64,
|
||||||
|
) {
|
||||||
|
record_event(
|
||||||
|
ctx,
|
||||||
|
QueryEventDraft {
|
||||||
|
event_type: EVENT_TYPE_SEARCH.to_string(),
|
||||||
|
query_text: query_text.to_string(),
|
||||||
|
request_context: request_context_from_headers("/api/search", headers),
|
||||||
|
result_count: Some(result_count.min(i32::MAX as usize) as i32),
|
||||||
|
success: Some(true),
|
||||||
|
response_mode: None,
|
||||||
|
provider: None,
|
||||||
|
chat_model: None,
|
||||||
|
latency_ms: Some(clamp_latency(latency_ms)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_ai_question_event(
|
||||||
|
ctx: &AppContext,
|
||||||
|
question: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
success: bool,
|
||||||
|
response_mode: &str,
|
||||||
|
provider: Option<String>,
|
||||||
|
chat_model: Option<String>,
|
||||||
|
result_count: Option<usize>,
|
||||||
|
latency_ms: i64,
|
||||||
|
) {
|
||||||
|
record_event(
|
||||||
|
ctx,
|
||||||
|
QueryEventDraft {
|
||||||
|
event_type: EVENT_TYPE_AI_QUESTION.to_string(),
|
||||||
|
query_text: question.to_string(),
|
||||||
|
request_context: request_context_from_headers(
|
||||||
|
if response_mode == "stream" {
|
||||||
|
"/api/ai/ask/stream"
|
||||||
|
} else {
|
||||||
|
"/api/ai/ask"
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
),
|
||||||
|
result_count: result_count.map(|value| value.min(i32::MAX as usize) as i32),
|
||||||
|
success: Some(success),
|
||||||
|
response_mode: Some(response_mode.to_string()),
|
||||||
|
provider,
|
||||||
|
chat_model,
|
||||||
|
latency_ms: Some(clamp_latency(latency_ms)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsResponse> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let since_24h = now - Duration::hours(24);
|
||||||
|
let since_7d = now - Duration::days(7);
|
||||||
|
|
||||||
|
let total_searches = query_events::Entity::find()
|
||||||
|
.filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let total_ai_questions = query_events::Entity::find()
|
||||||
|
.filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let searches_last_24h = query_events::Entity::find()
|
||||||
|
.filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH))
|
||||||
|
.filter(query_events::Column::CreatedAt.gte(since_24h))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let ai_questions_last_24h = query_events::Entity::find()
|
||||||
|
.filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION))
|
||||||
|
.filter(query_events::Column::CreatedAt.gte(since_24h))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let last_7d_events = query_events::Entity::find()
|
||||||
|
.filter(query_events::Column::CreatedAt.gte(since_7d))
|
||||||
|
.order_by_desc(query_events::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let searches_last_7d = last_7d_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.event_type == EVENT_TYPE_SEARCH)
|
||||||
|
.count() as u64;
|
||||||
|
let ai_questions_last_7d = last_7d_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.event_type == EVENT_TYPE_AI_QUESTION)
|
||||||
|
.count() as u64;
|
||||||
|
|
||||||
|
let (unique_search_terms_last_7d, top_search_terms) =
|
||||||
|
aggregate_queries(&last_7d_events, EVENT_TYPE_SEARCH, 8);
|
||||||
|
let (unique_ai_questions_last_7d, top_ai_questions) =
|
||||||
|
aggregate_queries(&last_7d_events, EVENT_TYPE_AI_QUESTION, 8);
|
||||||
|
|
||||||
|
let mut provider_breakdown: HashMap<String, u64> = HashMap::new();
|
||||||
|
let mut daily_map: BTreeMap<NaiveDate, (u64, u64)> = BTreeMap::new();
|
||||||
|
let mut total_search_results = 0.0_f64;
|
||||||
|
let mut counted_search_results = 0_u64;
|
||||||
|
let mut total_ai_latency = 0.0_f64;
|
||||||
|
let mut counted_ai_latency = 0_u64;
|
||||||
|
|
||||||
|
for offset in 0..7 {
|
||||||
|
let date = (now - Duration::days(offset)).date_naive();
|
||||||
|
daily_map.entry(date).or_insert((0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in &last_7d_events {
|
||||||
|
let day = DateTime::<Utc>::from(event.created_at).date_naive();
|
||||||
|
let entry = daily_map.entry(day).or_insert((0, 0));
|
||||||
|
|
||||||
|
if event.event_type == EVENT_TYPE_SEARCH {
|
||||||
|
entry.0 += 1;
|
||||||
|
if let Some(result_count) = event.result_count {
|
||||||
|
total_search_results += f64::from(result_count.max(0));
|
||||||
|
counted_search_results += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.event_type == EVENT_TYPE_AI_QUESTION {
|
||||||
|
entry.1 += 1;
|
||||||
|
|
||||||
|
let provider = event
|
||||||
|
.provider
|
||||||
|
.clone()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "local-or-unspecified".to_string());
|
||||||
|
*provider_breakdown.entry(provider).or_insert(0) += 1;
|
||||||
|
|
||||||
|
if let Some(latency_ms) = event.latency_ms {
|
||||||
|
total_ai_latency += f64::from(latency_ms.max(0));
|
||||||
|
counted_ai_latency += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut providers_last_7d = provider_breakdown
|
||||||
|
.into_iter()
|
||||||
|
.map(|(provider, count)| AnalyticsProviderBucket { provider, count })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
providers_last_7d.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.count
|
||||||
|
.cmp(&left.count)
|
||||||
|
.then_with(|| left.provider.cmp(&right.provider))
|
||||||
|
});
|
||||||
|
providers_last_7d.truncate(6);
|
||||||
|
|
||||||
|
let mut daily_activity = daily_map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(date, (searches, ai_questions))| AnalyticsDailyBucket {
|
||||||
|
date: date.format("%Y-%m-%d").to_string(),
|
||||||
|
searches,
|
||||||
|
ai_questions,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
daily_activity.sort_by(|left, right| left.date.cmp(&right.date));
|
||||||
|
|
||||||
|
let recent_events = query_events::Entity::find()
|
||||||
|
.order_by_desc(query_events::Column::CreatedAt)
|
||||||
|
.limit(24)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| AnalyticsRecentEvent {
|
||||||
|
id: event.id,
|
||||||
|
event_type: event.event_type,
|
||||||
|
query: event.query_text,
|
||||||
|
result_count: event.result_count,
|
||||||
|
success: event.success,
|
||||||
|
response_mode: event.response_mode,
|
||||||
|
provider: event.provider,
|
||||||
|
chat_model: event.chat_model,
|
||||||
|
latency_ms: event.latency_ms,
|
||||||
|
created_at: format_timestamp(event.created_at.into()),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(AdminAnalyticsResponse {
|
||||||
|
overview: AnalyticsOverview {
|
||||||
|
total_searches,
|
||||||
|
total_ai_questions,
|
||||||
|
searches_last_24h,
|
||||||
|
ai_questions_last_24h,
|
||||||
|
searches_last_7d,
|
||||||
|
ai_questions_last_7d,
|
||||||
|
unique_search_terms_last_7d,
|
||||||
|
unique_ai_questions_last_7d,
|
||||||
|
avg_search_results_last_7d: if counted_search_results > 0 {
|
||||||
|
total_search_results / counted_search_results as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
avg_ai_latency_ms_last_7d: (counted_ai_latency > 0)
|
||||||
|
.then(|| total_ai_latency / counted_ai_latency as f64),
|
||||||
|
},
|
||||||
|
top_search_terms,
|
||||||
|
top_ai_questions,
|
||||||
|
recent_events,
|
||||||
|
providers_last_7d,
|
||||||
|
daily_activity,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
|
pub mod analytics;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
pub mod storage;
|
||||||
|
|||||||
513
backend/src/services/storage.rs
Normal file
513
backend/src/services/storage.rs
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{EntityTrait, QueryOrder};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::_entities::site_settings;
|
||||||
|
|
||||||
|
const ENV_MEDIA_PROVIDER: &str = "TERMI_MEDIA_PROVIDER";
|
||||||
|
const ENV_MEDIA_ENDPOINT: &str = "TERMI_MEDIA_ENDPOINT";
|
||||||
|
const ENV_MEDIA_BUCKET: &str = "TERMI_MEDIA_BUCKET";
|
||||||
|
const ENV_MEDIA_PUBLIC_BASE_URL: &str = "TERMI_MEDIA_PUBLIC_BASE_URL";
|
||||||
|
const ENV_MEDIA_ACCESS_KEY_ID: &str = "TERMI_MEDIA_ACCESS_KEY_ID";
|
||||||
|
const ENV_MEDIA_SECRET_ACCESS_KEY: &str = "TERMI_MEDIA_SECRET_ACCESS_KEY";
|
||||||
|
|
||||||
|
const ENV_R2_ACCOUNT_ID: &str = "TERMI_R2_ACCOUNT_ID";
|
||||||
|
const ENV_R2_BUCKET: &str = "TERMI_R2_BUCKET";
|
||||||
|
const ENV_R2_PUBLIC_BASE_URL: &str = "TERMI_R2_PUBLIC_BASE_URL";
|
||||||
|
const ENV_R2_ACCESS_KEY_ID: &str = "TERMI_R2_ACCESS_KEY_ID";
|
||||||
|
const ENV_R2_SECRET_ACCESS_KEY: &str = "TERMI_R2_SECRET_ACCESS_KEY";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum MediaStorageProvider {
|
||||||
|
R2,
|
||||||
|
Minio,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MediaStorageSettings {
|
||||||
|
pub provider: MediaStorageProvider,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub public_base_url: String,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
pub region: String,
|
||||||
|
pub force_path_style: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct StoredObject {
|
||||||
|
pub key: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct StoredObjectSummary {
|
||||||
|
pub key: String,
|
||||||
|
pub url: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_value(name: &str) -> Option<String> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| trim_to_option(Some(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify_segment(value: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut previous_dash = false;
|
||||||
|
|
||||||
|
for ch in value.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
output.push(ch.to_ascii_lowercase());
|
||||||
|
previous_dash = false;
|
||||||
|
} else if !previous_dash {
|
||||||
|
output.push('-');
|
||||||
|
previous_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_public_base_url(value: String) -> String {
|
||||||
|
value.trim().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_provider(value: Option<String>) -> MediaStorageProvider {
|
||||||
|
match value
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"minio" => MediaStorageProvider::Minio,
|
||||||
|
_ => MediaStorageProvider::R2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_settings_row(ctx: &AppContext) -> Result<Option<site_settings::Model>> {
|
||||||
|
site_settings::Entity::find()
|
||||||
|
.order_by_asc(site_settings::Column::Id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_r2_endpoint(account_id: &str) -> String {
|
||||||
|
let trimmed = account_id.trim().trim_end_matches('/');
|
||||||
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{trimmed}.r2.cloudflarestorage.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_public_base_url_for_minio(endpoint: &str, bucket: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
endpoint.trim().trim_end_matches('/'),
|
||||||
|
bucket.trim().trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generated_cover_directory() -> PathBuf {
|
||||||
|
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
let candidates = [
|
||||||
|
current_dir
|
||||||
|
.join("frontend")
|
||||||
|
.join("public")
|
||||||
|
.join("generated-covers"),
|
||||||
|
current_dir
|
||||||
|
.join("..")
|
||||||
|
.join("frontend")
|
||||||
|
.join("public")
|
||||||
|
.join("generated-covers"),
|
||||||
|
];
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|path| path.parent().map(|parent| parent.exists()).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from("..")
|
||||||
|
.join("frontend")
|
||||||
|
.join("public")
|
||||||
|
.join("generated-covers")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_extension_for_path(path: &Path) -> &str {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|ext| !ext.is_empty())
|
||||||
|
.unwrap_or("png")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn optional_r2_settings(ctx: &AppContext) -> Result<Option<MediaStorageSettings>> {
|
||||||
|
let row = load_settings_row(ctx).await?;
|
||||||
|
|
||||||
|
let provider_raw = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_storage_provider.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_PROVIDER));
|
||||||
|
let provider = normalize_provider(provider_raw.clone());
|
||||||
|
|
||||||
|
let endpoint_or_account = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_r2_account_id.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_ENDPOINT))
|
||||||
|
.or_else(|| env_value(ENV_R2_ACCOUNT_ID));
|
||||||
|
let bucket = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_r2_bucket.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_BUCKET))
|
||||||
|
.or_else(|| env_value(ENV_R2_BUCKET));
|
||||||
|
let access_key_id = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_r2_access_key_id.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_ACCESS_KEY_ID))
|
||||||
|
.or_else(|| env_value(ENV_R2_ACCESS_KEY_ID));
|
||||||
|
let secret_access_key = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_r2_secret_access_key.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_SECRET_ACCESS_KEY))
|
||||||
|
.or_else(|| env_value(ENV_R2_SECRET_ACCESS_KEY));
|
||||||
|
|
||||||
|
let public_base_url = row
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|item| trim_to_option(item.media_r2_public_base_url.clone()))
|
||||||
|
.or_else(|| env_value(ENV_MEDIA_PUBLIC_BASE_URL))
|
||||||
|
.or_else(|| env_value(ENV_R2_PUBLIC_BASE_URL))
|
||||||
|
.or_else(
|
||||||
|
|| match (&provider, endpoint_or_account.as_deref(), bucket.as_deref()) {
|
||||||
|
(MediaStorageProvider::Minio, Some(endpoint), Some(bucket)) => {
|
||||||
|
Some(default_public_base_url_for_minio(endpoint, bucket))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let has_any = endpoint_or_account.is_some()
|
||||||
|
|| bucket.is_some()
|
||||||
|
|| public_base_url.is_some()
|
||||||
|
|| access_key_id.is_some()
|
||||||
|
|| secret_access_key.is_some()
|
||||||
|
|| provider_raw.is_some();
|
||||||
|
|
||||||
|
if !has_any {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing = [
|
||||||
|
(
|
||||||
|
match provider {
|
||||||
|
MediaStorageProvider::Minio => "Endpoint",
|
||||||
|
MediaStorageProvider::R2 => "Account ID",
|
||||||
|
},
|
||||||
|
endpoint_or_account.is_some(),
|
||||||
|
),
|
||||||
|
("Bucket", bucket.is_some()),
|
||||||
|
("Public Base URL", public_base_url.is_some()),
|
||||||
|
("Access Key ID", access_key_id.is_some()),
|
||||||
|
("Secret Access Key", secret_access_key.is_some()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(label, present)| (!present).then_some(label))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"对象存储配置不完整,请补齐:{}",
|
||||||
|
missing.join(" / ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint = match provider {
|
||||||
|
MediaStorageProvider::R2 => {
|
||||||
|
build_r2_endpoint(&endpoint_or_account.clone().unwrap_or_default())
|
||||||
|
}
|
||||||
|
MediaStorageProvider::Minio => endpoint_or_account.clone().unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(MediaStorageSettings {
|
||||||
|
provider: provider.clone(),
|
||||||
|
provider_name: match provider {
|
||||||
|
MediaStorageProvider::R2 => "r2".to_string(),
|
||||||
|
MediaStorageProvider::Minio => "minio".to_string(),
|
||||||
|
},
|
||||||
|
endpoint,
|
||||||
|
bucket: bucket.unwrap_or_default(),
|
||||||
|
public_base_url: normalize_public_base_url(public_base_url.unwrap_or_default()),
|
||||||
|
access_key_id: access_key_id.unwrap_or_default(),
|
||||||
|
secret_access_key: secret_access_key.unwrap_or_default(),
|
||||||
|
region: match provider {
|
||||||
|
MediaStorageProvider::R2 => "auto".to_string(),
|
||||||
|
MediaStorageProvider::Minio => "us-east-1".to_string(),
|
||||||
|
},
|
||||||
|
force_path_style: provider == MediaStorageProvider::Minio,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_r2_settings(ctx: &AppContext) -> Result<MediaStorageSettings> {
|
||||||
|
optional_r2_settings(ctx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先在后台配置媒体对象存储".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn r2_client(settings: &MediaStorageSettings) -> Client {
|
||||||
|
let shared_config = aws_config::defaults(BehaviorVersion::latest())
|
||||||
|
.endpoint_url(settings.endpoint.clone())
|
||||||
|
.credentials_provider(Credentials::new(
|
||||||
|
settings.access_key_id.clone(),
|
||||||
|
settings.secret_access_key.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
match settings.provider {
|
||||||
|
MediaStorageProvider::R2 => "r2",
|
||||||
|
MediaStorageProvider::Minio => "minio",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.region(aws_sdk_s3::config::Region::new(settings.region.clone()))
|
||||||
|
.load()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conf = aws_sdk_s3::config::Builder::from(&shared_config)
|
||||||
|
.force_path_style(settings.force_path_style)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Client::from_conf(conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_public_url(settings: &MediaStorageSettings, key: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
settings.public_base_url,
|
||||||
|
key.trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn object_key_from_public_url(settings: &MediaStorageSettings, url: &str) -> Option<String> {
|
||||||
|
let normalized_base = settings.public_base_url.trim().trim_end_matches('/');
|
||||||
|
let normalized_url = url.trim();
|
||||||
|
|
||||||
|
if normalized_base.is_empty() || normalized_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_url
|
||||||
|
.strip_prefix(normalized_base)
|
||||||
|
.map(|suffix| suffix.trim_start_matches('/').to_string())
|
||||||
|
.filter(|suffix| !suffix.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_object_key(prefix: &str, stem: &str, extension: &str) -> String {
|
||||||
|
let safe_prefix = prefix.trim_matches('/');
|
||||||
|
let safe_stem = slugify_segment(stem);
|
||||||
|
let safe_extension = extension
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('.')
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
let object_name = format!(
|
||||||
|
"{}-{}.{}",
|
||||||
|
if safe_stem.is_empty() {
|
||||||
|
"asset".to_string()
|
||||||
|
} else {
|
||||||
|
safe_stem
|
||||||
|
},
|
||||||
|
Uuid::new_v4().simple(),
|
||||||
|
if safe_extension.is_empty() {
|
||||||
|
"bin".to_string()
|
||||||
|
} else {
|
||||||
|
safe_extension
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if safe_prefix.is_empty() {
|
||||||
|
object_name
|
||||||
|
} else {
|
||||||
|
format!("{safe_prefix}/{object_name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_bucket_exists(client: &Client, settings: &MediaStorageSettings) -> Result<()> {
|
||||||
|
if client
|
||||||
|
.head_bucket()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.provider != MediaStorageProvider::Minio {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"对象存储 bucket 不存在或不可访问:{}",
|
||||||
|
settings.bucket
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("自动创建 MinIO bucket 失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_bytes_to_r2(
|
||||||
|
ctx: &AppContext,
|
||||||
|
key: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
cache_control: Option<&str>,
|
||||||
|
) -> Result<StoredObject> {
|
||||||
|
let settings = require_r2_settings(ctx).await?;
|
||||||
|
let client = r2_client(&settings).await;
|
||||||
|
ensure_bucket_exists(&client, &settings).await?;
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(bytes));
|
||||||
|
|
||||||
|
if let Some(content_type) = content_type
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request.content_type(content_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cache_control) = cache_control
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request.cache_control(cache_control);
|
||||||
|
}
|
||||||
|
|
||||||
|
request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("上传文件到对象存储失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(StoredObject {
|
||||||
|
key: key.to_string(),
|
||||||
|
url: build_public_url(&settings, key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_object(ctx: &AppContext, key: &str) -> Result<()> {
|
||||||
|
let settings = require_r2_settings(ctx).await?;
|
||||||
|
let client = r2_client(&settings).await;
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_managed_url(ctx: &AppContext, url: &str) -> Result<bool> {
|
||||||
|
let Some(settings) = optional_r2_settings(ctx).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let Some(key) = object_key_from_public_url(&settings, url) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let client = r2_client(&settings).await;
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.key(&key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_objects(
|
||||||
|
ctx: &AppContext,
|
||||||
|
prefix: Option<&str>,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<StoredObjectSummary>> {
|
||||||
|
let settings = require_r2_settings(ctx).await?;
|
||||||
|
let client = r2_client(&settings).await;
|
||||||
|
ensure_bucket_exists(&client, &settings).await?;
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.max_keys(limit.clamp(1, 1000));
|
||||||
|
|
||||||
|
if let Some(prefix) = prefix.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
request = request.prefix(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取对象存储列表失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(result
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let key = item.key()?.to_string();
|
||||||
|
Some(StoredObjectSummary {
|
||||||
|
url: build_public_url(&settings, &key),
|
||||||
|
key,
|
||||||
|
size_bytes: item.size().unwrap_or_default(),
|
||||||
|
last_modified: item.last_modified().map(|ts| format!("{ts:?}")),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test_r2_connectivity(ctx: &AppContext) -> Result<String> {
|
||||||
|
let settings = require_r2_settings(ctx).await?;
|
||||||
|
let client = r2_client(&settings).await;
|
||||||
|
ensure_bucket_exists(&client, &settings).await?;
|
||||||
|
let healthcheck_key = format!(".healthchecks/{}.txt", Uuid::new_v4().simple());
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.key(&healthcheck_key)
|
||||||
|
.body(ByteStream::from_static(b"termi-storage-ok"))
|
||||||
|
.content_type("text/plain")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("对象存储连接测试失败: {error}")))?;
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&settings.bucket)
|
||||||
|
.key(&healthcheck_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("对象存储清理测试文件失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(settings.bucket)
|
||||||
|
}
|
||||||
@@ -868,7 +868,9 @@ const currentNavLabel =
|
|||||||
renderSearchResults(query, [], 'loading');
|
renderSearchResults(query, [], 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6`);
|
const response = await fetch(
|
||||||
|
`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6&preview=true`
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Search failed: ${response.status}`);
|
throw new Error(`Search failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,25 @@ import Footer from '../components/Footer.astro';
|
|||||||
import BackToTop from '../components/interactive/BackToTop.svelte';
|
import BackToTop from '../components/interactive/BackToTop.svelte';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||||
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
siteSettings?: SiteSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = Astro.props;
|
const props = Astro.props;
|
||||||
const { locale, messages } = getI18n(Astro);
|
const { locale, messages } = getI18n(Astro);
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = props.siteSettings ?? DEFAULT_SITE_SETTINGS;
|
||||||
|
|
||||||
try {
|
if (!props.siteSettings) {
|
||||||
|
try {
|
||||||
siteSettings = await api.getSiteSettings();
|
siteSettings = await api.getSiteSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site settings:', error);
|
console.error('Failed to load site settings:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = props.title || siteSettings.siteTitle;
|
const title = props.title || siteSettings.siteTitle;
|
||||||
@@ -305,7 +309,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer siteSettings={siteSettings} />
|
<Footer siteSettings={siteSettings} />
|
||||||
<BackToTop client:load />
|
<BackToTop client:idle />
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ try {
|
|||||||
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}>
|
<BaseLayout
|
||||||
|
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
|
||||||
|
description={siteSettings.siteDescription}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/about" class="w-full">
|
<TerminalWindow title="~/about" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
|
|||||||
@@ -29,16 +29,21 @@ const { slug } = Astro.params;
|
|||||||
let post = null;
|
let post = null;
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
|
||||||
try {
|
const [postResult, siteSettingsResult] = await Promise.allSettled([
|
||||||
post = await apiClient.getPostBySlug(slug ?? '');
|
apiClient.getPostBySlug(slug ?? ''),
|
||||||
} catch (error) {
|
apiClient.getSiteSettings(),
|
||||||
console.error('API Error:', error);
|
]);
|
||||||
|
|
||||||
|
if (postResult.status === 'fulfilled') {
|
||||||
|
post = postResult.value;
|
||||||
|
} else {
|
||||||
|
console.error('API Error:', postResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (siteSettingsResult.status === 'fulfilled') {
|
||||||
siteSettings = await apiClient.getSiteSettings();
|
siteSettings = siteSettingsResult.value;
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Site settings API Error:', error);
|
console.error('Site settings API Error:', siteSettingsResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -59,7 +64,7 @@ const markdownProcessor = await createMarkdownProcessor();
|
|||||||
const renderedContent = await markdownProcessor.render(articleMarkdown);
|
const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={post.title} description={post.description}>
|
<BaseLayout title={post.title} description={post.description} siteSettings={siteSettings}>
|
||||||
<ReadingProgress />
|
<ReadingProgress />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
|
|||||||
@@ -19,9 +19,14 @@ const selectedSearch = url.searchParams.get('search') || '';
|
|||||||
const { t } = getI18n(Astro);
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
|
const [posts, categories, rawTags] = await Promise.all([
|
||||||
allCategories = await api.getCategories();
|
selectedSearch ? api.searchPosts(selectedSearch) : api.getPosts(),
|
||||||
const rawTags = await api.getTags();
|
api.getCategories(),
|
||||||
|
api.getTags(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
allPosts = posts;
|
||||||
|
allCategories = categories;
|
||||||
const seenTagIds = new Set<string>();
|
const seenTagIds = new Set<string>();
|
||||||
allTags = rawTags.filter(tag => {
|
allTags = rawTags.filter(tag => {
|
||||||
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ const sampleQuestions = [
|
|||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`} description={t('ask.pageDescription', { siteName: siteSettings.siteName })}>
|
<BaseLayout
|
||||||
|
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
|
||||||
|
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
|
||||||
|
siteSettings={siteSettings}
|
||||||
|
>
|
||||||
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
||||||
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
||||||
|
|||||||
@@ -37,8 +37,20 @@ let apiError: string | null = null;
|
|||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteSettings = await api.getSiteSettings();
|
const [settings, posts, rawTags, rawFriendLinks, nextCategories] = await Promise.all([
|
||||||
allPosts = await api.getPosts();
|
api.getSiteSettings(),
|
||||||
|
api.getPosts(),
|
||||||
|
api.getTags(),
|
||||||
|
api.getFriendLinks(),
|
||||||
|
api.getCategories(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
siteSettings = settings;
|
||||||
|
allPosts = posts;
|
||||||
|
tags = rawTags.map(tag => tag.name);
|
||||||
|
friendLinks = rawFriendLinks.filter(friend => friend.status === 'approved');
|
||||||
|
categories = nextCategories;
|
||||||
|
|
||||||
const filteredPosts = allPosts.filter(post => {
|
const filteredPosts = allPosts.filter(post => {
|
||||||
const normalizedCategory = post.category?.trim().toLowerCase() || '';
|
const normalizedCategory = post.category?.trim().toLowerCase() || '';
|
||||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
||||||
@@ -50,9 +62,6 @@ try {
|
|||||||
recentPosts = filteredPosts.slice(0, previewLimit);
|
recentPosts = filteredPosts.slice(0, previewLimit);
|
||||||
filteredPostsCount = filteredPosts.length;
|
filteredPostsCount = filteredPosts.length;
|
||||||
pinnedPost = allPosts.find(post => post.pinned) || null;
|
pinnedPost = allPosts.find(post => post.pinned) || null;
|
||||||
tags = (await api.getTags()).map(tag => tag.name);
|
|
||||||
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
|
|
||||||
categories = await api.getCategories();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
@@ -146,7 +155,7 @@ const navLinks = [
|
|||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription}>
|
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription} siteSettings={siteSettings}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<TerminalWindow title={terminalConfig.title} class="w-full">
|
<TerminalWindow title={terminalConfig.title} class="w-full">
|
||||||
<div class="mb-5 px-4 overflow-x-auto">
|
<div class="mb-5 px-4 overflow-x-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user