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

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