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
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:
@@ -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('创建文章时必须填写标题。')
|
||||
|
||||
Reference in New Issue
Block a user