feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
style: enhance global CSS for better responsiveness of terminal chips and navigation pills test: remove inline subscription test and add maintenance mode access code test feat: implement media library picker dialog for selecting images from the media library feat: add media URL controls for uploading and managing media assets feat: add migration for music_enabled and maintenance_mode settings in site settings feat: implement maintenance mode functionality with access control feat: create maintenance page with access code input and error handling chore: add TypeScript declaration for QR code module
This commit is contained in:
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -302,14 +303,26 @@ export function CategoriesPage() {
|
||||
placeholder="frontend-engineering"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="封面图 URL" hint="可选,用于前台分类头图。">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/frontend.jpg"
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/frontend.jpg"
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.coverImage}
|
||||
onChange={(coverImage) =>
|
||||
setForm((current) => ({ ...current, coverImage }))
|
||||
}
|
||||
prefix="category-covers/"
|
||||
contextLabel="分类封面上传"
|
||||
remoteTitle={form.name || form.slug || '分类封面'}
|
||||
dataTestIdPrefix="category-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -378,13 +379,25 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="头像 URL">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.avatarUrl}
|
||||
onChange={(avatarUrl) =>
|
||||
setForm((current) => ({ ...current, avatarUrl }))
|
||||
}
|
||||
prefix="friend-link-avatars/"
|
||||
contextLabel="友链头像上传"
|
||||
remoteTitle={form.siteName || form.siteUrl || '友链头像'}
|
||||
dataTestIdPrefix="friend-link-avatar"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="分类">
|
||||
<Input
|
||||
|
||||
@@ -23,8 +23,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
maybeCompressImageWithPrompt,
|
||||
normalizeCoverImageWithPrompt,
|
||||
prepareImageForUpload,
|
||||
type MediaUploadTargetFormat,
|
||||
} from '@/lib/image-compress'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -141,9 +141,11 @@ export function MediaPage() {
|
||||
const [metadataSaving, setMetadataSaving] = useState(false)
|
||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
|
||||
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
|
||||
defaultRemoteDownloadForm,
|
||||
)
|
||||
const [remoteTargetFormat, setRemoteTargetFormat] = useState<'original' | 'webp' | 'avif'>('original')
|
||||
const [downloadingRemote, setDownloadingRemote] = useState(false)
|
||||
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
|
||||
|
||||
@@ -218,22 +220,18 @@ export function MediaPage() {
|
||||
|
||||
const quality = Number.parseFloat(compressQuality)
|
||||
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
|
||||
const normalizeCover =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
|
||||
const mode =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image'
|
||||
|
||||
const result: File[] = []
|
||||
for (const file of files) {
|
||||
const compressed = normalizeCover
|
||||
? await normalizeCoverImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `封面规范化上传(${file.name})`,
|
||||
})
|
||||
: await maybeCompressImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `媒体库上传(${file.name})`,
|
||||
})
|
||||
const compressed = await prepareImageForUpload(file, {
|
||||
compress: true,
|
||||
quality: safeQuality,
|
||||
targetFormat: uploadTargetFormat,
|
||||
contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}(${file.name})`,
|
||||
mode,
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
|
||||
}
|
||||
@@ -304,11 +302,23 @@ export function MediaPage() {
|
||||
<option value="all">全部目录</option>
|
||||
<option value="post-covers/">文章封面</option>
|
||||
<option value="review-covers/">评测封面</option>
|
||||
<option value="category-covers/">分类封面</option>
|
||||
<option value="tag-covers/">标签封面</option>
|
||||
<option value="site-assets/">站点资源</option>
|
||||
<option value="seo-assets/">SEO 图片</option>
|
||||
<option value="music-covers/">音乐封面</option>
|
||||
<option value="friend-link-avatars/">友链头像</option>
|
||||
<option value="uploads/">通用上传</option>
|
||||
</Select>
|
||||
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
|
||||
<option value="post-covers/">上传到文章封面</option>
|
||||
<option value="review-covers/">上传到评测封面</option>
|
||||
<option value="category-covers/">上传到分类封面</option>
|
||||
<option value="tag-covers/">上传到标签封面</option>
|
||||
<option value="site-assets/">上传到站点资源</option>
|
||||
<option value="seo-assets/">上传到 SEO 图片</option>
|
||||
<option value="music-covers/">上传到音乐封面</option>
|
||||
<option value="friend-link-avatars/">上传到友链头像</option>
|
||||
<option value="uploads/">上传到通用目录</option>
|
||||
</Select>
|
||||
<Input
|
||||
@@ -319,7 +329,7 @@ export function MediaPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_180px_96px_auto]">
|
||||
<Input
|
||||
data-testid="media-upload-input"
|
||||
type="file"
|
||||
@@ -338,6 +348,15 @@ export function MediaPage() {
|
||||
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||
压缩上传
|
||||
</Button>
|
||||
<Select
|
||||
value={uploadTargetFormat}
|
||||
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
|
||||
disabled={!compressBeforeUpload}
|
||||
>
|
||||
<option value="avif">压缩为 AVIF</option>
|
||||
<option value="webp">压缩为 WebP</option>
|
||||
<option value="auto">自动选择格式</option>
|
||||
</Select>
|
||||
<Input
|
||||
className="w-[96px]"
|
||||
value={compressQuality}
|
||||
@@ -373,7 +392,7 @@ export function MediaPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {uploadFiles.length} 个文件。
|
||||
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
|
||||
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
|
||||
? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -401,6 +420,19 @@ export function MediaPage() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="抓取格式">
|
||||
<Select
|
||||
value={remoteTargetFormat}
|
||||
onChange={(event) =>
|
||||
setRemoteTargetFormat(event.target.value as 'original' | 'webp' | 'avif')
|
||||
}
|
||||
>
|
||||
<option value="original">按原格式抓取</option>
|
||||
<option value="webp">抓取后转 WebP</option>
|
||||
<option value="avif">抓取后转 AVIF</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="media-remote-title"
|
||||
@@ -489,6 +521,7 @@ export function MediaPage() {
|
||||
const result = await adminApi.downloadMediaObject({
|
||||
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
|
||||
prefix: uploadPrefix,
|
||||
targetFormat: remoteTargetFormat,
|
||||
title: remoteDownloadForm.title.trim() || null,
|
||||
altText: remoteDownloadForm.altText.trim() || null,
|
||||
caption: remoteDownloadForm.caption.trim() || null,
|
||||
@@ -496,7 +529,11 @@ export function MediaPage() {
|
||||
notes: remoteDownloadForm.notes.trim() || null,
|
||||
})
|
||||
setLastRemoteDownloadJobId(result.job_id)
|
||||
toast.success(`远程抓取任务已入队:#${result.job_id}`)
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `远程抓取任务已入队:#${result.job_id}`
|
||||
: '远程抓取请求已提交。',
|
||||
)
|
||||
setRemoteDownloadForm(defaultRemoteDownloadForm)
|
||||
window.setTimeout(() => {
|
||||
void loadItems(false)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -24,6 +23,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { LazyDiffEditor } from '@/components/lazy-monaco'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import {
|
||||
MarkdownWorkbench,
|
||||
@@ -49,10 +49,6 @@ import {
|
||||
formatPostVisibility,
|
||||
postTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
|
||||
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
|
||||
@@ -259,6 +255,17 @@ function buildVirtualPostPath(slug: string) {
|
||||
return `article://posts/${normalizedSlug}`
|
||||
}
|
||||
|
||||
function buildInlineImagePrefix(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
|
||||
return `post-inline-images/${normalized || 'draft'}`
|
||||
}
|
||||
|
||||
function parseImageList(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
@@ -808,8 +815,6 @@ export function PostsPage() {
|
||||
const { slug } = useParams()
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [posts, setPosts] = useState<PostRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -823,8 +828,8 @@ export function PostsPage() {
|
||||
useState(false)
|
||||
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
|
||||
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
|
||||
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
|
||||
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
|
||||
const [localizingEditorImages, setLocalizingEditorImages] = useState(false)
|
||||
const [localizingCreateImages, setLocalizingCreateImages] = useState(false)
|
||||
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
|
||||
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
|
||||
const [editor, setEditor] = useState<PostFormState | null>(null)
|
||||
@@ -1457,67 +1462,89 @@ export function PostsPage() {
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const uploadEditorCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingEditorCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '文章封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
const localizeEditorMarkdownImages = useCallback(async () => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
const sourceMarkdown = buildDraftMarkdownForWindow(editor)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalizingEditorImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(editor.slug),
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
|
||||
if (!result.localized_count && !result.failed_count) {
|
||||
toast.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setEditor((current) => (current ? { ...current, image: url } : current))
|
||||
setEditor((current) =>
|
||||
current ? applyPolishedEditorState(current, result.markdown) : current,
|
||||
)
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setUploadingEditorCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const uploadCreateCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCreateCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '新建封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
if (result.localized_count && result.failed_count) {
|
||||
toast.warning(
|
||||
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
|
||||
)
|
||||
} else if (result.localized_count) {
|
||||
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
|
||||
} else {
|
||||
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
} finally {
|
||||
setLocalizingEditorImages(false)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
const localizeCreateMarkdownImages = useCallback(async () => {
|
||||
const sourceMarkdown = buildCreateMarkdownForWindow(createForm)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalizingCreateImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(createForm.slug || createForm.title),
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
|
||||
if (!result.localized_count && !result.failed_count) {
|
||||
toast.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCreateForm((current) => ({ ...current, image: url }))
|
||||
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
|
||||
if (result.localized_count && result.failed_count) {
|
||||
toast.warning(
|
||||
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
|
||||
)
|
||||
} else if (result.localized_count) {
|
||||
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
|
||||
} else {
|
||||
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
} finally {
|
||||
setUploadingCreateCover(false)
|
||||
setLocalizingCreateImages(false)
|
||||
}
|
||||
}, [])
|
||||
}, [createForm])
|
||||
|
||||
const openEditorPreviewWindow = useCallback(() => {
|
||||
const snapshot = buildEditorDraftSnapshot()
|
||||
@@ -2087,32 +2114,6 @@ export function PostsPage() {
|
||||
void importMarkdownFiles(event.target.files)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={editorCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadEditorCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={createCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadCreateCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
@@ -2526,29 +2527,34 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="封面图 URL">
|
||||
<Input
|
||||
value={editor.image}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, image: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={editor.image}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, image: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={editor.image}
|
||||
onChange={(image) =>
|
||||
setEditor((current) => (current ? { ...current, image } : current))
|
||||
}
|
||||
prefix="post-covers/"
|
||||
contextLabel="文章封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={editor.title || editor.slug || '文章封面'}
|
||||
dataTestIdPrefix="post-editor-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => editorCoverInputRef.current?.click()}
|
||||
disabled={uploadingEditorCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingEditorCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateEditorCover()}
|
||||
disabled={generatingEditorCover || uploadingEditorCover}
|
||||
disabled={generatingEditorCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingEditorCover
|
||||
@@ -2703,6 +2709,14 @@ export function PostsPage() {
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void localizeEditorMarkdownImages()}
|
||||
disabled={saving || localizingEditorImages}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{localizingEditorImages ? '本地化中...' : '正文图本地化'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -2994,27 +3008,32 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="封面图 URL">
|
||||
<Input
|
||||
value={createForm.image}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, image: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={createForm.image}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, image: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={createForm.image}
|
||||
onChange={(image) =>
|
||||
setCreateForm((current) => ({ ...current, image }))
|
||||
}
|
||||
prefix="post-covers/"
|
||||
contextLabel="新建文章封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={createForm.title || createForm.slug || '文章封面'}
|
||||
dataTestIdPrefix="post-create-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => createCoverInputRef.current?.click()}
|
||||
disabled={uploadingCreateCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCreateCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateCreateCover()}
|
||||
disabled={generatingCreateCover || uploadingCreateCover}
|
||||
disabled={generatingCreateCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingCreateCover
|
||||
@@ -3150,6 +3169,14 @@ export function PostsPage() {
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void localizeCreateMarkdownImages()}
|
||||
disabled={creating || localizingCreateImages}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{localizingCreateImages ? '本地化中...' : '正文图本地化'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -18,10 +19,6 @@ import {
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
@@ -103,14 +100,12 @@ export function ReviewsPage() {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [polishingDescription, setPolishingDescription] = useState(false)
|
||||
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
|
||||
null,
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const loadReviews = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -217,29 +212,6 @@ export function ReviewsPage() {
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const uploadReviewCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '评测封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
const result = await adminApi.uploadReviewCoverImage(compressed.file)
|
||||
startTransition(() => {
|
||||
setForm((current) => ({ ...current, cover: result.url }))
|
||||
})
|
||||
toast.success('评测封面已上传到 R2。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
@@ -513,36 +485,21 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={reviewCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadReviewCover(file)
|
||||
}
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={uploadingCover}
|
||||
onClick={() => reviewCoverInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCover ? '上传中...' : '上传到 R2'}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.cover}
|
||||
onChange={(cover) => setForm((current) => ({ ...current, cover }))}
|
||||
prefix="review-covers/"
|
||||
contextLabel="评测封面上传"
|
||||
mode="cover"
|
||||
remoteTitle={form.title || '评测封面'}
|
||||
dataTestIdPrefix="review-cover"
|
||||
/>
|
||||
|
||||
{form.cover ? (
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -132,6 +133,9 @@ function normalizeSettingsResponse(
|
||||
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
|
||||
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
|
||||
web_push_vapid_subject: input.web_push_vapid_subject ?? null,
|
||||
music_enabled: input.music_enabled ?? true,
|
||||
maintenance_mode_enabled: input.maintenance_mode_enabled ?? false,
|
||||
maintenance_access_code: input.maintenance_access_code ?? null,
|
||||
ai_active_provider_id:
|
||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||
}
|
||||
@@ -177,6 +181,9 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
location: form.location,
|
||||
techStack: form.tech_stack,
|
||||
musicPlaylist: form.music_playlist,
|
||||
musicEnabled: form.music_enabled,
|
||||
maintenanceModeEnabled: form.maintenance_mode_enabled,
|
||||
maintenanceAccessCode: form.maintenance_access_code,
|
||||
aiEnabled: form.ai_enabled,
|
||||
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
||||
commentVerificationMode: form.comment_verification_mode,
|
||||
@@ -514,6 +521,11 @@ export function SiteSettingsPage() {
|
||||
disabled={saving}
|
||||
data-testid="site-settings-save"
|
||||
onClick={async () => {
|
||||
if (form.maintenance_mode_enabled && !form.maintenance_access_code?.trim()) {
|
||||
toast.error('开启维护模式前请先填写访问口令。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const updated = await adminApi.updateSiteSettings(toPayload(form))
|
||||
@@ -607,11 +619,21 @@ export function SiteSettingsPage() {
|
||||
onChange={(event) => updateField('owner_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="头像 URL">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
<Field label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(ownerAvatarUrl) => updateField('owner_avatar_url', ownerAvatarUrl)}
|
||||
prefix="site-assets/"
|
||||
contextLabel="站长头像上传"
|
||||
remoteTitle={form.owner_name || form.site_name || '站长头像'}
|
||||
dataTestIdPrefix="site-owner-avatar"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="站长简介">
|
||||
@@ -765,6 +787,55 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>维护模式</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,前台访问者需要先输入口令才能看到内容,适合开发联调、灰度预览或上线前封站检查。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.maintenance_mode_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_mode_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">开启前台维护模式</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
开启后,访问首页、文章页、分类页等前台内容都会先进入维护页;只有输入正确口令后才会放行。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field
|
||||
label="访问口令"
|
||||
hint="建议设置成临时口令后发给测试同事;修改口令后,旧口令拿到的访问凭证会自动失效。"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.maintenance_access_code ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_access_code', event.target.value)
|
||||
}
|
||||
placeholder="例如:staging-2026"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Badge variant={form.maintenance_mode_enabled ? 'warning' : 'outline'}>
|
||||
{form.maintenance_mode_enabled ? '维护中' : '正常开放'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>运行时安全 / 推送配置</CardTitle>
|
||||
@@ -844,11 +915,23 @@ export function SiteSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退,也支持上传 / 抓取 / 选择媒体库。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(seoDefaultOgImage) =>
|
||||
updateField('seo_default_og_image', seoDefaultOgImage)
|
||||
}
|
||||
prefix="seo-assets/"
|
||||
contextLabel="默认 OG 图上传"
|
||||
remoteTitle={form.site_name || form.site_title || '默认 OG 图'}
|
||||
dataTestIdPrefix="site-default-og"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Twitter / X Handle" hint="例如 @initcool。">
|
||||
<Input
|
||||
@@ -1565,13 +1648,33 @@ export function SiteSettingsPage() {
|
||||
<div>
|
||||
<CardTitle>音乐侧栏</CardTitle>
|
||||
<CardDescription>
|
||||
把头部播放器的曲目清单和单曲属性放到独立侧边栏里维护。
|
||||
可以直接控制前台是否显示音乐播放器,歌单配置会继续保留在后台。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={form.music_enabled ? 'default' : 'outline'}>
|
||||
{form.music_enabled ? '前台已开启' : '前台已关闭'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.music_enabled}
|
||||
onChange={(event) => updateField('music_enabled', event.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">前台显示音乐播放器</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
关闭后前台头部和移动菜单里的音乐模块会整体隐藏,但下面维护的歌单内容不会丢失。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
{form.music_playlist.map((track, index) => {
|
||||
const active = index === selectedTrackIndex
|
||||
@@ -1687,13 +1790,25 @@ export function SiteSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="封面图 URL">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
<Field label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(coverImageUrl) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', coverImageUrl)
|
||||
}
|
||||
prefix="music-covers/"
|
||||
contextLabel="音乐封面上传"
|
||||
remoteTitle={selectedTrack.title || `曲目 ${selectedTrackIndex + 1} 封面`}
|
||||
dataTestIdPrefix="site-music-cover"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -19,9 +19,12 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatBrowserName, formatDateTime } from '@/lib/admin-format'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
@@ -72,6 +75,127 @@ function normalizePreview(value: unknown) {
|
||||
return text || '—'
|
||||
}
|
||||
|
||||
function formatSubscriptionChannelLabel(channelType: string) {
|
||||
switch (channelType) {
|
||||
case 'web_push':
|
||||
return '浏览器提醒'
|
||||
case 'email':
|
||||
return '邮件订阅'
|
||||
case 'discord':
|
||||
return 'Discord Webhook'
|
||||
case 'telegram':
|
||||
return 'Telegram Bot API'
|
||||
case 'ntfy':
|
||||
return 'ntfy'
|
||||
case 'webhook':
|
||||
return 'Webhook'
|
||||
default:
|
||||
return channelType
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadataString(metadata: SubscriptionRecord['metadata'], key: string) {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function formatSubscriptionSource(source: string | null) {
|
||||
switch (source) {
|
||||
case 'frontend-popup':
|
||||
return '前台订阅弹窗'
|
||||
case 'manual':
|
||||
return '后台手动添加'
|
||||
case 'admin':
|
||||
return '后台手动添加'
|
||||
case 'import':
|
||||
return '批量导入'
|
||||
case 'seed':
|
||||
return '初始化数据'
|
||||
default:
|
||||
return source ?? '未记录'
|
||||
}
|
||||
}
|
||||
|
||||
function formatSubscriptionPlatform(userAgent: string | null) {
|
||||
if (!userAgent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase()
|
||||
if (ua.includes('android')) return 'Android'
|
||||
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ios')) return 'iOS'
|
||||
if (ua.includes('windows')) return 'Windows'
|
||||
if (ua.includes('mac os x') || ua.includes('macintosh')) return 'macOS'
|
||||
if (ua.includes('linux')) return 'Linux'
|
||||
return null
|
||||
}
|
||||
|
||||
function formatPushEndpointHost(target: string) {
|
||||
try {
|
||||
const url = new URL(target)
|
||||
return url.host || url.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function describeSubscriptionTarget(item: SubscriptionRecord) {
|
||||
const createdAt = formatDateTime(item.created_at)
|
||||
|
||||
if (item.channel_type === 'web_push') {
|
||||
const userAgent = readMetadataString(item.metadata, 'user_agent')
|
||||
const browser = userAgent ? formatBrowserName(userAgent) : '浏览器信息未记录'
|
||||
const platform = formatSubscriptionPlatform(userAgent)
|
||||
const pushHost = formatPushEndpointHost(item.target)
|
||||
|
||||
return {
|
||||
primary: platform ? `${browser} · ${platform}` : browser,
|
||||
details: [
|
||||
pushHost ? `推送节点:${pushHost}` : '推送地址:已隐藏完整链接',
|
||||
`创建于:${createdAt}`,
|
||||
],
|
||||
title: item.target,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary: item.target,
|
||||
details: [`创建于:${createdAt}`],
|
||||
title: item.target,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriptionSourceBadge(item: SubscriptionRecord): { label: string; variant: BadgeVariant } {
|
||||
const source = readMetadataString(item.metadata, 'source')
|
||||
const kind = readMetadataString(item.metadata, 'kind')
|
||||
|
||||
if (source === 'frontend-popup') {
|
||||
return { label: '前台弹窗', variant: 'default' }
|
||||
}
|
||||
|
||||
if (source === 'manual' || source === 'admin') {
|
||||
return { label: '后台手动', variant: 'secondary' }
|
||||
}
|
||||
|
||||
if (source === 'import' || source === 'seed') {
|
||||
return { label: formatSubscriptionSource(source), variant: 'warning' }
|
||||
}
|
||||
|
||||
if (kind === 'browser-push') {
|
||||
return { label: '前台浏览器订阅', variant: 'default' }
|
||||
}
|
||||
|
||||
if (kind === 'public-form') {
|
||||
return { label: '前台邮箱订阅', variant: 'default' }
|
||||
}
|
||||
|
||||
if (source) {
|
||||
return { label: formatSubscriptionSource(source), variant: 'outline' }
|
||||
}
|
||||
|
||||
return { label: '未记录来源', variant: 'outline' }
|
||||
}
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
|
||||
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
|
||||
@@ -84,6 +208,8 @@ export function SubscriptionsPage() {
|
||||
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
|
||||
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
const [subscriptionSearch, setSubscriptionSearch] = useState('')
|
||||
const [subscriptionChannelFilter, setSubscriptionChannelFilter] = useState('all')
|
||||
|
||||
const loadData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -131,6 +257,68 @@ export function SubscriptionsPage() {
|
||||
[deliveries],
|
||||
)
|
||||
|
||||
const filteredSubscriptions = useMemo(() => {
|
||||
const query = subscriptionSearch.trim().toLowerCase()
|
||||
|
||||
return subscriptions.filter((item) => {
|
||||
if (subscriptionChannelFilter !== 'all' && item.channel_type !== subscriptionChannelFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sourceBadge = getSubscriptionSourceBadge(item)
|
||||
const targetInfo = describeSubscriptionTarget(item)
|
||||
const searchable = [
|
||||
item.display_name,
|
||||
item.target,
|
||||
item.channel_type,
|
||||
formatSubscriptionChannelLabel(item.channel_type),
|
||||
sourceBadge.label,
|
||||
targetInfo.primary,
|
||||
...targetInfo.details,
|
||||
readMetadataString(item.metadata, 'user_agent'),
|
||||
readMetadataString(item.metadata, 'source'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchable.includes(query)
|
||||
})
|
||||
}, [subscriptionChannelFilter, subscriptionSearch, subscriptions])
|
||||
|
||||
const groupedSubscriptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'web_push',
|
||||
title: '浏览器提醒',
|
||||
description: '默认主流程,授权后可直接收到站内更新提醒。',
|
||||
badgeVariant: 'default' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter((item) => item.channel_type === 'web_push'),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
title: '邮件订阅',
|
||||
description: '通常作为额外备份,确认邮箱后开始生效。',
|
||||
badgeVariant: 'secondary' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter((item) => item.channel_type === 'email'),
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
title: '其他渠道',
|
||||
description: 'Webhook / Discord / Telegram / ntfy 等外部通知目标。',
|
||||
badgeVariant: 'outline' as BadgeVariant,
|
||||
items: filteredSubscriptions.filter(
|
||||
(item) => item.channel_type !== 'web_push' && item.channel_type !== 'email',
|
||||
),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
[filteredSubscriptions],
|
||||
)
|
||||
|
||||
const deliveryJobMap = useMemo(() => {
|
||||
const map = new Map<number, WorkerJobRecord>()
|
||||
for (const item of workerJobs) {
|
||||
@@ -177,6 +365,132 @@ export function SubscriptionsPage() {
|
||||
}
|
||||
}, [editingId, form, loadData, resetForm])
|
||||
|
||||
const renderSubscriptionRow = useCallback((item: SubscriptionRecord) => {
|
||||
const targetInfo = describeSubscriptionTarget(item)
|
||||
const sourceBadge = getSubscriptionSourceBadge(item)
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{item.display_name ?? formatSubscriptionChannelLabel(item.channel_type)}
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{item.channel_type}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px] break-words text-sm text-muted-foreground">
|
||||
<div className="space-y-2" title={targetInfo.title}>
|
||||
<div className="font-medium text-foreground">{targetInfo.primary}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={sourceBadge.variant}>{sourceBadge.label}</Badge>
|
||||
{item.channel_type === 'web_push' ? <Badge variant="outline">浏览器订阅</Badge> : null}
|
||||
</div>
|
||||
{targetInfo.details.map((line) => (
|
||||
<div key={line} className="text-xs text-muted-foreground/80">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-muted-foreground/80">
|
||||
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{normalizePreview(item.filters)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`subscription-edit-${item.id}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
channelType: item.channel_type,
|
||||
target: item.target,
|
||||
displayName: item.display_name ?? '',
|
||||
status: item.status,
|
||||
notes: item.notes ?? '',
|
||||
filtersText: prettyJson(item.filters),
|
||||
metadataText: prettyJson(item.metadata),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-test-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
const result = await adminApi.testSubscription(item.id)
|
||||
if (result.job_id) {
|
||||
setLastActionJobId(result.job_id)
|
||||
}
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `测试通知已入队:#${result.job_id}`
|
||||
: '测试通知已入队。',
|
||||
)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.deleteSubscription(item.id)
|
||||
toast.success('订阅目标已删除。')
|
||||
if (editingId === item.id) {
|
||||
resetForm()
|
||||
}
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}, [actioningId, editingId, loadData, resetForm])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -365,131 +679,91 @@ export function SubscriptionsPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>当前订阅目标</CardTitle>
|
||||
<CardDescription>支持单条测试、编辑 filters / metadata,以及删除。</CardDescription>
|
||||
<CardDescription>按浏览器提醒 / 邮件订阅 / 其他渠道分组查看,并支持搜索、筛选、测试与编辑。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{subscriptions.length} 个</Badge>
|
||||
<Badge variant="outline">
|
||||
{filteredSubscriptions.length} / {subscriptions.length} 个
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>偏好</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((item) => (
|
||||
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{item.channel_type}
|
||||
</div>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 rounded-2xl border border-border/70 bg-background/50 p-4 md:grid-cols-[minmax(0,1.2fr)_220px_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>搜索订阅</Label>
|
||||
<Input
|
||||
value={subscriptionSearch}
|
||||
onChange={(event) => setSubscriptionSearch(event.target.value)}
|
||||
placeholder="搜索名称、地址、来源、浏览器、推送节点..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型筛选</Label>
|
||||
<Select
|
||||
value={subscriptionChannelFilter}
|
||||
onChange={(event) => setSubscriptionChannelFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
{CHANNEL_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{formatSubscriptionChannelLabel(item.value)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
{(subscriptionSearch.trim() || subscriptionChannelFilter !== 'all') ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSubscriptionSearch('')
|
||||
setSubscriptionChannelFilter('all')
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscriptions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
当前还没有订阅记录。新的浏览器提醒或邮箱备份成功后,会直接出现在这里。
|
||||
</div>
|
||||
) : groupedSubscriptions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
没有符合当前搜索或筛选条件的订阅记录。
|
||||
</div>
|
||||
) : (
|
||||
groupedSubscriptions.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="overflow-hidden rounded-2xl border border-border/70 bg-background/35"
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-border/60 px-4 py-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
|
||||
<Badge variant={group.badgeVariant}>{group.items.length} 个</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
|
||||
<div>{item.target}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground/80">
|
||||
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{normalizePreview(item.filters)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`subscription-edit-${item.id}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
channelType: item.channel_type,
|
||||
target: item.target,
|
||||
displayName: item.display_name ?? '',
|
||||
status: item.status,
|
||||
notes: item.notes ?? '',
|
||||
filtersText: prettyJson(item.filters),
|
||||
metadataText: prettyJson(item.metadata),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-test-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
const result = await adminApi.testSubscription(item.id)
|
||||
if (result.job_id) {
|
||||
setLastActionJobId(result.job_id)
|
||||
}
|
||||
toast.success(
|
||||
result.job_id
|
||||
? `测试通知已入队:#${result.job_id}`
|
||||
: '测试通知已入队。',
|
||||
)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.deleteSubscription(item.id)
|
||||
toast.success('订阅目标已删除。')
|
||||
if (editingId === item.id) {
|
||||
resetForm()
|
||||
}
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-sm text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>偏好</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{group.items.map(renderSubscriptionRow)}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MediaUrlControls } from '@/components/media-url-controls'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -302,14 +303,26 @@ export function TagsPage() {
|
||||
placeholder="astro"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="封面图 URL" hint="可选,用于前台标签头图。">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/astro.jpg"
|
||||
/>
|
||||
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.coverImage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||
}
|
||||
placeholder="https://cdn.example.com/covers/astro.jpg"
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.coverImage}
|
||||
onChange={(coverImage) =>
|
||||
setForm((current) => ({ ...current, coverImage }))
|
||||
}
|
||||
prefix="tag-covers/"
|
||||
contextLabel="标签封面上传"
|
||||
remoteTitle={form.name || form.slug || '标签封面'}
|
||||
dataTestIdPrefix="tag-cover"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user