feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Save,
Trash2,
Upload,
WandSparkles,
X,
} from 'lucide-react'
@@ -38,10 +39,22 @@ import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime, formatPostType, postTagsToList } from '@/lib/admin-format'
import {
emptyToNull,
formatDateTime,
formatPostStatus,
formatPostType,
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'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
import type {
AdminPostMetadataResponse,
@@ -59,6 +72,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
savedMarkdown: string
@@ -73,6 +95,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
}
}
@@ -86,6 +117,15 @@ type CreatePostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
}
@@ -141,8 +181,6 @@ const createMetadataProposalFields: MetadataProposalField[] = [
'category',
'tags',
]
const FRONTEND_DEV_ORIGIN = 'http://localhost:4321'
const defaultCreateForm: CreatePostFormState = {
title: '',
slug: '',
@@ -152,6 +190,15 @@ const defaultCreateForm: CreatePostFormState = {
image: '',
imagesText: '',
pinned: false,
status: 'draft',
visibility: 'public',
publishAt: '',
unpublishAt: '',
canonicalUrl: '',
noindex: false,
ogImage: '',
redirectFromText: '',
redirectTo: '',
tags: '',
markdown: '# 未命名文章\n',
}
@@ -219,11 +266,7 @@ function resolveCoverPreviewUrl(value: string) {
}
if (trimmed.startsWith('/')) {
if (import.meta.env.DEV) {
return new URL(trimmed, FRONTEND_DEV_ORIGIN).toString()
}
return new URL(trimmed, window.location.origin).toString()
return buildFrontendUrl(trimmed)
}
return trimmed
@@ -409,20 +452,29 @@ function stripFrontmatter(markdown: string) {
return normalized.slice(endIndex + 5).trimStart()
}
function extractPublishedFlag(markdown: string) {
function extractPostStatus(markdown: string) {
const normalized = markdown.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) {
return true
return 'published'
}
const endIndex = normalized.indexOf('\n---\n', 4)
if (endIndex === -1) {
return true
return 'published'
}
const frontmatter = normalized.slice(4, endIndex)
const match = frontmatter.match(/^published:\s*(true|false)\s*$/m)
return match?.[1] !== 'false'
const statusMatch = frontmatter.match(/^status:\s*(.+)\s*$/m)
if (statusMatch?.[1]) {
return statusMatch[1].replace(/^['"]|['"]$/g, '').trim() || 'published'
}
const publishedMatch = frontmatter.match(/^published:\s*(true|false)\s*$/m)
if (publishedMatch) {
return publishedMatch[1] === 'false' ? 'draft' : 'published'
}
return 'published'
}
function buildMarkdownForSave(form: PostFormState) {
@@ -441,7 +493,17 @@ function buildMarkdownForSave(form: PostFormState) {
lines.push(`post_type: ${JSON.stringify(form.postType.trim() || 'article')}`)
lines.push(`pinned: ${form.pinned ? 'true' : 'false'}`)
lines.push(`published: ${extractPublishedFlag(form.markdown) ? 'true' : 'false'}`)
lines.push(`status: ${JSON.stringify(form.status.trim() || extractPostStatus(form.markdown))}`)
lines.push(`visibility: ${JSON.stringify(form.visibility.trim() || 'public')}`)
lines.push(`noindex: ${form.noindex ? 'true' : 'false'}`)
if (form.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(form.publishAt.trim())}`)
}
if (form.unpublishAt.trim()) {
lines.push(`unpublish_at: ${JSON.stringify(form.unpublishAt.trim())}`)
}
if (form.image.trim()) {
lines.push(`image: ${JSON.stringify(form.image.trim())}`)
@@ -466,6 +528,26 @@ function buildMarkdownForSave(form: PostFormState) {
})
}
if (form.canonicalUrl.trim()) {
lines.push(`canonical_url: ${JSON.stringify(form.canonicalUrl.trim())}`)
}
if (form.ogImage.trim()) {
lines.push(`og_image: ${JSON.stringify(form.ogImage.trim())}`)
}
const redirectFrom = parseImageList(form.redirectFromText)
if (redirectFrom.length) {
lines.push('redirect_from:')
redirectFrom.forEach((item) => {
lines.push(` - ${JSON.stringify(item)}`)
})
}
if (form.redirectTo.trim()) {
lines.push(`redirect_to: ${JSON.stringify(form.redirectTo.trim())}`)
}
return `${lines.join('\n')}\n---\n\n${stripFrontmatter(form.markdown).trim()}\n`
}
@@ -483,6 +565,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
markdown,
savedMarkdown: markdown,
@@ -497,6 +588,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
},
}
@@ -511,6 +611,15 @@ function hasMetadataDraftChanges(form: PostFormState) {
form.image !== form.savedMeta.image ||
form.imagesText !== form.savedMeta.imagesText ||
form.pinned !== form.savedMeta.pinned ||
form.status !== form.savedMeta.status ||
form.visibility !== form.savedMeta.visibility ||
form.publishAt !== form.savedMeta.publishAt ||
form.unpublishAt !== form.savedMeta.unpublishAt ||
form.canonicalUrl !== form.savedMeta.canonicalUrl ||
form.noindex !== form.savedMeta.noindex ||
form.ogImage !== form.savedMeta.ogImage ||
form.redirectFromText !== form.savedMeta.redirectFromText ||
form.redirectTo !== form.savedMeta.redirectTo ||
form.tags !== form.savedMeta.tags
)
}
@@ -534,7 +643,15 @@ function buildCreatePayload(form: CreatePostFormState): CreatePostPayload {
image: emptyToNull(form.image),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: emptyToNull(form.status) ?? 'draft',
visibility: emptyToNull(form.visibility) ?? 'public',
publishAt: emptyToNull(form.publishAt),
unpublishAt: emptyToNull(form.unpublishAt),
canonicalUrl: emptyToNull(form.canonicalUrl),
noindex: form.noindex,
ogImage: emptyToNull(form.ogImage),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: emptyToNull(form.redirectTo),
}
}
@@ -549,7 +666,15 @@ function buildCreateMarkdownForWindow(form: CreatePostFormState) {
image: form.image.trim(),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: form.status.trim() || 'draft',
visibility: form.visibility.trim() || 'public',
publishAt: form.publishAt.trim(),
unpublishAt: form.unpublishAt.trim(),
canonicalUrl: form.canonicalUrl.trim(),
noindex: form.noindex,
ogImage: form.ogImage.trim(),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: form.redirectTo.trim(),
tags: form.tags
.split(',')
.map((item) => item.trim())
@@ -571,7 +696,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
images: parsed.meta.images.length ? parsed.meta.images : parseImageList(form.imagesText),
pinned: parsed.meta.pinned,
published: extractPublishedFlag(markdown),
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFrom: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom
: parseImageList(form.redirectFromText),
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length
? parsed.meta.tags
: form.tags
@@ -591,6 +726,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: nextMarkdown,
}
@@ -609,6 +755,17 @@ function applyPolishedCreateState(form: CreatePostFormState, markdown: string):
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: parsed.body || stripFrontmatter(markdown),
}
@@ -629,6 +786,8 @@ 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)
@@ -642,6 +801,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 [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null)
@@ -896,6 +1057,15 @@ export function PostsPage() {
image: emptyToNull(editor.image),
images: parseImageList(editor.imagesText),
pinned: editor.pinned,
status: emptyToNull(editor.status) ?? 'draft',
visibility: emptyToNull(editor.visibility) ?? 'public',
publishAt: emptyToNull(editor.publishAt),
unpublishAt: emptyToNull(editor.unpublishAt),
canonicalUrl: emptyToNull(editor.canonicalUrl),
noindex: editor.noindex,
ogImage: emptyToNull(editor.ogImage),
redirectFrom: parseImageList(editor.redirectFromText),
redirectTo: emptyToNull(editor.redirectTo),
})
const updatedMarkdown = await adminApi.updatePostMarkdown(editor.slug, persistedMarkdown)
@@ -1082,6 +1252,68 @@ 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 result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setEditor((current) => (current ? { ...current, image: url } : 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))
}
const result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setCreateForm((current) => ({ ...current, image: url }))
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingCreateCover(false)
}
}, [])
const editorPolishHunks = useMemo(
() =>
editorPolish
@@ -1596,6 +1828,32 @@ 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">
@@ -1842,7 +2100,10 @@ export function PostsPage() {
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">{editor.title || editor.slug}</CardTitle>
<Badge variant="secondary">{formatPostType(editor.postType)}</Badge>
<Badge variant="outline">{formatPostStatus(editor.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(editor.visibility)}</Badge>
{editor.pinned ? <Badge variant="success"></Badge> : null}
{editor.noindex ? <Badge variant="warning">noindex</Badge> : null}
{markdownDirty ? <Badge variant="warning"></Badge> : null}
</div>
<CardDescription className="font-mono text-xs">{editor.slug}</CardDescription>
@@ -1912,6 +2173,60 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={editor.status}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, status: event.target.value } : current,
)
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={editor.visibility}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, visibility: event.target.value } : current,
)
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={editor.publishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, publishAt: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={editor.unpublishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, unpublishAt: event.target.value } : current,
)
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={editor.tags}
@@ -1954,10 +2269,18 @@ export function PostsPage() {
/>
</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}
disabled={generatingEditorCover || uploadingEditorCover}
>
<WandSparkles className="h-4 w-4" />
{generatingEditorCover
@@ -1998,6 +2321,64 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空则使用默认文章地址。">
<Input
value={editor.canonicalUrl}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, canonicalUrl: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则前台自动生成 SVG 分享图。">
<Input
value={editor.ogImage}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, ogImage: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug不带 /articles/ 前缀。">
<Textarea
value={editor.redirectFromText}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectFromText: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="强制跳转目标" hint="适合旧文跳新文;留空表示当前 slug 为主地址。">
<Input
value={editor.redirectTo}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectTo: event.target.value } : current,
)
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.noindex}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, noindex: event.target.checked } : current,
)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">noindex</div>
<div className="text-sm text-muted-foreground">
访
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
@@ -2200,6 +2581,8 @@ export function PostsPage() {
<Badge variant="outline">{createForm.markdown.split(/\r?\n/).length} </Badge>
<Badge variant="secondary">AI </Badge>
<Badge variant="outline">AI </Badge>
<Badge variant="outline">{formatPostStatus(createForm.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(createForm.visibility)}</Badge>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-6 text-muted-foreground">
AI slug稿
@@ -2252,6 +2635,52 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={createForm.status}
onChange={(event) =>
setCreateForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={createForm.visibility}
onChange={(event) =>
setCreateForm((current) => ({ ...current, visibility: event.target.value }))
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={createForm.publishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, publishAt: event.target.value }))
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={createForm.unpublishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, unpublishAt: event.target.value }))
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={createForm.tags}
@@ -2291,10 +2720,18 @@ export function PostsPage() {
/>
</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}
disabled={generatingCreateCover || uploadingCreateCover}
>
<WandSparkles className="h-4 w-4" />
{generatingCreateCover
@@ -2333,6 +2770,57 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空时使用默认文章地址。">
<Input
value={createForm.canonicalUrl}
onChange={(event) =>
setCreateForm((current) => ({ ...current, canonicalUrl: event.target.value }))
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则由前台自动生成。">
<Input
value={createForm.ogImage}
onChange={(event) =>
setCreateForm((current) => ({ ...current, ogImage: event.target.value }))
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug。">
<Textarea
value={createForm.redirectFromText}
onChange={(event) =>
setCreateForm((current) => ({
...current,
redirectFromText: event.target.value,
}))
}
/>
</FormField>
<FormField label="强制跳转目标" hint="可选:创建即作为跳转占位。">
<Input
value={createForm.redirectTo}
onChange={(event) =>
setCreateForm((current) => ({ ...current, redirectTo: event.target.value }))
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.noindex}
onChange={(event) =>
setCreateForm((current) => ({ ...current, noindex: event.target.checked }))
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> noindex</div>
<div className="text-sm text-muted-foreground">
sitemap / RSS
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"