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

@@ -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('创建文章时必须填写标题。')