test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s

This commit is contained in:
2026-04-02 00:55:34 +08:00
parent 7de4ddc3ee
commit ee0bec4a78
32 changed files with 5100 additions and 336 deletions

View File

@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const PostPreviewPage = lazy(async () => {
const mod = await import('@/pages/post-preview-page')
return { default: mod.PostPreviewPage }
})
const PostComparePage = lazy(async () => {
const mod = await import('@/pages/post-compare-page')
return { default: mod.PostComparePage }
})
const PostPolishPage = lazy(async () => {
const mod = await import('@/pages/post-polish-page')
return { default: mod.PostPolishPage }
})
const CategoriesPage = lazy(async () => {
const mod = await import('@/pages/categories-page')
return { default: mod.CategoriesPage }
@@ -223,6 +235,56 @@ function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicOnly />} />
<Route
path="/posts/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/polish"
element={
<RequireAuth>
<LazyRoute>
<PostPolishPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/"
element={

View File

@@ -4,7 +4,9 @@ import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
type NativeSelectProps = React.ComponentProps<'select'>
type NativeSelectProps = React.ComponentProps<'select'> & {
'data-testid'?: string
}
type SelectOption = {
value: string
@@ -78,8 +80,11 @@ function getNextEnabledIndex(options: SelectOption[], currentIndex: number, dire
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
className,
'data-testid': dataTestId,
defaultValue,
disabled = false,
id,
@@ -434,6 +439,9 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
className="pointer-events-none absolute h-0 w-0 opacity-0"
defaultValue={defaultValue}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
data-testid={dataTestId}
id={id}
onBlur={onBlur}
onFocus={onFocus}
@@ -454,8 +462,11 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-haspopup="listbox"
className={triggerClasses}
data-testid={dataTestId}
data-state={open ? 'open' : 'closed'}
disabled={disabled}
onBlur={(event) => {

View File

@@ -61,22 +61,38 @@ export function savePolishWindowResult(
return payload
}
export function consumePolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
const raw = window.localStorage.getItem(storageKey)
function parsePolishWindowResult(raw: string | null) {
if (!raw) {
return null
}
window.localStorage.removeItem(storageKey)
try {
return JSON.parse(raw) as PolishWindowResult
} catch {
return null
}
}
export function readPolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
return parsePolishWindowResult(window.localStorage.getItem(storageKey))
}
export function consumePolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
const parsed = parsePolishWindowResult(window.localStorage.getItem(storageKey))
if (!parsed) {
return null
}
window.localStorage.removeItem(storageKey)
return parsed
}

View File

@@ -218,6 +218,7 @@ export function CategoriesPage() {
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="categories-search"
placeholder="按分类名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -229,6 +230,7 @@ export function CategoriesPage() {
<button
key={item.id}
type="button"
data-testid={`category-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
@@ -286,6 +288,7 @@ export function CategoriesPage() {
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="分类名称" hint="例如:前端工程、随笔、工具链。">
<Input
data-testid="category-name-input"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入分类名称"
@@ -293,6 +296,7 @@ export function CategoriesPage() {
</FormField>
<FormField label="分类 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
data-testid="category-slug-input"
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="frontend-engineering"
@@ -377,7 +381,7 @@ export function CategoriesPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Button onClick={() => void handleSave()} disabled={saving} data-testid="category-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
</Button>
@@ -388,6 +392,7 @@ export function CategoriesPage() {
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
data-testid="category-delete"
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />

View File

@@ -898,6 +898,7 @@ export function CommentsPage() {
setManualMatcherValue('')
}}
disabled={!manualMatcherValue.trim()}
data-testid="comment-blacklist-add"
>
<Shield className="h-4 w-4" />
@@ -908,6 +909,7 @@ export function CommentsPage() {
{blacklist.map((item) => (
<div
key={item.id}
data-testid={`blacklist-item-${item.id}`}
className="rounded-2xl border border-border/70 bg-background/40 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -929,6 +931,7 @@ export function CommentsPage() {
size="sm"
variant="outline"
disabled={actingBlacklistId === item.id}
data-testid={`blacklist-toggle-${item.id}`}
onClick={async () => {
try {
setActingBlacklistId(item.id)
@@ -959,6 +962,7 @@ export function CommentsPage() {
size="sm"
variant="danger"
disabled={actingBlacklistId === item.id}
data-testid={`blacklist-delete-${item.id}`}
onClick={async () => {
if (!window.confirm('确定删除这条黑名单规则吗?')) {
return

View File

@@ -58,6 +58,24 @@ const defaultMetadataForm: MediaMetadataFormState = {
notes: '',
}
function normalizeMediaTags(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse {
return {
...item,
tags: normalizeMediaTags(item.tags),
}
}
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
if (!item) {
return defaultMetadataForm
@@ -67,7 +85,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: item.tags.join(', '),
tags: normalizeMediaTags(item.tags).join(', '),
notes: item.notes ?? '',
}
}
@@ -111,8 +129,9 @@ export function MediaPage() {
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
const normalizedItems = result.items.map(normalizeMediaItem)
startTransition(() => {
setItems(result.items)
setItems(normalizedItems)
setProvider(result.provider)
setBucket(result.bucket)
})
@@ -219,6 +238,7 @@ export function MediaPage() {
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
data-testid="media-batch-delete"
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
@@ -267,6 +287,7 @@ export function MediaPage() {
<option value="uploads/"></option>
</Select>
<Input
data-testid="media-search"
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -275,6 +296,7 @@ export function MediaPage() {
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<Input
data-testid="media-upload-input"
type="file"
multiple
accept="image/*"
@@ -300,6 +322,7 @@ export function MediaPage() {
/>
<Button
disabled={!uploadFiles.length || uploading}
data-testid="media-upload"
onClick={async () => {
try {
setUploading(true)
@@ -399,6 +422,7 @@ export function MediaPage() {
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={metadataSaving}
data-testid="media-save-metadata"
onClick={async () => {
if (!activeItem) {
return
@@ -423,7 +447,7 @@ export function MediaPage() {
title: result.title,
alt_text: result.alt_text,
caption: result.caption,
tags: result.tags,
tags: normalizeMediaTags(result.tags),
notes: result.notes,
}
: item,
@@ -473,10 +497,12 @@ export function MediaPage() {
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
const itemTags = normalizeMediaTags(item.tags)
return (
<Card
key={item.key}
data-testid={`media-item-${index}`}
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
>
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
@@ -504,9 +530,9 @@ export function MediaPage() {
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
{item.tags.length ? (
{itemTags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 4).map((tag) => (
{itemTags.slice(0, 4).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
@@ -515,7 +541,12 @@ export function MediaPage() {
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
<Button
size="sm"
variant="outline"
onClick={() => setActiveKey(item.key)}
data-testid={`media-edit-${index}`}
>
</Button>
<Button
@@ -541,6 +572,7 @@ export function MediaPage() {
</Button>
<input
id={replaceInputId}
data-testid={`media-replace-input-${index}`}
className="hidden"
type="file"
accept="image/*"
@@ -583,6 +615,7 @@ export function MediaPage() {
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
data-testid={`media-delete-${index}`}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return

View File

@@ -1,5 +1,6 @@
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
import { Badge } from '@/components/ui/badge'
@@ -17,15 +18,6 @@ type CompareState = {
draftMarkdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
@@ -35,7 +27,8 @@ function getDraftKey() {
}
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const { slug: routeSlug } = useParams<{ slug?: string }>()
const slug = slugOverride ?? routeSlug ?? ''
const [state, setState] = useState<CompareState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -49,6 +42,28 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
setError(null)
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && (!slug || draft.slug === slug)) {
if (!active) {
return
}
startTransition(() => {
setState({
title: draft.title,
slug: draft.slug,
path: draft.path,
savedMarkdown: draft.savedMarkdown,
draftMarkdown: draft.markdown,
})
})
return
}
if (!slug) {
throw new Error('缺少文章 slug无法加载改动对比。')
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),
@@ -63,8 +78,8 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
title: post.title ?? slug,
slug,
path: markdown.path,
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
draftMarkdown: draft?.markdown ?? markdown.markdown,
savedMarkdown: markdown.markdown,
draftMarkdown: markdown.markdown,
})
})
} catch (loadError) {

View File

@@ -1,5 +1,6 @@
import { ExternalLink, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownPreview } from '@/components/markdown-preview'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
@@ -17,15 +18,6 @@ type PreviewState = {
markdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
@@ -35,7 +27,8 @@ function getDraftKey() {
}
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const { slug: routeSlug } = useParams<{ slug?: string }>()
const slug = slugOverride ?? routeSlug ?? ''
const [state, setState] = useState<PreviewState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -50,7 +43,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && draft.slug === slug) {
if (draft && (!slug || draft.slug === slug)) {
if (!active) {
return
}
@@ -66,6 +59,10 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
return
}
if (!slug) {
throw new Error('缺少文章 slug无法加载独立预览。')
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),

View File

@@ -4,9 +4,11 @@ import {
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FilePlus2,
FileUp,
FolderOpen,
GitCompareArrows,
PencilLine,
RefreshCcw,
RotateCcw,
@@ -54,6 +56,13 @@ import {
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
import {
consumePolishWindowResult,
readPolishWindowResult,
saveDraftWindowSnapshot,
type DraftWindowSnapshot,
type PolishWindowResult,
} from '@/lib/post-draft-window'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
import type {
@@ -206,6 +215,14 @@ const defaultCreateForm: CreatePostFormState = {
const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit']
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const
const ADMIN_BASENAME =
((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(/\/$/, '')
const POLISH_RESULT_STORAGE_PREFIX = 'termi-admin-post-polish-result:'
function buildAdminRoute(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${ADMIN_BASENAME}${normalizedPath}` || normalizedPath
}
function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) {
switch (panel) {
@@ -828,6 +845,8 @@ export function PostsPage() {
const [sortKey, setSortKey] = useState('updated_at_desc')
const [totalPosts, setTotalPosts] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const editorPolishDraftKeyRef = useRef<string | null>(null)
const createPolishDraftKeyRef = useRef<string | null>(null)
const { sortBy, sortOrder } = useMemo(() => {
switch (sortKey) {
@@ -930,6 +949,7 @@ export function PostsPage() {
useEffect(() => {
setEditorMode('workspace')
setEditorPanels(defaultWorkbenchPanels)
editorPolishDraftKeyRef.current = null
if (!slug) {
setEditor(null)
@@ -942,6 +962,12 @@ export function PostsPage() {
void loadEditor(slug)
}, [loadEditor, slug])
useEffect(() => {
if (!createDialogOpen) {
createPolishDraftKeyRef.current = null
}
}, [createDialogOpen])
useEffect(() => {
if (!metadataDialog && !slug && !createDialogOpen) {
return
@@ -1024,6 +1050,175 @@ export function PostsPage() {
normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)),
[createForm],
)
const buildEditorDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> | null => {
if (!editor) {
return null
}
return {
title: editor.title.trim() || editor.slug,
slug: editor.slug,
path: editor.path,
markdown: buildDraftMarkdownForWindow(editor),
savedMarkdown: editor.savedMarkdown,
}
}, [editor])
const buildCreateDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> => {
const fallbackSlug = createForm.slug.trim() || 'new-post'
return {
title: createForm.title.trim() || createForm.slug.trim() || '新建草稿',
slug: fallbackSlug,
path: buildVirtualPostPath(fallbackSlug),
markdown: buildCreateMarkdownForWindow(createForm),
savedMarkdown: buildCreateMarkdownForWindow(defaultCreateForm),
}
}, [createForm])
const openDraftWorkbenchWindow = useCallback(
(
path: string,
snapshot: Omit<DraftWindowSnapshot, 'createdAt'>,
extraQuery?: Record<string, string>,
) => {
const draftKey = saveDraftWindowSnapshot(snapshot)
const url = new URL(buildAdminRoute(path), window.location.origin)
url.searchParams.set('draftKey', draftKey)
Object.entries(extraQuery ?? {}).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value)
}
})
const popup = window.open(
url.toString(),
'_blank',
'popup=yes,width=1560,height=980,resizable=yes,scrollbars=yes',
)
if (!popup) {
toast.error('浏览器拦截了独立工作台窗口,请允许当前站点打开新窗口后重试。')
return null
}
popup.focus()
return draftKey
},
[],
)
const applyExternalPolishResult = useCallback(
(result: PolishWindowResult) => {
if (result.target === 'editor') {
if (!editor) {
return false
}
startTransition(() => {
setEditor((current) =>
current ? applyPolishedEditorState(current, result.markdown) : current,
)
setEditorPolish(null)
setEditorMode('workspace')
})
toast.success('独立 AI 润色结果已回填到当前文章。')
return true
}
if (!createDialogOpen) {
return false
}
startTransition(() => {
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
setCreatePolish(null)
setCreateMode('workspace')
})
toast.success('独立 AI 润色结果已回填到新建草稿。')
return true
},
[createDialogOpen, editor],
)
const flushPendingPolishResult = useCallback(
(draftKey: string | null) => {
const pending = readPolishWindowResult(draftKey)
if (!pending || !applyExternalPolishResult(pending)) {
return false
}
consumePolishWindowResult(draftKey)
return true
},
[applyExternalPolishResult],
)
useEffect(() => {
const tryFlushAll = () => {
if (flushPendingPolishResult(editorPolishDraftKeyRef.current)) {
editorPolishDraftKeyRef.current = null
}
if (flushPendingPolishResult(createPolishDraftKeyRef.current)) {
createPolishDraftKeyRef.current = null
}
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin || !event.data) {
return
}
const payload = event.data as Partial<PolishWindowResult> & { type?: string }
if (
payload.type !== 'termi-admin-post-polish-apply' ||
typeof payload.draftKey !== 'string' ||
typeof payload.markdown !== 'string'
) {
return
}
const result: PolishWindowResult = {
draftKey: payload.draftKey,
markdown: payload.markdown,
target: payload.target === 'create' ? 'create' : 'editor',
createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : Date.now(),
}
if (!applyExternalPolishResult(result)) {
return
}
consumePolishWindowResult(result.draftKey)
if (result.target === 'editor') {
editorPolishDraftKeyRef.current = null
} else {
createPolishDraftKeyRef.current = null
}
}
const handleStorage = (event: StorageEvent) => {
if (!event.key?.startsWith(POLISH_RESULT_STORAGE_PREFIX)) {
return
}
tryFlushAll()
}
window.addEventListener('message', handleMessage)
window.addEventListener('storage', handleStorage)
window.addEventListener('focus', tryFlushAll)
tryFlushAll()
return () => {
window.removeEventListener('message', handleMessage)
window.removeEventListener('storage', handleStorage)
window.removeEventListener('focus', tryFlushAll)
}
}, [applyExternalPolishResult, flushPendingPolishResult])
const compareStats = useMemo(() => {
if (!editor) {
return {
@@ -1324,6 +1519,60 @@ export function PostsPage() {
}
}, [])
const openEditorPreviewWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立预览窗口。')
return
}
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/preview`, snapshot)
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openEditorCompareWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立对比窗口。')
return
}
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/compare`, snapshot)
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openEditorPolishWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立 AI 润色工作台。')
return
}
const draftKey = openDraftWorkbenchWindow('/posts/polish', snapshot, {
target: 'editor',
})
if (draftKey) {
editorPolishDraftKeyRef.current = draftKey
}
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openCreatePreviewWindow = useCallback(() => {
openDraftWorkbenchWindow('/posts/preview', buildCreateDraftSnapshot())
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const openCreateCompareWindow = useCallback(() => {
openDraftWorkbenchWindow('/posts/compare', buildCreateDraftSnapshot())
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const openCreatePolishWindow = useCallback(() => {
const draftKey = openDraftWorkbenchWindow('/posts/polish', buildCreateDraftSnapshot(), {
target: 'create',
})
if (draftKey) {
createPolishDraftKeyRef.current = draftKey
}
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const editorPolishHunks = useMemo(
() =>
editorPolish
@@ -1877,7 +2126,7 @@ export function PostsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={openCreateDialog}>
<Button variant="outline" onClick={openCreateDialog} data-testid="posts-open-create">
<FilePlus2 className="h-4 w-4" />
稿
</Button>
@@ -1919,6 +2168,7 @@ export function PostsPage() {
<div className="grid gap-3">
<div className="flex flex-col gap-3 lg:flex-row">
<Input
data-testid="posts-search"
className="flex-1"
placeholder="搜索标题、slug、分类、标签或摘要"
value={searchTerm}
@@ -1990,6 +2240,7 @@ export function PostsPage() {
<button
key={post.id}
type="button"
data-testid={`post-item-${post.slug}`}
onClick={() => navigate(`/posts/${post.slug}`)}
className={cn(
'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all',
@@ -2099,7 +2350,7 @@ export function PostsPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={closeEditorDialog}>
<Button variant="outline" onClick={closeEditorDialog} data-testid="post-editor-close">
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -2148,6 +2399,7 @@ export function PostsPage() {
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-editor-title"
value={editor.title}
onChange={(event) =>
setEditor((current) =>
@@ -2439,6 +2691,18 @@ export function PostsPage() {
<Bot className="h-4 w-4" />
{generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'}
</Button>
<Button variant="outline" onClick={openEditorPreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorPolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => {
@@ -2474,11 +2738,12 @@ export function PostsPage() {
<RotateCcw className="h-4 w-4" />
</Button>
<Button onClick={() => void saveEditor()} disabled={saving}>
<Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</Button>
<Button
data-testid="post-editor-delete"
variant="danger"
onClick={async () => {
if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) {
@@ -2614,6 +2879,7 @@ export function PostsPage() {
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-create-title"
value={createForm.title}
onChange={(event) =>
setCreateForm((current) => ({ ...current, title: event.target.value }))
@@ -2622,6 +2888,7 @@ export function PostsPage() {
</FormField>
<FormField label="Slug" hint="留空则根据标题自动生成。">
<Input
data-testid="post-create-slug"
value={createForm.slug}
onChange={(event) =>
setCreateForm((current) => ({ ...current, slug: event.target.value }))
@@ -2871,6 +3138,18 @@ export function PostsPage() {
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={openCreatePreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreateCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreatePolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => {
@@ -2907,6 +3186,7 @@ export function PostsPage() {
</Button>
<Button
data-testid="post-create-submit"
onClick={async () => {
if (!createForm.title.trim()) {
toast.error('创建文章时必须填写标题。')

View File

@@ -305,6 +305,7 @@ export function ReviewsPage() {
<button
key={review.id}
type="button"
data-testid={`review-item-${review.id}`}
onClick={() => {
setSelectedId(review.id)
setForm(toFormState(review))
@@ -363,6 +364,7 @@ export function ReviewsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="review-save"
onClick={async () => {
if (!form.title.trim()) {
toast.error('标题不能为空。')
@@ -411,6 +413,7 @@ export function ReviewsPage() {
{selectedReview ? (
<Button
variant="danger"
data-testid="review-delete"
disabled={deleting}
onClick={async () => {
if (!window.confirm('确定删除这条评测吗?')) {
@@ -453,6 +456,7 @@ export function ReviewsPage() {
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="标题">
<Input
data-testid="review-title"
value={form.title}
onChange={(event) =>
setForm((current) => ({ ...current, title: event.target.value }))
@@ -487,6 +491,7 @@ export function ReviewsPage() {
</FormField>
<FormField label="评测日期">
<Input
data-testid="review-date"
type="date"
value={form.reviewDate}
onChange={(event) =>
@@ -583,6 +588,7 @@ export function ReviewsPage() {
<Button
size="sm"
variant="outline"
data-testid="review-ai-polish"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
@@ -603,6 +609,7 @@ export function ReviewsPage() {
</div>
<Textarea
data-testid="review-description"
value={form.description}
onChange={(event) => {
const nextDescription = event.target.value
@@ -625,6 +632,7 @@ export function ReviewsPage() {
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
data-testid="review-ai-adopt"
onClick={() => {
setForm((current) => ({
...current,

View File

@@ -248,6 +248,7 @@ export function RevisionsPage() {
<div className="flex flex-wrap items-center gap-3">
<Input
data-testid="revisions-slug-filter"
value={slugFilter}
onChange={(event) => setSlugFilter(event.target.value)}
placeholder="按 slug 过滤,例如 hello-world"
@@ -304,7 +305,12 @@ export function RevisionsPage() {
</TableCell>
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
<Button
variant="outline"
size="sm"
onClick={() => void openDetail(item.id)}
data-testid={`revision-open-${item.id}`}
>
<History className="h-4 w-4" />
/
</Button>
@@ -371,6 +377,7 @@ export function RevisionsPage() {
key={mode}
size="sm"
disabled={restoring !== null || !selected.item.has_markdown}
data-testid={`revision-restore-${mode}`}
onClick={() => void runRestore(mode)}
>
<RotateCcw className="h-4 w-4" />

View File

@@ -485,13 +485,14 @@ export function SiteSettingsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadSettings(true)}>
<Button variant="outline" onClick={() => void loadSettings(true)} data-testid="site-settings-refresh">
<RefreshCcw className="h-4 w-4" />
</Button>
<Button
variant="secondary"
disabled={reindexing}
data-testid="site-settings-reindex"
onClick={async () => {
try {
setReindexing(true)
@@ -510,6 +511,7 @@ export function SiteSettingsPage() {
</Button>
<Button
disabled={saving}
data-testid="site-settings-save"
onClick={async () => {
try {
setSaving(true)
@@ -543,6 +545,7 @@ export function SiteSettingsPage() {
<CardContent className="grid gap-6 lg:grid-cols-2">
<Field label="站点名称">
<Input
data-testid="site-settings-site-name"
value={form.site_name ?? ''}
onChange={(event) => updateField('site_name', event.target.value)}
/>
@@ -724,6 +727,7 @@ export function SiteSettingsPage() {
<div className="grid gap-4 lg:grid-cols-2">
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
<Input
data-testid="site-settings-popup-title"
value={form.subscription_popup_title}
onChange={(event) =>
updateField('subscription_popup_title', event.target.value)
@@ -1105,6 +1109,7 @@ export function SiteSettingsPage() {
<Button
type="button"
variant="outline"
data-testid="site-settings-test-provider"
disabled={testingProvider}
onClick={async () => {
try {
@@ -1243,6 +1248,7 @@ export function SiteSettingsPage() {
<Button
type="button"
variant="outline"
data-testid="site-settings-test-image-provider"
disabled={testingImageProvider}
onClick={async () => {
try {
@@ -1396,6 +1402,7 @@ export function SiteSettingsPage() {
<Button
type="button"
variant="outline"
data-testid="site-settings-test-storage"
disabled={testingR2Storage}
onClick={async () => {
try {

View File

@@ -188,6 +188,7 @@ export function SubscriptionsPage() {
<Button
variant="secondary"
disabled={digesting !== null}
data-testid="subscriptions-send-weekly"
onClick={async () => {
try {
setDigesting('weekly')
@@ -206,6 +207,7 @@ export function SubscriptionsPage() {
</Button>
<Button
disabled={digesting !== null}
data-testid="subscriptions-send-monthly"
onClick={async () => {
try {
setDigesting('monthly')
@@ -314,7 +316,12 @@ export function SubscriptionsPage() {
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
<Button
className="flex-1"
disabled={submitting}
onClick={() => void submitForm()}
data-testid="subscriptions-save"
>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
@@ -349,7 +356,7 @@ export function SubscriptionsPage() {
</TableHeader>
<TableBody>
{subscriptions.map((item) => (
<TableRow key={item.id}>
<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>
@@ -382,6 +389,7 @@ export function SubscriptionsPage() {
<Button
variant="outline"
size="sm"
data-testid={`subscription-edit-${item.id}`}
onClick={() => {
setEditingId(item.id)
setForm({
@@ -402,6 +410,7 @@ export function SubscriptionsPage() {
variant="outline"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-test-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
@@ -422,6 +431,7 @@ export function SubscriptionsPage() {
variant="ghost"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-delete-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)

View File

@@ -218,6 +218,7 @@ export function TagsPage() {
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="tags-search"
placeholder="按标签名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -229,6 +230,7 @@ export function TagsPage() {
<button
key={item.id}
type="button"
data-testid={`tag-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
@@ -286,6 +288,7 @@ export function TagsPage() {
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="标签名称" hint="例如astro、rust、workflow。">
<Input
data-testid="tag-name-input"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入标签名称"
@@ -293,6 +296,7 @@ export function TagsPage() {
</FormField>
<FormField label="标签 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
data-testid="tag-slug-input"
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="astro"
@@ -377,7 +381,7 @@ export function TagsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Button onClick={() => void handleSave()} disabled={saving} data-testid="tag-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
</Button>
@@ -388,6 +392,7 @@ export function TagsPage() {
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
data-testid="tag-delete"
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />