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

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:
2026-04-02 23:05:49 +08:00
parent 6a50dd478c
commit 9665c933b5
94 changed files with 5266 additions and 1612 deletions

View File

@@ -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={

View File

@@ -106,12 +106,6 @@ const primaryNav = [
description: '异步任务 / 队列控制台',
icon: Workflow,
},
{
to: '/audit',
label: '审计',
description: '后台操作审计日志',
icon: Settings,
},
{
to: '/settings',
label: '设置',

View 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>
)
}

View 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>
)
}

View File

@@ -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')

View File

@@ -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',

View File

@@ -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 ''

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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)

View File

@@ -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={() => {

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">