chore: checkpoint admin editor and perf work
This commit is contained in:
413
admin/src/pages/analytics-page.tsx
Normal file
413
admin/src/pages/analytics-page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminAnalyticsResponse } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
icon: typeof Search
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="flex items-start justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEventType(value: string) {
|
||||
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
|
||||
}
|
||||
|
||||
function formatSuccess(value: boolean | null) {
|
||||
if (value === null) {
|
||||
return '未记录'
|
||||
}
|
||||
|
||||
return value ? '成功' : '失败'
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadAnalytics = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.analytics()
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('数据分析已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics(false)
|
||||
}, [loadAnalytics])
|
||||
|
||||
const maxDailyTotal = useMemo(() => {
|
||||
if (!data?.daily_activity.length) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
...data.daily_activity.map((item) => item.searches + item.ai_questions),
|
||||
1,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-44 rounded-3xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: '累计搜索',
|
||||
value: String(data.overview.total_searches),
|
||||
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)} 条`,
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
label: '累计 AI 提问',
|
||||
value: String(data.overview.total_ai_questions),
|
||||
note: `近 7 天 ${data.overview.ai_questions_last_7d} 次`,
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
{
|
||||
label: '24 小时活跃',
|
||||
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
|
||||
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: '近 7 天去重词',
|
||||
value: String(
|
||||
data.overview.unique_search_terms_last_7d +
|
||||
data.overview.unique_ai_questions_last_7d,
|
||||
),
|
||||
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">数据分析</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">前台搜索与 AI 问答洞察</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
这里会记录用户真实提交过的站内搜索词和 AI 提问,方便你判断内容需求、热点问题和接入质量。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
|
||||
<BrainCircuit className="h-4 w-4" />
|
||||
打开问答页
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadAnalytics(true)}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map((item) => (
|
||||
<StatCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近记录</CardTitle>
|
||||
<CardDescription>
|
||||
最近一批真实发生的搜索和 AI 问答请求。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_events.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>结果</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.recent_events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
|
||||
{formatEventType(event.event_type)}
|
||||
</Badge>
|
||||
{event.response_mode ? (
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{event.response_mode}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="line-clamp-2 font-medium">{event.query}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.provider ? `${event.provider}` : '未记录渠道'}
|
||||
{event.chat_model ? ` / ${event.chat_model}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div>{formatSuccess(event.success)}</div>
|
||||
<div className="mt-1">
|
||||
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
|
||||
</div>
|
||||
{event.latency_ms !== null ? (
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
|
||||
{event.latency_ms} ms
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>热门搜索词</CardTitle>
|
||||
<CardDescription>
|
||||
近 7 天最常被搜索的关键词。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.top_search_terms.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.top_search_terms.length ? (
|
||||
data.top_search_terms.map((item) => (
|
||||
<div
|
||||
key={`${item.query}-${item.last_seen_at}`}
|
||||
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium">{item.query}</p>
|
||||
<Badge variant="secondary">{item.count}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
最近一次:{item.last_seen_at}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有站内搜索记录。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>热门 AI 问题</CardTitle>
|
||||
<CardDescription>
|
||||
近 7 天重复出现最多的提问。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.top_ai_questions.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.top_ai_questions.length ? (
|
||||
data.top_ai_questions.map((item) => (
|
||||
<div
|
||||
key={`${item.query}-${item.last_seen_at}`}
|
||||
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium">{item.query}</p>
|
||||
<Badge variant="secondary">{item.count}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
最近一次:{item.last_seen_at}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有 AI 提问记录。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>分析侧栏</CardTitle>
|
||||
<CardDescription>
|
||||
24 小时、7 天和模型渠道维度的快速摘要。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
24 小时搜索
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
24 小时 AI 提问
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
AI 平均耗时
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{data.overview.avg_ai_latency_ms_last_7d !== null
|
||||
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
|
||||
: '暂无'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">统计范围:最近 7 天</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型渠道分布</CardTitle>
|
||||
<CardDescription>
|
||||
最近 7 天 AI 请求实际使用的 provider 厂商。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.providers_last_7d.length ? (
|
||||
data.providers_last_7d.map((item) => (
|
||||
<div
|
||||
key={item.provider}
|
||||
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||||
>
|
||||
<span className="font-medium">{item.provider}</span>
|
||||
<Badge variant="outline">{item.count}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有 AI 渠道数据。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>7 天走势</CardTitle>
|
||||
<CardDescription>
|
||||
搜索与 AI 问答的日维度活动量。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.daily_activity.map((item) => {
|
||||
const total = item.searches + item.ai_questions
|
||||
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
|
||||
|
||||
return (
|
||||
<div key={item.date} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{item.date}</span>
|
||||
<span className="text-muted-foreground">
|
||||
搜索 {item.searches} / AI {item.ai_questions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
admin/src/pages/media-page.tsx
Normal file
190
admin/src/pages/media-page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
export function MediaPage() {
|
||||
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null)
|
||||
const [prefixFilter, setPrefixFilter] = useState('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [bucket, setBucket] = useState<string | null>(null)
|
||||
|
||||
const loadItems = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
|
||||
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
|
||||
startTransition(() => {
|
||||
setItems(result.items)
|
||||
setProvider(result.provider)
|
||||
setBucket(result.bucket)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('媒体对象列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [prefixFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadItems(false)
|
||||
}, [loadItems])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) => item.key.toLowerCase().includes(keyword))
|
||||
}, [items, searchTerm])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">媒体库</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">对象存储媒体管理</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
查看当前对象存储里的封面资源,支持筛选、复制链接和删除无用对象。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前存储</CardTitle>
|
||||
<CardDescription>
|
||||
Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
|
||||
<option value="all">全部目录</option>
|
||||
<option value="post-covers/">文章封面</option>
|
||||
<option value="review-covers/">评测封面</option>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="按对象 key 搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredItems.map((item) => (
|
||||
<Card key={item.key} className="overflow-hidden">
|
||||
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
|
||||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="space-y-2">
|
||||
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatBytes(item.size_bytes)}</span>
|
||||
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.url)
|
||||
toast.success('图片链接已复制。')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制。')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deletingKey === item.key}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setDeletingKey(item.key)
|
||||
await adminApi.deleteMediaObject(item.key)
|
||||
startTransition(() => {
|
||||
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
|
||||
})
|
||||
toast.success('媒体对象已删除。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
|
||||
} finally {
|
||||
setDeletingKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingKey === item.key ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!filteredItems.length ? (
|
||||
<Card className="xl:col-span-2 2xl:col-span-3">
|
||||
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有媒体对象。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DiffEditor } from '@monaco-editor/react'
|
||||
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
editorTheme,
|
||||
sharedOptions,
|
||||
} from '@/components/markdown-workbench'
|
||||
import { LazyDiffEditor } from '@/components/lazy-monaco'
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -191,7 +191,7 @@ export function PostPolishPage() {
|
||||
<span>当前合并结果</span>
|
||||
</div>
|
||||
<div className="h-[560px]">
|
||||
<DiffEditor
|
||||
<LazyDiffEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
original={originalMarkdown}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { BookOpenText, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -32,6 +32,11 @@ type ReviewFormState = {
|
||||
linkUrl: string
|
||||
}
|
||||
|
||||
type ReviewDescriptionPolishState = {
|
||||
originalDescription: string
|
||||
polishedDescription: string
|
||||
}
|
||||
|
||||
const defaultReviewForm: ReviewFormState = {
|
||||
title: '',
|
||||
reviewType: 'book',
|
||||
@@ -94,8 +99,14 @@ export function ReviewsPage() {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [polishingDescription, setPolishingDescription] = useState(false)
|
||||
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
|
||||
null,
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const loadReviews = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -153,6 +164,70 @@ export function ReviewsPage() {
|
||||
[reviews, selectedId],
|
||||
)
|
||||
|
||||
const requestDescriptionPolish = useCallback(async () => {
|
||||
if (!form.description.trim()) {
|
||||
toast.error('请先写一点点评内容,再让 AI 帮你润色。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPolishingDescription(true)
|
||||
const result = await adminApi.polishReviewDescription({
|
||||
title: form.title.trim() || '未命名评测',
|
||||
reviewType: form.reviewType,
|
||||
rating: Number(form.rating) || 0,
|
||||
reviewDate: form.reviewDate || null,
|
||||
status: form.status,
|
||||
tags: csvToList(form.tags),
|
||||
description: form.description,
|
||||
})
|
||||
const polishedDescription =
|
||||
typeof result.polished_description === 'string' ? result.polished_description : ''
|
||||
|
||||
if (!polishedDescription.trim()) {
|
||||
throw new Error('AI 润色返回为空。')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setDescriptionPolish({
|
||||
originalDescription: form.description,
|
||||
polishedDescription,
|
||||
})
|
||||
})
|
||||
|
||||
if (polishedDescription.trim() === form.description.trim()) {
|
||||
toast.success('AI 已检查这段点评,当前文案已经比较完整。')
|
||||
} else {
|
||||
toast.success('AI 已生成一版更顺的点评文案,可以先对比再决定是否采用。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'AI 润色点评失败。',
|
||||
)
|
||||
} finally {
|
||||
setPolishingDescription(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const uploadReviewCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCover(true)
|
||||
const result = await adminApi.uploadReviewCoverImage(file)
|
||||
startTransition(() => {
|
||||
setForm((current) => ({ ...current, cover: result.url }))
|
||||
})
|
||||
toast.success('评测封面已上传到 R2。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
@@ -172,6 +247,7 @@ export function ReviewsPage() {
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
setForm(defaultReviewForm)
|
||||
setDescriptionPolish(null)
|
||||
}}
|
||||
>
|
||||
新建评测
|
||||
@@ -220,6 +296,7 @@ export function ReviewsPage() {
|
||||
onClick={() => {
|
||||
setSelectedId(review.id)
|
||||
setForm(toFormState(review))
|
||||
setDescriptionPolish(null)
|
||||
}}
|
||||
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||
selectedId === review.id
|
||||
@@ -295,6 +372,7 @@ export function ReviewsPage() {
|
||||
startTransition(() => {
|
||||
setSelectedId(updated.id)
|
||||
setForm(toFormState(updated))
|
||||
setDescriptionPolish(null)
|
||||
})
|
||||
toast.success('评测已更新。')
|
||||
} else {
|
||||
@@ -302,6 +380,7 @@ export function ReviewsPage() {
|
||||
startTransition(() => {
|
||||
setSelectedId(created.id)
|
||||
setForm(toFormState(created))
|
||||
setDescriptionPolish(null)
|
||||
})
|
||||
toast.success('评测已创建。')
|
||||
}
|
||||
@@ -332,6 +411,7 @@ export function ReviewsPage() {
|
||||
toast.success('评测已删除。')
|
||||
setSelectedId(null)
|
||||
setForm(defaultReviewForm)
|
||||
setDescriptionPolish(null)
|
||||
await loadReviews(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
||||
@@ -414,13 +494,49 @@ export function ReviewsPage() {
|
||||
<option value="archived">已归档</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="封面 URL">
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={reviewCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadReviewCover(file)
|
||||
}
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={uploadingCover}
|
||||
onClick={() => reviewCoverInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCover ? '上传中...' : '上传到 R2'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{form.cover ? (
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
|
||||
<img
|
||||
src={form.cover}
|
||||
alt={form.title || '评测封面预览'}
|
||||
className="h-48 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
|
||||
<Input
|
||||
@@ -442,13 +558,113 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="简介">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
label="简介 / 点评"
|
||||
hint="可以先写你的原始观感,再用 AI 帮你把这段点评润得更顺。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
AI 会结合当前标题、类型、评分、状态和标签,只润色这段点评文案,不会自动保存。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{polishingDescription ? '润色中...' : 'AI 润色点评'}
|
||||
</Button>
|
||||
{descriptionPolish ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDescriptionPolish(null)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
收起对比
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) => {
|
||||
const nextDescription = event.target.value
|
||||
setForm((current) => ({ ...current, description: nextDescription }))
|
||||
setDescriptionPolish((current) =>
|
||||
current && current.originalDescription === nextDescription ? current : null,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{descriptionPolish ? (
|
||||
<div className="overflow-hidden rounded-[1.8rem] border border-border/70 bg-background/80">
|
||||
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-base font-semibold">AI 点评润色对比</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
左边保留当前文案,右边是 AI 建议,你可以直接采用或保留原文。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
description: descriptionPolish.polishedDescription,
|
||||
}))
|
||||
setDescriptionPolish(null)
|
||||
toast.success('AI 润色点评已回填到评测简介。')
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
采用润色结果
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
重新润色
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDescriptionPolish(null)}
|
||||
>
|
||||
保留原文
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5 xl:grid-cols-2">
|
||||
<div className="rounded-[1.4rem] border border-border/70 bg-muted/20 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
当前点评
|
||||
</p>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
|
||||
{descriptionPolish.originalDescription.trim() || '未填写'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[1.4rem] border border-emerald-500/30 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
AI 建议
|
||||
</p>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
|
||||
{descriptionPolish.polishedDescription.trim() || '未填写'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
@@ -42,10 +43,39 @@ function createEmptyAiProvider(): AiProviderConfig {
|
||||
return {
|
||||
id: createAiProviderId(),
|
||||
name: '',
|
||||
provider: 'newapi',
|
||||
provider: 'openai',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
chat_model: '',
|
||||
image_model: '',
|
||||
}
|
||||
}
|
||||
|
||||
const AI_PROVIDER_OPTIONS = [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'cloudflare', label: 'Cloudflare Workers AI' },
|
||||
{ value: 'newapi', label: 'NewAPI / Responses' },
|
||||
{ value: 'openai-compatible', label: 'OpenAI Compatible' },
|
||||
] as const
|
||||
|
||||
const MEDIA_STORAGE_PROVIDER_OPTIONS = [
|
||||
{ value: 'r2', label: 'Cloudflare R2' },
|
||||
{ value: 'minio', label: 'MinIO' },
|
||||
] as const
|
||||
|
||||
function isCloudflareProvider(provider: string | null | undefined) {
|
||||
const normalized = provider?.trim().toLowerCase()
|
||||
return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
|
||||
}
|
||||
|
||||
function buildCloudflareAiPreset(current: AiProviderConfig): AiProviderConfig {
|
||||
return {
|
||||
...current,
|
||||
name: current.name?.trim() ? current.name : 'Cloudflare Workers AI',
|
||||
provider: 'cloudflare',
|
||||
chat_model: current.chat_model?.trim() || '@cf/meta/llama-3.1-8b-instruct',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +135,22 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
aiApiBase: form.ai_api_base,
|
||||
aiApiKey: form.ai_api_key,
|
||||
aiChatModel: form.ai_chat_model,
|
||||
aiImageProvider: form.ai_image_provider,
|
||||
aiImageApiBase: form.ai_image_api_base,
|
||||
aiImageApiKey: form.ai_image_api_key,
|
||||
aiImageModel: form.ai_image_model,
|
||||
aiProviders: form.ai_providers,
|
||||
aiActiveProviderId: form.ai_active_provider_id,
|
||||
aiEmbeddingModel: form.ai_embedding_model,
|
||||
aiSystemPrompt: form.ai_system_prompt,
|
||||
aiTopK: form.ai_top_k,
|
||||
aiChunkSize: form.ai_chunk_size,
|
||||
mediaStorageProvider: form.media_storage_provider,
|
||||
mediaR2AccountId: form.media_r2_account_id,
|
||||
mediaR2Bucket: form.media_r2_bucket,
|
||||
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
|
||||
mediaR2AccessKeyId: form.media_r2_access_key_id,
|
||||
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +160,8 @@ export function SiteSettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reindexing, setReindexing] = useState(false)
|
||||
const [testingProvider, setTestingProvider] = useState(false)
|
||||
const [testingImageProvider, setTestingImageProvider] = useState(false)
|
||||
const [testingR2Storage, setTestingR2Storage] = useState(false)
|
||||
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
|
||||
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
|
||||
|
||||
@@ -290,6 +332,38 @@ export function SiteSettingsPage() {
|
||||
updateField('ai_active_provider_id', providerId)
|
||||
}
|
||||
|
||||
const applyCloudflarePreset = (index: number) => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const nextProviders = current.ai_providers.map((provider, providerIndex) =>
|
||||
providerIndex === index ? buildCloudflareAiPreset(provider) : provider,
|
||||
)
|
||||
|
||||
return {
|
||||
...current,
|
||||
ai_providers: nextProviders,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const applyCloudflareImagePreset = () => {
|
||||
setForm((current) => {
|
||||
if (!current) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
ai_image_provider: 'cloudflare',
|
||||
ai_image_model:
|
||||
current.ai_image_model?.trim() || '@cf/black-forest-labs/flux-2-klein-4b',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const techStackValue = useMemo(
|
||||
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
|
||||
[form?.tech_stack],
|
||||
@@ -306,6 +380,18 @@ export function SiteSettingsPage() {
|
||||
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
|
||||
[form],
|
||||
)
|
||||
const selectedProviderIsCloudflare = useMemo(
|
||||
() => isCloudflareProvider(selectedProvider.provider),
|
||||
[selectedProvider.provider],
|
||||
)
|
||||
const imageProviderIsCloudflare = useMemo(
|
||||
() => isCloudflareProvider(form?.ai_image_provider),
|
||||
[form?.ai_image_provider],
|
||||
)
|
||||
const mediaStorageProvider = useMemo(
|
||||
() => (form?.media_storage_provider?.trim().toLowerCase() === 'minio' ? 'minio' : 'r2'),
|
||||
[form?.media_storage_provider],
|
||||
)
|
||||
|
||||
if (loading || !form) {
|
||||
return (
|
||||
@@ -532,7 +618,7 @@ export function SiteSettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>AI 模块</CardTitle>
|
||||
<CardDescription>
|
||||
站内 AI 问答功能使用的提供方与检索控制参数。
|
||||
把文本问答和封面图生成拆成两套配置,检索参数仍在这里统一管理。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
@@ -551,182 +637,328 @@ export function SiteSettingsPage() {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Field label="提供方">
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">提供商列表</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
可以同时保存多套模型渠道配置,并指定当前实际生效的那一套。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={addAiProvider}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加提供商
|
||||
</Button>
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">文本问答 Provider</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
这里用于站内问答、文章元数据生成和文案润色。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={addAiProvider}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加提供商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{form.ai_providers.length ? (
|
||||
form.ai_providers.map((provider, index) => {
|
||||
const active = provider.id === form.ai_active_provider_id
|
||||
const selected = index === selectedProviderIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProviderIndex(index)}
|
||||
className={
|
||||
selected
|
||||
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
|
||||
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{provider.name?.trim() || `提供商 ${index + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
Provider:{provider.provider?.trim() || '未填写'}
|
||||
</p>
|
||||
</div>
|
||||
{active ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
当前启用
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{provider.chat_model?.trim() || '未填写模型'}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
|
||||
还没有配置任何模型提供商,先添加一套即可开始切换使用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{form.ai_providers.length ? (
|
||||
form.ai_providers.map((provider, index) => {
|
||||
const active = provider.id === form.ai_active_provider_id
|
||||
const selected = index === selectedProviderIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProviderIndex(index)}
|
||||
className={
|
||||
selected
|
||||
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
|
||||
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{provider.name?.trim() || `提供商 ${index + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{provider.provider?.trim() || '未填写 provider'}
|
||||
</p>
|
||||
</div>
|
||||
{active ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
当前启用
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{provider.chat_model?.trim() || '未填写模型'}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
|
||||
还没有配置任何模型提供商,先添加一套即可开始切换使用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
|
||||
{form.ai_providers.length ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
当前编辑
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
保存后,系统会使用“当前启用”的提供商处理站内 AI 请求。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testingProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setTestingProvider(true)
|
||||
const result = await adminApi.testAiProvider(selectedProvider)
|
||||
toast.success(
|
||||
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : '模型连通性测试失败。',
|
||||
)
|
||||
} finally {
|
||||
setTestingProvider(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{testingProvider ? '测试中...' : '测试连通性'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
|
||||
onClick={() => setActiveAiProvider(selectedProvider.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => removeAiProvider(selectedProviderIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
|
||||
{form.ai_providers.length ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
当前编辑
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
保存后,系统会使用“当前启用”的提供商处理文本 AI 请求。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => applyCloudflarePreset(selectedProviderIndex)}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
套用 Cloudflare
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testingProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setTestingProvider(true)
|
||||
const result = await adminApi.testAiProvider(selectedProvider)
|
||||
toast.success(
|
||||
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : '模型连通性测试失败。',
|
||||
)
|
||||
} finally {
|
||||
setTestingProvider(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{testingProvider ? '测试中...' : '测试连通性'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
|
||||
onClick={() => setActiveAiProvider(selectedProvider.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => removeAiProvider(selectedProviderIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
|
||||
<Input
|
||||
value={selectedProvider.name ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'name', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Provider 标识">
|
||||
<Input
|
||||
value={selectedProvider.provider ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
|
||||
}
|
||||
placeholder="newapi / openai-compatible / 其他兼容值"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API 地址">
|
||||
<Input
|
||||
value={selectedProvider.api_base ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API 密钥">
|
||||
<Input
|
||||
value={selectedProvider.api_key ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="对话模型">
|
||||
<Input
|
||||
value={selectedProvider.chat_model ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
|
||||
<Input
|
||||
value={selectedProvider.name ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'name', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Provider"
|
||||
hint="选择文本模型提供方。Cloudflare 文本模型也支持。"
|
||||
>
|
||||
<Select
|
||||
value={selectedProvider.provider ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
|
||||
}
|
||||
>
|
||||
{AI_PROVIDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
label="API 地址"
|
||||
hint={selectedProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID,或填写 https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>。' : undefined}
|
||||
>
|
||||
<Input
|
||||
value={selectedProvider.api_base ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
|
||||
}
|
||||
placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="API 密钥"
|
||||
hint={selectedProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。该 Token 需要 Workers AI Read 和 Edit 权限。' : undefined}
|
||||
>
|
||||
<Input
|
||||
value={selectedProvider.api_key ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="对话模型"
|
||||
hint={selectedProviderIsCloudflare ? '例如 @cf/meta/llama-3.1-8b-instruct,用于问答与连通性测试。' : undefined}
|
||||
>
|
||||
<Input
|
||||
value={selectedProvider.chat_model ?? ''}
|
||||
onChange={(event) =>
|
||||
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{selectedProviderIsCloudflare ? (
|
||||
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
|
||||
<p className="font-medium text-foreground">文本问答 / Cloudflare 说明</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>API 地址可直接填 Cloudflare Account ID。</li>
|
||||
<li>这里的模型只负责文本问答与后台文字类 AI 能力。</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
||||
当前生效:
|
||||
文本 AI 当前生效:
|
||||
{activeProvider
|
||||
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
|
||||
? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
|
||||
: '未选择提供商'}
|
||||
</div>
|
||||
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">图片生成(封面)</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
后台“AI 生成封面”单独走这一套配置,不再复用文本问答设置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={applyCloudflareImagePreset}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
套用 Cloudflare
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testingImageProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setTestingImageProvider(true)
|
||||
const result = await adminApi.testAiImageProvider({
|
||||
provider: form.ai_image_provider ?? '',
|
||||
api_base: form.ai_image_api_base,
|
||||
api_key: form.ai_image_api_key,
|
||||
image_model: form.ai_image_model,
|
||||
})
|
||||
toast.success(
|
||||
`图片连通成功:${result.provider} / ${result.image_model} / ${result.result_preview}`,
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : '图片模型连通性测试失败。',
|
||||
)
|
||||
} finally {
|
||||
setTestingImageProvider(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{testingImageProvider ? '测试中...' : '测试图片连通性'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<Field
|
||||
label="图片 Provider"
|
||||
hint="选择图片模型提供方。这里专门用于封面图生成。"
|
||||
>
|
||||
<Select
|
||||
value={form.ai_image_provider ?? ''}
|
||||
onChange={(event) => updateField('ai_image_provider', event.target.value)}
|
||||
>
|
||||
{AI_PROVIDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
label="图片模型"
|
||||
hint={imageProviderIsCloudflare ? 'Cloudflare 建议使用 @cf/black-forest-labs/flux-2-klein-4b。' : '这里填写图片模型名。'}
|
||||
>
|
||||
<Input
|
||||
value={form.ai_image_model ?? ''}
|
||||
onChange={(event) => updateField('ai_image_model', event.target.value)}
|
||||
placeholder={imageProviderIsCloudflare ? '@cf/black-forest-labs/flux-2-klein-4b' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field
|
||||
label="图片 API 地址"
|
||||
hint={imageProviderIsCloudflare ? 'Cloudflare 可直接填写 Account ID,或填写完整 accounts URL。' : '填写图片服务的 API 地址。'}
|
||||
>
|
||||
<Input
|
||||
value={form.ai_image_api_base ?? ''}
|
||||
onChange={(event) => updateField('ai_image_api_base', event.target.value)}
|
||||
placeholder={imageProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<Field
|
||||
label="图片 API 密钥"
|
||||
hint={imageProviderIsCloudflare ? '请填写 Cloudflare Workers AI API Token。' : '填写图片服务的 API Key。'}
|
||||
>
|
||||
<Input
|
||||
value={form.ai_image_api_key ?? ''}
|
||||
onChange={(event) => updateField('ai_image_api_key', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
||||
图片 AI 当前配置:
|
||||
{form.ai_image_provider?.trim()
|
||||
? `${form.ai_image_provider} / ${form.ai_image_model || '未填写模型'}`
|
||||
: '未填写,封面图会回退到旧配置'}
|
||||
</div>
|
||||
|
||||
{imageProviderIsCloudflare ? (
|
||||
<div className="mt-4 rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 text-sm leading-6 text-muted-foreground">
|
||||
<p className="font-medium text-foreground">封面图 / Cloudflare 说明</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>API 地址可直接填 Cloudflare Account ID。</li>
|
||||
<li>这套配置只用于后台“AI 生成封面”。</li>
|
||||
<li>文本问答和图片生成现在是两套独立设置。</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Field
|
||||
label="向量模型"
|
||||
hint={`本地选项:${form.ai_local_embedding}`}
|
||||
@@ -771,6 +1003,128 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle>媒体对象存储</CardTitle>
|
||||
<CardDescription>
|
||||
AI 封面图和评测封面上传都会优先走这里的对象存储。支持 Cloudflare R2 / MinIO。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testingR2Storage}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setTestingR2Storage(true)
|
||||
const result = await adminApi.testR2Storage()
|
||||
toast.success(`存储连通成功:${result.bucket} / ${result.public_base_url}`)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '对象存储连通性测试失败。')
|
||||
} finally {
|
||||
setTestingR2Storage(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{testingR2Storage ? '测试中...' : '测试存储连通性'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Field
|
||||
label="存储 Provider"
|
||||
hint="选择媒体资源存储后端。"
|
||||
>
|
||||
<Select
|
||||
value={form.media_storage_provider ?? 'r2'}
|
||||
onChange={(event) => updateField('media_storage_provider', event.target.value)}
|
||||
>
|
||||
{MEDIA_STORAGE_PROVIDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
label={mediaStorageProvider === 'minio' ? 'Endpoint' : 'Account ID'}
|
||||
hint={
|
||||
mediaStorageProvider === 'minio'
|
||||
? '例如 http://10.0.0.2:9100 或你的 MinIO API 地址。'
|
||||
: 'Cloudflare 账户 ID,用来拼接 R2 S3 兼容 endpoint。'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.media_r2_account_id ?? ''}
|
||||
onChange={(event) => updateField('media_r2_account_id', event.target.value)}
|
||||
placeholder={mediaStorageProvider === 'minio' ? 'http://10.0.0.2:9100' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Bucket"
|
||||
hint={mediaStorageProvider === 'minio' ? '存放封面图的 MinIO bucket 名称。' : '存放封面图的 R2 bucket 名称。'}
|
||||
>
|
||||
<Input
|
||||
value={form.media_r2_bucket ?? ''}
|
||||
onChange={(event) => updateField('media_r2_bucket', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field
|
||||
label="Public Base URL"
|
||||
hint={
|
||||
mediaStorageProvider === 'minio'
|
||||
? '例如 https://s3.init.cool/你的bucket 或 http://10.0.0.2:9100/你的bucket。系统会把对象 key 拼到这个地址后面。'
|
||||
: '例如 https://image.init.cool 或你的 R2 公网域名。系统会把对象 key 拼到这个地址后面。'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.media_r2_public_base_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('media_r2_public_base_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Access Key ID"
|
||||
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Access Key ID。' : 'R2 S3 API 的 Access Key ID。'}
|
||||
>
|
||||
<Input
|
||||
value={form.media_r2_access_key_id ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('media_r2_access_key_id', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Secret Access Key"
|
||||
hint={mediaStorageProvider === 'minio' ? 'MinIO / S3 的 Secret Access Key。后端会用 Rust SDK 上传图片。' : 'R2 S3 API 的 Secret Access Key。后端会用 Rust SDK 上传图片。'}
|
||||
>
|
||||
<Input
|
||||
value={form.media_r2_secret_access_key ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('media_r2_secret_access_key', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
|
||||
<p className="font-medium text-foreground">当前用途</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>文章 AI 生成封面:上传到 `post-covers/`</li>
|
||||
<li>评测封面上传:上传到 `review-covers/`</li>
|
||||
<li>{mediaStorageProvider === 'minio' ? '当前会按 MinIO / S3 兼容方式上传。' : '当前会按 Cloudflare R2 方式上传。'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>索引状态</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user