feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
style: enhance global CSS for better responsiveness of terminal chips and navigation pills test: remove inline subscription test and add maintenance mode access code test feat: implement media library picker dialog for selecting images from the media library feat: add media URL controls for uploading and managing media assets feat: add migration for music_enabled and maintenance_mode settings in site settings feat: implement maintenance mode functionality with access control feat: create maintenance page with access code input and error handling chore: add TypeScript declaration for QR code module
This commit is contained in:
@@ -86,10 +86,6 @@ const SiteSettingsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/site-settings-page')
|
||||
return { default: mod.SiteSettingsPage }
|
||||
})
|
||||
const AuditPage = lazy(async () => {
|
||||
const mod = await import('@/pages/audit-page')
|
||||
return { default: mod.AuditPage }
|
||||
})
|
||||
const SubscriptionsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/subscriptions-page')
|
||||
return { default: mod.SubscriptionsPage }
|
||||
@@ -401,14 +397,6 @@ function AppRoutes() {
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<AuditPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="reviews"
|
||||
element={
|
||||
|
||||
@@ -106,12 +106,6 @@ const primaryNav = [
|
||||
description: '异步任务 / 队列控制台',
|
||||
icon: Workflow,
|
||||
},
|
||||
{
|
||||
to: '/audit',
|
||||
label: '审计',
|
||||
description: '后台操作审计日志',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: '设置',
|
||||
|
||||
291
admin/src/components/media-library-picker-dialog.tsx
Normal file
291
admin/src/components/media-library-picker-dialog.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Image as ImageIcon, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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'
|
||||
|
||||
type MediaLibraryPickerDialogProps = {
|
||||
open: boolean
|
||||
selectedUrl?: string
|
||||
preferredPrefix?: string
|
||||
onClose: () => void
|
||||
onSelect: (item: AdminMediaObjectResponse) => void
|
||||
}
|
||||
|
||||
const DEFAULT_PREFIX_OPTIONS = [
|
||||
'all',
|
||||
'post-covers/',
|
||||
'review-covers/',
|
||||
'category-covers/',
|
||||
'tag-covers/',
|
||||
'site-assets/',
|
||||
'seo-assets/',
|
||||
'music-covers/',
|
||||
'friend-link-avatars/',
|
||||
'uploads/',
|
||||
] as const
|
||||
|
||||
function prefixLabel(value: string) {
|
||||
switch (value) {
|
||||
case 'all':
|
||||
return '全部目录'
|
||||
case 'post-covers/':
|
||||
return '文章封面'
|
||||
case 'review-covers/':
|
||||
return '评测封面'
|
||||
case 'category-covers/':
|
||||
return '分类封面'
|
||||
case 'tag-covers/':
|
||||
return '标签封面'
|
||||
case 'site-assets/':
|
||||
return '站点资源'
|
||||
case 'seo-assets/':
|
||||
return 'SEO 图片'
|
||||
case 'music-covers/':
|
||||
return '音乐封面'
|
||||
case 'friend-link-avatars/':
|
||||
return '友链头像'
|
||||
case 'uploads/':
|
||||
return '通用上传'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyImage(item: AdminMediaObjectResponse) {
|
||||
return /\.(png|jpe?g|webp|avif|gif|svg)$/i.test(item.key)
|
||||
}
|
||||
|
||||
export function MediaLibraryPickerDialog({
|
||||
open,
|
||||
selectedUrl,
|
||||
preferredPrefix,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: MediaLibraryPickerDialogProps) {
|
||||
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [prefixFilter, setPrefixFilter] = useState(preferredPrefix ?? 'all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const prefixOptions = useMemo(
|
||||
() => Array.from(new Set([preferredPrefix, ...DEFAULT_PREFIX_OPTIONS].filter(Boolean))) as string[],
|
||||
[preferredPrefix],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setPrefixFilter(preferredPrefix ?? 'all')
|
||||
setSearchTerm('')
|
||||
}, [open, preferredPrefix])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function loadItems() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await adminApi.listMediaObjects({
|
||||
prefix: prefixFilter === 'all' ? undefined : prefixFilter,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
if (!cancelled) {
|
||||
setItems(result.items)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof ApiError ? error.message : '加载媒体库失败。')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadItems()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, prefixFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
[
|
||||
item.key,
|
||||
item.title ?? '',
|
||||
item.alt_text ?? '',
|
||||
item.caption ?? '',
|
||||
...(item.tags ?? []),
|
||||
]
|
||||
.join('\n')
|
||||
.toLowerCase()
|
||||
.includes(keyword),
|
||||
)
|
||||
}, [items, searchTerm])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] bg-slate-950/70 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-[32px] border border-border/70 bg-background shadow-[0_40px_120px_rgba(15,23,42,0.45)]">
|
||||
<div className="flex flex-col gap-4 border-b border-border/70 bg-background/95 px-6 py-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">媒体库选择器</Badge>
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold tracking-tight">从已有媒体库选择图片</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
可按目录和关键字筛选;点击“使用这张”后会把媒体库 URL 回填到当前表单。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 border-b border-border/70 bg-background/80 px-6 py-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
|
||||
{prefixOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{prefixLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="按 key / 标题 / alt / 标签搜索"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-[260px] rounded-[28px]" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredItems.map((item) => {
|
||||
const isSelected = selectedUrl === item.url
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`overflow-hidden rounded-[28px] border bg-background/75 ${
|
||||
isSelected
|
||||
? 'border-primary/40 shadow-[0_16px_44px_rgba(37,99,235,0.16)]'
|
||||
: 'border-border/70'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[16/9] overflow-hidden border-b border-border/70 bg-muted/30">
|
||||
{isLikelyImage(item) ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.alt_text ?? item.title ?? item.key}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="line-clamp-1 text-sm font-medium">{item.title || item.key}</p>
|
||||
<p className="line-clamp-2 break-all text-xs text-muted-foreground">{item.key}</p>
|
||||
{item.tags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={`${item.key}-${tag}`} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prefixLabel(item.key.split('/')[0] ? `${item.key.split('/')[0]}/` : 'uploads/')}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
使用这张
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-3 rounded-[28px] border border-dashed border-border/70 bg-background/40 text-center text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有可选媒体。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
admin/src/components/media-url-controls.tsx
Normal file
256
admin/src/components/media-url-controls.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { CheckSquare, Download, Images, Square, Upload } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MediaLibraryPickerDialog } from '@/components/media-library-picker-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
prepareImageForUpload,
|
||||
type MediaUploadTargetFormat,
|
||||
} from '@/lib/image-compress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type RemoteTargetFormat = 'original' | 'webp' | 'avif'
|
||||
|
||||
type MediaUrlControlsProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
prefix: string
|
||||
contextLabel: string
|
||||
mode?: 'image' | 'cover'
|
||||
className?: string
|
||||
remoteTitle?: string | null
|
||||
accept?: string
|
||||
dataTestIdPrefix?: string
|
||||
}
|
||||
|
||||
function formatLabelForUploadTarget(value: MediaUploadTargetFormat) {
|
||||
switch (value) {
|
||||
case 'avif':
|
||||
return 'AVIF'
|
||||
case 'webp':
|
||||
return 'WebP'
|
||||
default:
|
||||
return '自动'
|
||||
}
|
||||
}
|
||||
|
||||
export function MediaUrlControls({
|
||||
value,
|
||||
onChange,
|
||||
prefix,
|
||||
contextLabel,
|
||||
mode = 'image',
|
||||
className,
|
||||
remoteTitle,
|
||||
accept = 'image/*',
|
||||
dataTestIdPrefix,
|
||||
}: MediaUrlControlsProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [downloadingRemote, setDownloadingRemote] = useState(false)
|
||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
|
||||
const [remoteUrl, setRemoteUrl] = useState('')
|
||||
const [remoteTargetFormat, setRemoteTargetFormat] = useState<RemoteTargetFormat>('original')
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
const quality = Number.parseFloat(compressQuality)
|
||||
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-border/70 bg-background/55 p-4', className)}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.item(0)
|
||||
event.currentTarget.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
const prepared = await prepareImageForUpload(file, {
|
||||
compress: compressBeforeUpload,
|
||||
quality: safeQuality,
|
||||
targetFormat: uploadTargetFormat,
|
||||
contextLabel: `${contextLabel}(${file.name})`,
|
||||
mode,
|
||||
})
|
||||
|
||||
if (prepared.preview) {
|
||||
toast.message(formatCompressionPreview(prepared.preview))
|
||||
}
|
||||
|
||||
const uploaded = await adminApi.uploadMediaObjects([prepared.file], { prefix })
|
||||
const url = uploaded.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但没有返回 URL')
|
||||
}
|
||||
|
||||
if (compressBeforeUpload && uploadTargetFormat !== 'auto') {
|
||||
const expectedMimeType =
|
||||
uploadTargetFormat === 'avif' ? 'image/avif' : 'image/webp'
|
||||
if (prepared.file.type !== expectedMimeType) {
|
||||
toast.warning(
|
||||
`当前环境无法直接导出 ${formatLabelForUploadTarget(uploadTargetFormat)},已回退为 ${prepared.file.type || '原格式'}。`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onChange(url)
|
||||
toast.success('已上传到媒体库,并回填 URL。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '上传到媒体库失败。')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={uploading}
|
||||
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-upload` : undefined}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploading ? '上传中...' : '上传到媒体库'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-library` : undefined}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Images className="h-4 w-4" />
|
||||
从媒体库选
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCompressBeforeUpload((current) => !current)}
|
||||
>
|
||||
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||
压缩上传
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={uploadTargetFormat}
|
||||
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
|
||||
disabled={!compressBeforeUpload}
|
||||
className="min-w-[180px]"
|
||||
>
|
||||
<option value="avif">压缩为 AVIF</option>
|
||||
<option value="webp">压缩为 WebP</option>
|
||||
<option value="auto">自动选择格式</option>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className="w-[92px]"
|
||||
value={compressQuality}
|
||||
onChange={(event) => setCompressQuality(event.target.value)}
|
||||
placeholder="0.82"
|
||||
disabled={!compressBeforeUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={remoteUrl}
|
||||
onChange={(event) => setRemoteUrl(event.target.value)}
|
||||
placeholder="https://example.com/cover.webp"
|
||||
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-url` : undefined}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<Select
|
||||
value={remoteTargetFormat}
|
||||
onChange={(event) => setRemoteTargetFormat(event.target.value as RemoteTargetFormat)}
|
||||
>
|
||||
<option value="original">按原格式抓取</option>
|
||||
<option value="webp">抓取后转 WebP</option>
|
||||
<option value="avif">抓取后转 AVIF</option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
disabled={!remoteUrl.trim() || downloadingRemote}
|
||||
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-download` : undefined}
|
||||
onClick={async () => {
|
||||
if (!remoteUrl.trim()) {
|
||||
toast.error('请先填写远程图片 URL。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDownloadingRemote(true)
|
||||
const result = await adminApi.downloadMediaObject({
|
||||
sourceUrl: remoteUrl.trim(),
|
||||
prefix,
|
||||
targetFormat: remoteTargetFormat,
|
||||
title: remoteTitle?.trim() || null,
|
||||
sync: true,
|
||||
})
|
||||
|
||||
if (!result.url) {
|
||||
throw new Error(result.job_id ? `远程抓取已入队:#${result.job_id}` : '远程抓取完成但未返回 URL')
|
||||
}
|
||||
|
||||
onChange(result.url)
|
||||
setRemoteUrl('')
|
||||
toast.success('远程素材已写入媒体库,并回填 URL。')
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: '远程抓取失败。',
|
||||
)
|
||||
} finally {
|
||||
setDownloadingRemote(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{downloadingRemote ? '抓取中...' : '抓取到媒体库'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
上传 / 抓取完成后会自动回填当前 URL;也可以直接在上面的输入框里手填外链。
|
||||
{value.trim() ? ' 当前已有值,可继续覆盖。' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MediaLibraryPickerDialog
|
||||
open={pickerOpen}
|
||||
selectedUrl={value}
|
||||
preferredPrefix={prefix}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(item) => {
|
||||
onChange(item.url)
|
||||
toast.success('已从媒体库选中并回填 URL。')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -139,12 +139,14 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const viewportPadding = 12
|
||||
const gutter = 6
|
||||
const minMenuWidth = 220
|
||||
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 maxAllowedWidth = window.innerWidth - viewportPadding * 2
|
||||
const width = Math.min(Math.max(rect.width, minMenuWidth), maxAllowedWidth)
|
||||
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
|
||||
|
||||
setMenuPlacement(openToTop ? 'top' : 'bottom')
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AdminMediaUploadResponse,
|
||||
AdminPostCoverImageRequest,
|
||||
AdminPostCoverImageResponse,
|
||||
AdminPostLocalizeImagesResponse,
|
||||
AdminDashboardResponse,
|
||||
AdminPostMetadataResponse,
|
||||
AdminPostPolishResponse,
|
||||
@@ -453,11 +454,14 @@ export const adminApi = {
|
||||
body: JSON.stringify({
|
||||
source_url: payload.sourceUrl,
|
||||
prefix: payload.prefix,
|
||||
target_format:
|
||||
payload.targetFormat && payload.targetFormat !== 'original' ? payload.targetFormat : null,
|
||||
title: payload.title,
|
||||
alt_text: payload.altText,
|
||||
caption: payload.caption,
|
||||
tags: payload.tags,
|
||||
notes: payload.notes,
|
||||
sync: payload.sync ?? false,
|
||||
}),
|
||||
}),
|
||||
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
|
||||
@@ -488,6 +492,14 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
localizePostMarkdownImages: (payload: { markdown: string; prefix?: string | null }) =>
|
||||
request<AdminPostLocalizeImagesResponse>('/api/admin/posts/localize-images', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
markdown: payload.markdown,
|
||||
prefix: payload.prefix,
|
||||
}),
|
||||
}),
|
||||
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
|
||||
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface CompressionResult {
|
||||
preview: CompressionPreview | null
|
||||
}
|
||||
|
||||
export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp'
|
||||
|
||||
interface ProcessedVariant {
|
||||
file: File
|
||||
preview: CompressionPreview
|
||||
}
|
||||
|
||||
interface ProcessImageOptions {
|
||||
quality: number
|
||||
maxWidth: number
|
||||
@@ -83,6 +90,427 @@ function deriveFileName(file: File, mimeType: string) {
|
||||
return `processed${extension}`
|
||||
}
|
||||
|
||||
function buildPreview(originalSize: number, compressedSize: number): CompressionPreview {
|
||||
const savedBytes = originalSize - compressedSize
|
||||
const savedRatio = originalSize > 0 ? savedBytes / originalSize : 0
|
||||
|
||||
return {
|
||||
originalSize,
|
||||
compressedSize,
|
||||
savedBytes,
|
||||
savedRatio,
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabelForMimeType(mimeType: string) {
|
||||
switch (mimeType) {
|
||||
case 'image/avif':
|
||||
return 'AVIF'
|
||||
case 'image/webp':
|
||||
return 'WebP'
|
||||
case 'image/png':
|
||||
return 'PNG'
|
||||
default:
|
||||
return 'JPEG'
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPreferredFormats(file: File, coverMode = false) {
|
||||
if (coverMode) {
|
||||
return ['image/avif', 'image/webp', 'image/jpeg']
|
||||
}
|
||||
|
||||
if (file.type === 'image/png') {
|
||||
return ['image/png', 'image/webp', 'image/jpeg']
|
||||
}
|
||||
|
||||
return ['image/webp', 'image/avif', 'image/jpeg']
|
||||
}
|
||||
|
||||
function preferredFormatsForTarget(file: File, targetFormat: MediaUploadTargetFormat, coverMode = false) {
|
||||
switch (targetFormat) {
|
||||
case 'avif':
|
||||
return ['image/avif', 'image/webp', 'image/jpeg']
|
||||
case 'webp':
|
||||
return ['image/webp', 'image/jpeg']
|
||||
default:
|
||||
return defaultPreferredFormats(file, coverMode)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildProcessedVariants(file: File, options: ProcessImageOptions): Promise<ProcessedVariant[]> {
|
||||
const variants: ProcessedVariant[] = []
|
||||
const requestedFormats = Array.from(new Set(options.preferredFormats))
|
||||
|
||||
for (const format of requestedFormats) {
|
||||
const processed = await processImage(file, {
|
||||
...options,
|
||||
preferredFormats: [format],
|
||||
})
|
||||
|
||||
if (processed.type !== format) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (variants.some((item) => item.file.type === processed.type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
variants.push({
|
||||
file: processed,
|
||||
preview: buildPreview(file.size, processed.size),
|
||||
})
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function ensureImageChoiceDialogStyles() {
|
||||
const styleId = 'termi-image-choice-dialog-style'
|
||||
if (document.getElementById(styleId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
.termi-image-choice-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.termi-image-choice-dialog {
|
||||
width: min(680px, 100%);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #0f172a;
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.termi-image-choice-header {
|
||||
padding: 20px 22px 12px;
|
||||
}
|
||||
|
||||
.termi-image-choice-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.termi-image-choice-description {
|
||||
margin: 8px 0 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.termi-image-choice-body {
|
||||
padding: 0 22px 22px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.termi-image-choice-note {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
color: #1d4ed8;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.termi-image-choice-option {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.termi-image-choice-option:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.termi-image-choice-option.is-selected {
|
||||
border-color: rgba(37, 99, 235, 0.52);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
}
|
||||
|
||||
.termi-image-choice-option-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.termi-image-choice-option-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.termi-image-choice-option-label input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.termi-image-choice-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.termi-image-choice-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.termi-image-choice-badge--recommended {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.termi-image-choice-badge--neutral {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.termi-image-choice-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.termi-image-choice-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 0 22px 22px;
|
||||
}
|
||||
|
||||
.termi-image-choice-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 11px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.termi-image-choice-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.termi-image-choice-button--ghost {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.termi-image-choice-button--primary {
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.26);
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
async function showImageChoiceDialog(options: {
|
||||
title: string
|
||||
description: string
|
||||
note?: string
|
||||
choices: Array<{
|
||||
id: string
|
||||
title: string
|
||||
meta: string[]
|
||||
badge?: string
|
||||
recommended?: boolean
|
||||
}>
|
||||
defaultChoiceId: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
}) {
|
||||
ensureImageChoiceDialogStyles()
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'termi-image-choice-overlay'
|
||||
|
||||
const dialog = document.createElement('div')
|
||||
dialog.className = 'termi-image-choice-dialog'
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
dialog.setAttribute('aria-modal', 'true')
|
||||
dialog.setAttribute('aria-label', options.title)
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'termi-image-choice-header'
|
||||
header.innerHTML = `
|
||||
<h3 class="termi-image-choice-title"></h3>
|
||||
<p class="termi-image-choice-description"></p>
|
||||
`
|
||||
const titleEl = header.querySelector('.termi-image-choice-title')
|
||||
const descriptionEl = header.querySelector('.termi-image-choice-description')
|
||||
if (titleEl) titleEl.textContent = options.title
|
||||
if (descriptionEl) descriptionEl.textContent = options.description
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.className = 'termi-image-choice-body'
|
||||
|
||||
if (options.note) {
|
||||
const note = document.createElement('div')
|
||||
note.className = 'termi-image-choice-note'
|
||||
note.textContent = options.note
|
||||
body.appendChild(note)
|
||||
}
|
||||
|
||||
let selectedChoiceId = options.defaultChoiceId
|
||||
const optionElements: HTMLElement[] = []
|
||||
|
||||
for (const choice of options.choices) {
|
||||
const option = document.createElement('label')
|
||||
option.className = 'termi-image-choice-option'
|
||||
option.dataset.choiceId = choice.id
|
||||
|
||||
const top = document.createElement('div')
|
||||
top.className = 'termi-image-choice-option-top'
|
||||
|
||||
const label = document.createElement('div')
|
||||
label.className = 'termi-image-choice-option-label'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'radio'
|
||||
input.name = 'termi-image-choice'
|
||||
input.value = choice.id
|
||||
input.checked = choice.id === selectedChoiceId
|
||||
|
||||
const text = document.createElement('span')
|
||||
text.textContent = choice.title
|
||||
|
||||
label.append(input, text)
|
||||
|
||||
const badges = document.createElement('div')
|
||||
badges.className = 'termi-image-choice-badges'
|
||||
if (choice.recommended) {
|
||||
const recommended = document.createElement('span')
|
||||
recommended.className = 'termi-image-choice-badge termi-image-choice-badge--recommended'
|
||||
recommended.textContent = '推荐'
|
||||
badges.appendChild(recommended)
|
||||
}
|
||||
if (choice.badge) {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'termi-image-choice-badge termi-image-choice-badge--neutral'
|
||||
badge.textContent = choice.badge
|
||||
badges.appendChild(badge)
|
||||
}
|
||||
|
||||
top.append(label, badges)
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'termi-image-choice-meta'
|
||||
for (const line of choice.meta) {
|
||||
const item = document.createElement('div')
|
||||
item.textContent = line
|
||||
meta.appendChild(item)
|
||||
}
|
||||
|
||||
option.append(top, meta)
|
||||
option.addEventListener('click', () => {
|
||||
selectedChoiceId = choice.id
|
||||
optionElements.forEach((element) => {
|
||||
const checked = element.dataset.choiceId === selectedChoiceId
|
||||
element.classList.toggle('is-selected', checked)
|
||||
const radio = element.querySelector('input[type="radio"]') as HTMLInputElement | null
|
||||
if (radio) {
|
||||
radio.checked = checked
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
option.classList.toggle('is-selected', choice.id === selectedChoiceId)
|
||||
optionElements.push(option)
|
||||
body.appendChild(option)
|
||||
}
|
||||
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'termi-image-choice-actions'
|
||||
|
||||
const cancelButton = document.createElement('button')
|
||||
cancelButton.type = 'button'
|
||||
cancelButton.className = 'termi-image-choice-button termi-image-choice-button--ghost'
|
||||
cancelButton.textContent = options.cancelLabel ?? '保留原图'
|
||||
|
||||
const confirmButton = document.createElement('button')
|
||||
confirmButton.type = 'button'
|
||||
confirmButton.className = 'termi-image-choice-button termi-image-choice-button--primary'
|
||||
confirmButton.textContent = options.confirmLabel ?? '使用所选版本'
|
||||
|
||||
const cleanup = () => {
|
||||
overlay.remove()
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
cleanup()
|
||||
resolve('original')
|
||||
}
|
||||
}
|
||||
|
||||
cancelButton.addEventListener('click', () => {
|
||||
cleanup()
|
||||
resolve('original')
|
||||
})
|
||||
|
||||
confirmButton.addEventListener('click', () => {
|
||||
cleanup()
|
||||
resolve(selectedChoiceId)
|
||||
})
|
||||
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) {
|
||||
cleanup()
|
||||
resolve('original')
|
||||
}
|
||||
})
|
||||
|
||||
actions.append(cancelButton, confirmButton)
|
||||
dialog.append(header, body, actions)
|
||||
overlay.appendChild(dialog)
|
||||
document.body.appendChild(overlay)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
const defaultInput = overlay.querySelector(
|
||||
`input[value="${CSS.escape(options.defaultChoiceId)}"]`,
|
||||
) as HTMLInputElement | null
|
||||
defaultInput?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function processImage(file: File, options: ProcessImageOptions): Promise<File> {
|
||||
if (!canTransformWithCanvas(file)) {
|
||||
return file
|
||||
@@ -161,33 +589,29 @@ async function maybeProcessImageWithPrompt(
|
||||
const contextLabel = options?.contextLabel ?? '图片上传'
|
||||
const forceProcessed = options?.forceProcessed ?? false
|
||||
|
||||
const processOptions: ProcessImageOptions = {
|
||||
quality,
|
||||
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
|
||||
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
|
||||
preferredFormats:
|
||||
options?.preferredFormats && options.preferredFormats.length
|
||||
? options.preferredFormats
|
||||
: file.type === 'image/png'
|
||||
? ['image/png', 'image/webp', 'image/jpeg']
|
||||
: ['image/webp', 'image/avif', 'image/jpeg'],
|
||||
coverWidth: options?.coverWidth,
|
||||
coverHeight: options?.coverHeight,
|
||||
}
|
||||
|
||||
let processed: File
|
||||
try {
|
||||
processed = await processImage(file, {
|
||||
quality,
|
||||
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
|
||||
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
|
||||
preferredFormats:
|
||||
options?.preferredFormats && options.preferredFormats.length
|
||||
? options.preferredFormats
|
||||
: file.type === 'image/png'
|
||||
? ['image/png', 'image/webp', 'image/jpeg']
|
||||
: ['image/webp', 'image/avif', 'image/jpeg'],
|
||||
coverWidth: options?.coverWidth,
|
||||
coverHeight: options?.coverHeight,
|
||||
})
|
||||
processed = await processImage(file, processOptions)
|
||||
} catch {
|
||||
return { file, usedCompressed: false, preview: null }
|
||||
}
|
||||
|
||||
const savedBytes = file.size - processed.size
|
||||
const savedRatio = file.size > 0 ? savedBytes / file.size : 0
|
||||
const preview: CompressionPreview = {
|
||||
originalSize: file.size,
|
||||
compressedSize: processed.size,
|
||||
savedBytes,
|
||||
savedRatio,
|
||||
}
|
||||
const preview = buildPreview(file.size, processed.size)
|
||||
const { savedRatio } = preview
|
||||
|
||||
if (!forceProcessed && processed.size >= file.size) {
|
||||
return { file, usedCompressed: false, preview }
|
||||
@@ -201,30 +625,80 @@ async function maybeProcessImageWithPrompt(
|
||||
return { file: processed, usedCompressed: true, preview }
|
||||
}
|
||||
|
||||
const deltaText =
|
||||
savedBytes >= 0
|
||||
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
|
||||
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
|
||||
let variants: ProcessedVariant[]
|
||||
try {
|
||||
variants = await buildProcessedVariants(file, processOptions)
|
||||
} catch {
|
||||
variants = [
|
||||
{
|
||||
file: processed,
|
||||
preview,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const intro = forceProcessed
|
||||
? `${contextLabel}: 已生成规范化版本。`
|
||||
: `${contextLabel}: 检测到可压缩空间。`
|
||||
const selectableVariants = forceProcessed
|
||||
? variants
|
||||
: variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio)
|
||||
|
||||
const useProcessed = window.confirm(
|
||||
[
|
||||
intro,
|
||||
`原始: ${formatBytes(file.size)}`,
|
||||
`处理后: ${formatBytes(processed.size)}`,
|
||||
deltaText,
|
||||
'',
|
||||
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
|
||||
].join('\n'),
|
||||
if (!selectableVariants.length) {
|
||||
return { file, usedCompressed: false, preview }
|
||||
}
|
||||
|
||||
const recommendedVariant = selectableVariants[0]
|
||||
const missingPreferredFormats = processOptions.preferredFormats.filter(
|
||||
(format) => !variants.some((item) => item.file.type === format),
|
||||
)
|
||||
const note =
|
||||
missingPreferredFormats.length > 0
|
||||
? `当前环境未提供 ${missingPreferredFormats.map(formatLabelForMimeType).join(' / ')} 编码能力,因此这里只展示可实际生成的格式。`
|
||||
: undefined
|
||||
|
||||
const choice = await showImageChoiceDialog({
|
||||
title: forceProcessed ? `${contextLabel}:已生成规范化版本` : `${contextLabel}:检测到可压缩空间`,
|
||||
description: forceProcessed
|
||||
? '可以直接保留原图,也可以选择更适合上传的规范化版本。'
|
||||
: '可以直接保留原图,也可以选择体积更合适的版本再上传。',
|
||||
note,
|
||||
choices: [
|
||||
{
|
||||
id: 'original',
|
||||
title: `保留原图(${file.name})`,
|
||||
meta: [
|
||||
`当前文件: ${formatBytes(file.size)}`,
|
||||
`格式: ${formatLabelForMimeType(file.type || 'image/jpeg')}`,
|
||||
],
|
||||
badge: '原图',
|
||||
},
|
||||
...selectableVariants.map((item, index) => {
|
||||
const variantSavedBytes = item.preview.savedBytes
|
||||
const variantSavedRatio = item.preview.savedRatio
|
||||
return {
|
||||
id: item.file.type,
|
||||
title: `${formatLabelForMimeType(item.file.type)} 版本`,
|
||||
meta: [
|
||||
`处理后: ${formatBytes(item.file.size)}`,
|
||||
variantSavedBytes >= 0
|
||||
? `节省: ${formatBytes(variantSavedBytes)} (${(variantSavedRatio * 100).toFixed(1)}%)`
|
||||
: `体积增加: ${formatBytes(Math.abs(variantSavedBytes))} (${Math.abs(variantSavedRatio * 100).toFixed(1)}%)`,
|
||||
],
|
||||
badge: item.file.name.replace(/^.*(\.[A-Za-z0-9]+)$/, '$1').toLowerCase(),
|
||||
recommended: index === 0,
|
||||
}
|
||||
}),
|
||||
],
|
||||
defaultChoiceId: recommendedVariant.file.type,
|
||||
confirmLabel: '使用所选版本',
|
||||
cancelLabel: '保留原图',
|
||||
})
|
||||
|
||||
const selectedVariant = selectableVariants.find((item) => item.file.type === choice)
|
||||
const useProcessed = Boolean(selectedVariant)
|
||||
|
||||
return {
|
||||
file: useProcessed ? processed : file,
|
||||
file: selectedVariant?.file ?? file,
|
||||
usedCompressed: useProcessed,
|
||||
preview,
|
||||
preview: selectedVariant?.preview ?? preview,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +709,7 @@ export async function maybeCompressImageWithPrompt(
|
||||
ask?: boolean
|
||||
minSavingsRatio?: number
|
||||
contextLabel?: string
|
||||
preferredFormats?: string[]
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
return maybeProcessImageWithPrompt(file, options)
|
||||
@@ -248,13 +723,14 @@ export async function normalizeCoverImageWithPrompt(
|
||||
contextLabel?: string
|
||||
width?: number
|
||||
height?: number
|
||||
preferredFormats?: string[]
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
return maybeProcessImageWithPrompt(file, {
|
||||
quality: options?.quality ?? 0.82,
|
||||
ask: options?.ask ?? true,
|
||||
contextLabel: options?.contextLabel ?? '封面图规范化',
|
||||
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'],
|
||||
preferredFormats: options?.preferredFormats ?? ['image/avif', 'image/webp', 'image/jpeg'],
|
||||
coverWidth: Math.max(options?.width ?? 1600, 640),
|
||||
coverHeight: Math.max(options?.height ?? 900, 360),
|
||||
forceProcessed: true,
|
||||
@@ -262,6 +738,42 @@ export async function normalizeCoverImageWithPrompt(
|
||||
})
|
||||
}
|
||||
|
||||
export async function prepareImageForUpload(
|
||||
file: File,
|
||||
options?: {
|
||||
compress?: boolean
|
||||
quality?: number
|
||||
targetFormat?: MediaUploadTargetFormat
|
||||
contextLabel?: string
|
||||
mode?: 'image' | 'cover'
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
const compress = options?.compress ?? true
|
||||
if (!compress) {
|
||||
return { file, usedCompressed: false, preview: null }
|
||||
}
|
||||
|
||||
const targetFormat = options?.targetFormat ?? 'auto'
|
||||
const mode = options?.mode ?? 'image'
|
||||
const preferredFormats = preferredFormatsForTarget(file, targetFormat, mode === 'cover')
|
||||
|
||||
if (mode === 'cover') {
|
||||
return normalizeCoverImageWithPrompt(file, {
|
||||
quality: options?.quality ?? 0.82,
|
||||
ask: false,
|
||||
contextLabel: options?.contextLabel ?? '封面图规范化上传',
|
||||
preferredFormats,
|
||||
})
|
||||
}
|
||||
|
||||
return maybeCompressImageWithPrompt(file, {
|
||||
quality: options?.quality ?? 0.82,
|
||||
ask: false,
|
||||
contextLabel: options?.contextLabel ?? '媒体上传',
|
||||
preferredFormats,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCompressionPreview(preview: CompressionPreview | null) {
|
||||
if (!preview) {
|
||||
return ''
|
||||
|
||||
@@ -374,6 +374,9 @@ export interface AdminSiteSettingsResponse {
|
||||
location: string | null
|
||||
tech_stack: string[]
|
||||
music_playlist: MusicTrack[]
|
||||
music_enabled: boolean
|
||||
maintenance_mode_enabled: boolean
|
||||
maintenance_access_code: string | null
|
||||
ai_enabled: boolean
|
||||
paragraph_comments_enabled: boolean
|
||||
comment_verification_mode: HumanVerificationMode
|
||||
@@ -451,6 +454,9 @@ export interface SiteSettingsPayload {
|
||||
location?: string | null
|
||||
techStack?: string[]
|
||||
musicPlaylist?: MusicTrack[]
|
||||
musicEnabled?: boolean
|
||||
maintenanceModeEnabled?: boolean
|
||||
maintenanceAccessCode?: string | null
|
||||
aiEnabled?: boolean
|
||||
paragraphCommentsEnabled?: boolean
|
||||
commentVerificationMode?: HumanVerificationMode | null
|
||||
@@ -613,17 +619,23 @@ export interface AdminMediaReplaceResponse {
|
||||
export interface MediaDownloadPayload {
|
||||
sourceUrl: string
|
||||
prefix?: string | null
|
||||
targetFormat?: 'original' | 'webp' | 'avif' | null
|
||||
title?: string | null
|
||||
altText?: string | null
|
||||
caption?: string | null
|
||||
tags?: string[]
|
||||
notes?: string | null
|
||||
sync?: boolean
|
||||
}
|
||||
|
||||
export interface AdminMediaDownloadResponse {
|
||||
queued: boolean
|
||||
job_id: number
|
||||
status: string
|
||||
job_id: number | null
|
||||
status: string | null
|
||||
key: string | null
|
||||
url: string | null
|
||||
size_bytes: number | null
|
||||
content_type: string | null
|
||||
}
|
||||
|
||||
export interface MediaAssetMetadataPayload {
|
||||
@@ -754,6 +766,27 @@ export interface AdminPostPolishResponse {
|
||||
polished_markdown: string
|
||||
}
|
||||
|
||||
export interface AdminPostLocalizeImagesFailure {
|
||||
source_url: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface AdminPostLocalizedImageItem {
|
||||
source_url: string
|
||||
localized_url: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface AdminPostLocalizeImagesResponse {
|
||||
markdown: string
|
||||
detected_count: number
|
||||
localized_count: number
|
||||
uploaded_count: number
|
||||
failed_count: number
|
||||
items: AdminPostLocalizedImageItem[]
|
||||
failures: AdminPostLocalizeImagesFailure[]
|
||||
}
|
||||
|
||||
export interface AdminReviewPolishRequest {
|
||||
title: string
|
||||
reviewType: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -302,14 +303,26 @@ export function CategoriesPage() {
|
||||
placeholder="frontend-engineering"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="封面图 URL" hint="可选,用于前台分类头图。">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/frontend.jpg"
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/frontend.jpg"
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.coverImage}
|
||||
onChange={(coverImage) =>
|
||||
setForm((current) => ({ ...current, coverImage }))
|
||||
}
|
||||
prefix="category-covers/"
|
||||
contextLabel="分类封面上传"
|
||||
remoteTitle={form.name || form.slug || '分类封面'}
|
||||
dataTestIdPrefix="category-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -378,13 +379,25 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="头像 URL">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.avatarUrl}
|
||||
onChange={(avatarUrl) =>
|
||||
setForm((current) => ({ ...current, avatarUrl }))
|
||||
}
|
||||
prefix="friend-link-avatars/"
|
||||
contextLabel="友链头像上传"
|
||||
remoteTitle={form.siteName || form.siteUrl || '友链头像'}
|
||||
dataTestIdPrefix="friend-link-avatar"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="分类">
|
||||
<Input
|
||||
|
||||
@@ -23,8 +23,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
maybeCompressImageWithPrompt,
|
||||
normalizeCoverImageWithPrompt,
|
||||
prepareImageForUpload,
|
||||
type MediaUploadTargetFormat,
|
||||
} from '@/lib/image-compress'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -141,9 +141,11 @@ export function MediaPage() {
|
||||
const [metadataSaving, setMetadataSaving] = useState(false)
|
||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
|
||||
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
|
||||
defaultRemoteDownloadForm,
|
||||
)
|
||||
const [remoteTargetFormat, setRemoteTargetFormat] = useState<'original' | 'webp' | 'avif'>('original')
|
||||
const [downloadingRemote, setDownloadingRemote] = useState(false)
|
||||
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
|
||||
|
||||
@@ -218,22 +220,18 @@ export function MediaPage() {
|
||||
|
||||
const quality = Number.parseFloat(compressQuality)
|
||||
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
|
||||
const normalizeCover =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
|
||||
const mode =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image'
|
||||
|
||||
const result: File[] = []
|
||||
for (const file of files) {
|
||||
const compressed = normalizeCover
|
||||
? await normalizeCoverImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `封面规范化上传(${file.name})`,
|
||||
})
|
||||
: await maybeCompressImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `媒体库上传(${file.name})`,
|
||||
})
|
||||
const compressed = await prepareImageForUpload(file, {
|
||||
compress: true,
|
||||
quality: safeQuality,
|
||||
targetFormat: uploadTargetFormat,
|
||||
contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}(${file.name})`,
|
||||
mode,
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
|
||||
}
|
||||
@@ -304,11 +302,23 @@ export function MediaPage() {
|
||||
<option value="all">全部目录</option>
|
||||
<option value="post-covers/">文章封面</option>
|
||||
<option value="review-covers/">评测封面</option>
|
||||
<option value="category-covers/">分类封面</option>
|
||||
<option value="tag-covers/">标签封面</option>
|
||||
<option value="site-assets/">站点资源</option>
|
||||
<option value="seo-assets/">SEO 图片</option>
|
||||
<option value="music-covers/">音乐封面</option>
|
||||
<option value="friend-link-avatars/">友链头像</option>
|
||||
<option value="uploads/">通用上传</option>
|
||||
</Select>
|
||||
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
|
||||
<option value="post-covers/">上传到文章封面</option>
|
||||
<option value="review-covers/">上传到评测封面</option>
|
||||
<option value="category-covers/">上传到分类封面</option>
|
||||
<option value="tag-covers/">上传到标签封面</option>
|
||||
<option value="site-assets/">上传到站点资源</option>
|
||||
<option value="seo-assets/">上传到 SEO 图片</option>
|
||||
<option value="music-covers/">上传到音乐封面</option>
|
||||
<option value="friend-link-avatars/">上传到友链头像</option>
|
||||
<option value="uploads/">上传到通用目录</option>
|
||||
</Select>
|
||||
<Input
|
||||
@@ -319,7 +329,7 @@ export function MediaPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_180px_96px_auto]">
|
||||
<Input
|
||||
data-testid="media-upload-input"
|
||||
type="file"
|
||||
@@ -338,6 +348,15 @@ export function MediaPage() {
|
||||
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||
压缩上传
|
||||
</Button>
|
||||
<Select
|
||||
value={uploadTargetFormat}
|
||||
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
|
||||
disabled={!compressBeforeUpload}
|
||||
>
|
||||
<option value="avif">压缩为 AVIF</option>
|
||||
<option value="webp">压缩为 WebP</option>
|
||||
<option value="auto">自动选择格式</option>
|
||||
</Select>
|
||||
<Input
|
||||
className="w-[96px]"
|
||||
value={compressQuality}
|
||||
@@ -373,7 +392,7 @@ export function MediaPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {uploadFiles.length} 个文件。
|
||||
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
|
||||
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
|
||||
? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -401,6 +420,19 @@ export function MediaPage() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="抓取格式">
|
||||
<Select
|
||||
value={remoteTargetFormat}
|
||||
onChange={(event) =>
|
||||
setRemoteTargetFormat(event.target.value as 'original' | 'webp' | 'avif')
|
||||
}
|
||||
>
|
||||
<option value="original">按原格式抓取</option>
|
||||
<option value="webp">抓取后转 WebP</option>
|
||||
<option value="avif">抓取后转 AVIF</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="media-remote-title"
|
||||
@@ -489,6 +521,7 @@ export function MediaPage() {
|
||||
const result = await adminApi.downloadMediaObject({
|
||||
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
|
||||
prefix: uploadPrefix,
|
||||
targetFormat: remoteTargetFormat,
|
||||
title: remoteDownloadForm.title.trim() || null,
|
||||
altText: remoteDownloadForm.altText.trim() || null,
|
||||
caption: remoteDownloadForm.caption.trim() || null,
|
||||
@@ -496,7 +529,11 @@ export function MediaPage() {
|
||||
notes: remoteDownloadForm.notes.trim() || null,
|
||||
})
|
||||
setLastRemoteDownloadJobId(result.job_id)
|
||||
toast.success(`远程抓取任务已入队:#${result.job_id}`)
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `远程抓取任务已入队:#${result.job_id}`
|
||||
: '远程抓取请求已提交。',
|
||||
)
|
||||
setRemoteDownloadForm(defaultRemoteDownloadForm)
|
||||
window.setTimeout(() => {
|
||||
void loadItems(false)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -24,6 +23,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { LazyDiffEditor } from '@/components/lazy-monaco'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import {
|
||||
MarkdownWorkbench,
|
||||
@@ -49,10 +49,6 @@ import {
|
||||
formatPostVisibility,
|
||||
postTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
|
||||
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
|
||||
@@ -259,6 +255,17 @@ function buildVirtualPostPath(slug: string) {
|
||||
return `article://posts/${normalizedSlug}`
|
||||
}
|
||||
|
||||
function buildInlineImagePrefix(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
|
||||
return `post-inline-images/${normalized || 'draft'}`
|
||||
}
|
||||
|
||||
function parseImageList(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
@@ -808,8 +815,6 @@ export function PostsPage() {
|
||||
const { slug } = useParams()
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [posts, setPosts] = useState<PostRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -823,8 +828,8 @@ export function PostsPage() {
|
||||
useState(false)
|
||||
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
|
||||
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
|
||||
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
|
||||
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
|
||||
const [localizingEditorImages, setLocalizingEditorImages] = useState(false)
|
||||
const [localizingCreateImages, setLocalizingCreateImages] = useState(false)
|
||||
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
|
||||
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
|
||||
const [editor, setEditor] = useState<PostFormState | null>(null)
|
||||
@@ -1457,67 +1462,89 @@ export function PostsPage() {
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const uploadEditorCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingEditorCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '文章封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
const localizeEditorMarkdownImages = useCallback(async () => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
const sourceMarkdown = buildDraftMarkdownForWindow(editor)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalizingEditorImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(editor.slug),
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
|
||||
if (!result.localized_count && !result.failed_count) {
|
||||
toast.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setEditor((current) => (current ? { ...current, image: url } : current))
|
||||
setEditor((current) =>
|
||||
current ? applyPolishedEditorState(current, result.markdown) : current,
|
||||
)
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setUploadingEditorCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const uploadCreateCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCreateCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '新建封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
if (result.localized_count && result.failed_count) {
|
||||
toast.warning(
|
||||
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
|
||||
)
|
||||
} else if (result.localized_count) {
|
||||
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
|
||||
} else {
|
||||
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
} finally {
|
||||
setLocalizingEditorImages(false)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
const localizeCreateMarkdownImages = useCallback(async () => {
|
||||
const sourceMarkdown = buildCreateMarkdownForWindow(createForm)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalizingCreateImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(createForm.slug || createForm.title),
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
|
||||
if (!result.localized_count && !result.failed_count) {
|
||||
toast.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCreateForm((current) => ({ ...current, image: url }))
|
||||
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
|
||||
if (result.localized_count && result.failed_count) {
|
||||
toast.warning(
|
||||
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
|
||||
)
|
||||
} else if (result.localized_count) {
|
||||
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
|
||||
} else {
|
||||
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
} finally {
|
||||
setUploadingCreateCover(false)
|
||||
setLocalizingCreateImages(false)
|
||||
}
|
||||
}, [])
|
||||
}, [createForm])
|
||||
|
||||
const openEditorPreviewWindow = useCallback(() => {
|
||||
const snapshot = buildEditorDraftSnapshot()
|
||||
@@ -2087,32 +2114,6 @@ export function PostsPage() {
|
||||
void importMarkdownFiles(event.target.files)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={editorCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadEditorCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={createCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadCreateCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
@@ -2526,29 +2527,34 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="封面图 URL">
|
||||
<Input
|
||||
value={editor.image}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, image: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={editor.image}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, image: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={editor.image}
|
||||
onChange={(image) =>
|
||||
setEditor((current) => (current ? { ...current, image } : current))
|
||||
}
|
||||
prefix="post-covers/"
|
||||
contextLabel="文章封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={editor.title || editor.slug || '文章封面'}
|
||||
dataTestIdPrefix="post-editor-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => editorCoverInputRef.current?.click()}
|
||||
disabled={uploadingEditorCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingEditorCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateEditorCover()}
|
||||
disabled={generatingEditorCover || uploadingEditorCover}
|
||||
disabled={generatingEditorCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingEditorCover
|
||||
@@ -2703,6 +2709,14 @@ export function PostsPage() {
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void localizeEditorMarkdownImages()}
|
||||
disabled={saving || localizingEditorImages}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{localizingEditorImages ? '本地化中...' : '正文图本地化'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -2994,27 +3008,32 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="封面图 URL">
|
||||
<Input
|
||||
value={createForm.image}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, image: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={createForm.image}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, image: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={createForm.image}
|
||||
onChange={(image) =>
|
||||
setCreateForm((current) => ({ ...current, image }))
|
||||
}
|
||||
prefix="post-covers/"
|
||||
contextLabel="新建文章封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={createForm.title || createForm.slug || '文章封面'}
|
||||
dataTestIdPrefix="post-create-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => createCoverInputRef.current?.click()}
|
||||
disabled={uploadingCreateCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCreateCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateCreateCover()}
|
||||
disabled={generatingCreateCover || uploadingCreateCover}
|
||||
disabled={generatingCreateCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingCreateCover
|
||||
@@ -3150,6 +3169,14 @@ export function PostsPage() {
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void localizeCreateMarkdownImages()}
|
||||
disabled={creating || localizingCreateImages}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{localizingCreateImages ? '本地化中...' : '正文图本地化'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -18,10 +19,6 @@ import {
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
@@ -103,14 +100,12 @@ 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 {
|
||||
@@ -217,29 +212,6 @@ export function ReviewsPage() {
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const uploadReviewCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '评测封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
const result = await adminApi.uploadReviewCoverImage(compressed.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">
|
||||
@@ -513,36 +485,21 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
<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/avif,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>
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.cover}
|
||||
onChange={(cover) => setForm((current) => ({ ...current, cover }))}
|
||||
prefix="review-covers/"
|
||||
contextLabel="评测封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={form.title || '评测封面'}
|
||||
dataTestIdPrefix="review-cover"
|
||||
/>
|
||||
|
||||
{form.cover ? (
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -132,6 +133,9 @@ function normalizeSettingsResponse(
|
||||
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
|
||||
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
|
||||
web_push_vapid_subject: input.web_push_vapid_subject ?? null,
|
||||
music_enabled: input.music_enabled ?? true,
|
||||
maintenance_mode_enabled: input.maintenance_mode_enabled ?? false,
|
||||
maintenance_access_code: input.maintenance_access_code ?? null,
|
||||
ai_active_provider_id:
|
||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||
}
|
||||
@@ -177,6 +181,9 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
location: form.location,
|
||||
techStack: form.tech_stack,
|
||||
musicPlaylist: form.music_playlist,
|
||||
musicEnabled: form.music_enabled,
|
||||
maintenanceModeEnabled: form.maintenance_mode_enabled,
|
||||
maintenanceAccessCode: form.maintenance_access_code,
|
||||
aiEnabled: form.ai_enabled,
|
||||
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
||||
commentVerificationMode: form.comment_verification_mode,
|
||||
@@ -514,6 +521,11 @@ export function SiteSettingsPage() {
|
||||
disabled={saving}
|
||||
data-testid="site-settings-save"
|
||||
onClick={async () => {
|
||||
if (form.maintenance_mode_enabled && !form.maintenance_access_code?.trim()) {
|
||||
toast.error('开启维护模式前请先填写访问口令。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const updated = await adminApi.updateSiteSettings(toPayload(form))
|
||||
@@ -607,11 +619,21 @@ export function SiteSettingsPage() {
|
||||
onChange={(event) => updateField('owner_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="头像 URL">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
<Field label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(ownerAvatarUrl) => updateField('owner_avatar_url', ownerAvatarUrl)}
|
||||
prefix="site-assets/"
|
||||
contextLabel="站长头像上传"
|
||||
remoteTitle={form.owner_name || form.site_name || '站长头像'}
|
||||
dataTestIdPrefix="site-owner-avatar"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="站长简介">
|
||||
@@ -765,6 +787,55 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>维护模式</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,前台访问者需要先输入口令才能看到内容,适合开发联调、灰度预览或上线前封站检查。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.maintenance_mode_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_mode_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">开启前台维护模式</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
开启后,访问首页、文章页、分类页等前台内容都会先进入维护页;只有输入正确口令后才会放行。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field
|
||||
label="访问口令"
|
||||
hint="建议设置成临时口令后发给测试同事;修改口令后,旧口令拿到的访问凭证会自动失效。"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.maintenance_access_code ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_access_code', event.target.value)
|
||||
}
|
||||
placeholder="例如:staging-2026"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Badge variant={form.maintenance_mode_enabled ? 'warning' : 'outline'}>
|
||||
{form.maintenance_mode_enabled ? '维护中' : '正常开放'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>运行时安全 / 推送配置</CardTitle>
|
||||
@@ -844,11 +915,23 @@ export function SiteSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退,也支持上传 / 抓取 / 选择媒体库。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(seoDefaultOgImage) =>
|
||||
updateField('seo_default_og_image', seoDefaultOgImage)
|
||||
}
|
||||
prefix="seo-assets/"
|
||||
contextLabel="默认 OG 图上传"
|
||||
remoteTitle={form.site_name || form.site_title || '默认 OG 图'}
|
||||
dataTestIdPrefix="site-default-og"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Twitter / X Handle" hint="例如 @initcool。">
|
||||
<Input
|
||||
@@ -1565,13 +1648,33 @@ export function SiteSettingsPage() {
|
||||
<div>
|
||||
<CardTitle>音乐侧栏</CardTitle>
|
||||
<CardDescription>
|
||||
把头部播放器的曲目清单和单曲属性放到独立侧边栏里维护。
|
||||
可以直接控制前台是否显示音乐播放器,歌单配置会继续保留在后台。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={form.music_enabled ? 'default' : 'outline'}>
|
||||
{form.music_enabled ? '前台已开启' : '前台已关闭'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.music_enabled}
|
||||
onChange={(event) => updateField('music_enabled', event.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">前台显示音乐播放器</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
关闭后前台头部和移动菜单里的音乐模块会整体隐藏,但下面维护的歌单内容不会丢失。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
{form.music_playlist.map((track, index) => {
|
||||
const active = index === selectedTrackIndex
|
||||
@@ -1687,13 +1790,25 @@ export function SiteSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="封面图 URL">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
<Field label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(coverImageUrl) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', coverImageUrl)
|
||||
}
|
||||
prefix="music-covers/"
|
||||
contextLabel="音乐封面上传"
|
||||
remoteTitle={selectedTrack.title || `曲目 ${selectedTrackIndex + 1} 封面`}
|
||||
dataTestIdPrefix="site-music-cover"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -19,9 +19,12 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatBrowserName, formatDateTime } from '@/lib/admin-format'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
@@ -72,6 +75,127 @@ function normalizePreview(value: unknown) {
|
||||
return text || '—'
|
||||
}
|
||||
|
||||
function formatSubscriptionChannelLabel(channelType: string) {
|
||||
switch (channelType) {
|
||||
case 'web_push':
|
||||
return '浏览器提醒'
|
||||
case 'email':
|
||||
return '邮件订阅'
|
||||
case 'discord':
|
||||
return 'Discord Webhook'
|
||||
case 'telegram':
|
||||
return 'Telegram Bot API'
|
||||
case 'ntfy':
|
||||
return 'ntfy'
|
||||
case 'webhook':
|
||||
return 'Webhook'
|
||||
default:
|
||||
return channelType
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: SubscriptionRecord['metadata'], key: string) {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function formatSubscriptionSource(source: string | null) {
|
||||
switch (source) {
|
||||
case 'frontend-popup':
|
||||
return '前台订阅弹窗'
|
||||
case 'manual':
|
||||
return '后台手动添加'
|
||||
case 'admin':
|
||||
return '后台手动添加'
|
||||
case 'import':
|
||||
return '批量导入'
|
||||
case 'seed':
|
||||
return '初始化数据'
|
||||
default:
|
||||
return source ?? '未记录'
|
||||
}
|
||||
}
|
||||
|
||||
function formatSubscriptionPlatform(userAgent: string | null) {
|
||||
if (!userAgent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase()
|
||||
if (ua.includes('android')) return 'Android'
|
||||
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ios')) return 'iOS'
|
||||
if (ua.includes('windows')) return 'Windows'
|
||||
if (ua.includes('mac os x') || ua.includes('macintosh')) return 'macOS'
|
||||
if (ua.includes('linux')) return 'Linux'
|
||||
return null
|
||||
}
|
||||
|
||||
function formatPushEndpointHost(target: string) {
|
||||
try {
|
||||
const url = new URL(target)
|
||||
return url.host || url.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function describeSubscriptionTarget(item: SubscriptionRecord) {
|
||||
const createdAt = formatDateTime(item.created_at)
|
||||
|
||||
if (item.channel_type === 'web_push') {
|
||||
const userAgent = readMetadataString(item.metadata, 'user_agent')
|
||||
const browser = userAgent ? formatBrowserName(userAgent) : '浏览器信息未记录'
|
||||
const platform = formatSubscriptionPlatform(userAgent)
|
||||
const pushHost = formatPushEndpointHost(item.target)
|
||||
|
||||
return {
|
||||
primary: platform ? `${browser} · ${platform}` : browser,
|
||||
details: [
|
||||
pushHost ? `推送节点:${pushHost}` : '推送地址:已隐藏完整链接',
|
||||
`创建于:${createdAt}`,
|
||||
],
|
||||
title: item.target,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary: item.target,
|
||||
details: [`创建于:${createdAt}`],
|
||||
title: item.target,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriptionSourceBadge(item: SubscriptionRecord): { label: string; variant: BadgeVariant } {
|
||||
const source = readMetadataString(item.metadata, 'source')
|
||||
const kind = readMetadataString(item.metadata, 'kind')
|
||||
|
||||
if (source === 'frontend-popup') {
|
||||
return { label: '前台弹窗', variant: 'default' }
|
||||
}
|
||||
|
||||
if (source === 'manual' || source === 'admin') {
|
||||
return { label: '后台手动', variant: 'secondary' }
|
||||
}
|
||||
|
||||
if (source === 'import' || source === 'seed') {
|
||||
return { label: formatSubscriptionSource(source), variant: 'warning' }
|
||||
}
|
||||
|
||||
if (kind === 'browser-push') {
|
||||
return { label: '前台浏览器订阅', variant: 'default' }
|
||||
}
|
||||
|
||||
if (kind === 'public-form') {
|
||||
return { label: '前台邮箱订阅', variant: 'default' }
|
||||
}
|
||||
|
||||
if (source) {
|
||||
return { label: formatSubscriptionSource(source), variant: 'outline' }
|
||||
}
|
||||
|
||||
return { label: '未记录来源', variant: 'outline' }
|
||||
}
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
|
||||
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
|
||||
@@ -84,6 +208,8 @@ export function SubscriptionsPage() {
|
||||
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
|
||||
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
const [subscriptionSearch, setSubscriptionSearch] = useState('')
|
||||
const [subscriptionChannelFilter, setSubscriptionChannelFilter] = useState('all')
|
||||
|
||||
const loadData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -131,6 +257,68 @@ export function SubscriptionsPage() {
|
||||
[deliveries],
|
||||
)
|
||||
|
||||
const filteredSubscriptions = useMemo(() => {
|
||||
const query = subscriptionSearch.trim().toLowerCase()
|
||||
|
||||
return subscriptions.filter((item) => {
|
||||
if (subscriptionChannelFilter !== 'all' && item.channel_type !== subscriptionChannelFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sourceBadge = getSubscriptionSourceBadge(item)
|
||||
const targetInfo = describeSubscriptionTarget(item)
|
||||
const searchable = [
|
||||
item.display_name,
|
||||
item.target,
|
||||
item.channel_type,
|
||||
formatSubscriptionChannelLabel(item.channel_type),
|
||||
sourceBadge.label,
|
||||
targetInfo.primary,
|
||||
...targetInfo.details,
|
||||
readMetadataString(item.metadata, 'user_agent'),
|
||||
readMetadataString(item.metadata, 'source'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchable.includes(query)
|
||||
})
|
||||
}, [subscriptionChannelFilter, subscriptionSearch, subscriptions])
|
||||
|
||||
const groupedSubscriptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'web_push',
|
||||
title: '浏览器提醒',
|
||||
description: '默认主流程,授权后可直接收到站内更新提醒。',
|
||||
badgeVariant: 'default' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter((item) => item.channel_type === 'web_push'),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
title: '邮件订阅',
|
||||
description: '通常作为额外备份,确认邮箱后开始生效。',
|
||||
badgeVariant: 'secondary' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter((item) => item.channel_type === 'email'),
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
title: '其他渠道',
|
||||
description: 'Webhook / Discord / Telegram / ntfy 等外部通知目标。',
|
||||
badgeVariant: 'outline' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter(
|
||||
(item) => item.channel_type !== 'web_push' && item.channel_type !== 'email',
|
||||
),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
[filteredSubscriptions],
|
||||
)
|
||||
|
||||
const deliveryJobMap = useMemo(() => {
|
||||
const map = new Map<number, WorkerJobRecord>()
|
||||
for (const item of workerJobs) {
|
||||
@@ -177,6 +365,132 @@ export function SubscriptionsPage() {
|
||||
}
|
||||
}, [editingId, form, loadData, resetForm])
|
||||
|
||||
const renderSubscriptionRow = useCallback((item: SubscriptionRecord) => {
|
||||
const targetInfo = describeSubscriptionTarget(item)
|
||||
const sourceBadge = getSubscriptionSourceBadge(item)
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{item.display_name ?? formatSubscriptionChannelLabel(item.channel_type)}
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{item.channel_type}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px] break-words text-sm text-muted-foreground">
|
||||
<div className="space-y-2" title={targetInfo.title}>
|
||||
<div className="font-medium text-foreground">{targetInfo.primary}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={sourceBadge.variant}>{sourceBadge.label}</Badge>
|
||||
{item.channel_type === 'web_push' ? <Badge variant="outline">浏览器订阅</Badge> : null}
|
||||
</div>
|
||||
{targetInfo.details.map((line) => (
|
||||
<div key={line} className="text-xs text-muted-foreground/80">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground/80">
|
||||
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{normalizePreview(item.filters)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`subscription-edit-${item.id}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
channelType: item.channel_type,
|
||||
target: item.target,
|
||||
displayName: item.display_name ?? '',
|
||||
status: item.status,
|
||||
notes: item.notes ?? '',
|
||||
filtersText: prettyJson(item.filters),
|
||||
metadataText: prettyJson(item.metadata),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-test-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
const result = await adminApi.testSubscription(item.id)
|
||||
if (result.job_id) {
|
||||
setLastActionJobId(result.job_id)
|
||||
}
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `测试通知已入队:#${result.job_id}`
|
||||
: '测试通知已入队。',
|
||||
)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.deleteSubscription(item.id)
|
||||
toast.success('订阅目标已删除。')
|
||||
if (editingId === item.id) {
|
||||
resetForm()
|
||||
}
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}, [actioningId, editingId, loadData, resetForm])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -365,131 +679,91 @@ export function SubscriptionsPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>当前订阅目标</CardTitle>
|
||||
<CardDescription>支持单条测试、编辑 filters / metadata,以及删除。</CardDescription>
|
||||
<CardDescription>按浏览器提醒 / 邮件订阅 / 其他渠道分组查看,并支持搜索、筛选、测试与编辑。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{subscriptions.length} 个</Badge>
|
||||
<Badge variant="outline">
|
||||
{filteredSubscriptions.length} / {subscriptions.length} 个
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>偏好</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((item) => (
|
||||
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{item.channel_type}
|
||||
</div>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 rounded-2xl border border-border/70 bg-background/50 p-4 md:grid-cols-[minmax(0,1.2fr)_220px_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>搜索订阅</Label>
|
||||
<Input
|
||||
value={subscriptionSearch}
|
||||
onChange={(event) => setSubscriptionSearch(event.target.value)}
|
||||
placeholder="搜索名称、地址、来源、浏览器、推送节点..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型筛选</Label>
|
||||
<Select
|
||||
value={subscriptionChannelFilter}
|
||||
onChange={(event) => setSubscriptionChannelFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
{CHANNEL_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{formatSubscriptionChannelLabel(item.value)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
{(subscriptionSearch.trim() || subscriptionChannelFilter !== 'all') ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSubscriptionSearch('')
|
||||
setSubscriptionChannelFilter('all')
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscriptions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
当前还没有订阅记录。新的浏览器提醒或邮箱备份成功后,会直接出现在这里。
|
||||
</div>
|
||||
) : groupedSubscriptions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
没有符合当前搜索或筛选条件的订阅记录。
|
||||
</div>
|
||||
) : (
|
||||
groupedSubscriptions.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="overflow-hidden rounded-2xl border border-border/70 bg-background/35"
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-border/60 px-4 py-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
|
||||
<Badge variant={group.badgeVariant}>{group.items.length} 个</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
|
||||
<div>{item.target}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground/80">
|
||||
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{normalizePreview(item.filters)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`subscription-edit-${item.id}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
channelType: item.channel_type,
|
||||
target: item.target,
|
||||
displayName: item.display_name ?? '',
|
||||
status: item.status,
|
||||
notes: item.notes ?? '',
|
||||
filtersText: prettyJson(item.filters),
|
||||
metadataText: prettyJson(item.metadata),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-test-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
const result = await adminApi.testSubscription(item.id)
|
||||
if (result.job_id) {
|
||||
setLastActionJobId(result.job_id)
|
||||
}
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `测试通知已入队:#${result.job_id}`
|
||||
: '测试通知已入队。',
|
||||
)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.deleteSubscription(item.id)
|
||||
toast.success('订阅目标已删除。')
|
||||
if (editingId === item.id) {
|
||||
resetForm()
|
||||
}
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-sm text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>偏好</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{group.items.map(renderSubscriptionRow)}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -302,14 +303,26 @@ export function TagsPage() {
|
||||
placeholder="astro"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="封面图 URL" hint="可选,用于前台标签头图。">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/astro.jpg"
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/astro.jpg"
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.coverImage}
|
||||
onChange={(coverImage) =>
|
||||
setForm((current) => ({ ...current, coverImage }))
|
||||
}
|
||||
prefix="tag-covers/"
|
||||
contextLabel="标签封面上传"
|
||||
remoteTitle={form.name || form.slug || '标签封面'}
|
||||
dataTestIdPrefix="tag-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user