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:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user