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

@@ -0,0 +1,167 @@
name: ui-regression
on:
push:
branches:
- main
- master
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
pull_request:
paths:
- admin/**
- frontend/**
- playwright-smoke/**
- .gitea/workflows/ui-regression.yml
workflow_dispatch:
jobs:
playwright-regression:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: |
frontend/pnpm-lock.yaml
admin/pnpm-lock.yaml
playwright-smoke/pnpm-lock.yaml
- name: Install frontend deps
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install admin deps
working-directory: admin
run: pnpm install --frozen-lockfile
- name: Install Playwright deps
working-directory: playwright-smoke
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium
- name: Typecheck Playwright suite
working-directory: playwright-smoke
run: pnpm exec tsc -p tsconfig.json --noEmit
- name: Prepare Playwright artifact folders
run: |
rm -rf playwright-smoke/.artifacts
mkdir -p playwright-smoke/.artifacts/frontend
mkdir -p playwright-smoke/.artifacts/admin
- name: Run frontend UI regression suite
id: ui_frontend
working-directory: playwright-smoke
continue-on-error: true
run: pnpm test:frontend
- name: Collect frontend Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
fi
rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
- name: Run admin UI regression suite
id: ui_admin
working-directory: playwright-smoke
continue-on-error: true
run: pnpm test:admin
- name: Collect admin Playwright artifacts
if: always()
run: |
if [ -d playwright-smoke/playwright-report ]; then
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
fi
if [ -d playwright-smoke/test-results ]; then
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
fi
- name: Upload frontend HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-html-report-frontend
path: playwright-smoke/.artifacts/frontend/playwright-report
retention-days: 14
if-no-files-found: ignore
- name: Upload admin HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-html-report-admin
path: playwright-smoke/.artifacts/admin/playwright-report
retention-days: 14
if-no-files-found: ignore
- name: Upload frontend raw results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-raw-results-frontend
path: playwright-smoke/.artifacts/frontend/test-results
retention-days: 14
if-no-files-found: ignore
- name: Upload admin raw results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-raw-results-admin
path: playwright-smoke/.artifacts/admin/test-results
retention-days: 14
if-no-files-found: ignore
- name: Upload frontend failure screenshots / videos / traces
if: steps.ui_frontend.outcome != 'success'
uses: actions/upload-artifact@v4
with:
name: playwright-failure-artifacts-frontend
path: |
playwright-smoke/.artifacts/frontend/test-results/**/*.png
playwright-smoke/.artifacts/frontend/test-results/**/*.webm
playwright-smoke/.artifacts/frontend/test-results/**/*.zip
playwright-smoke/.artifacts/frontend/test-results/**/error-context.md
retention-days: 21
if-no-files-found: ignore
- name: Upload admin failure screenshots / videos / traces
if: steps.ui_admin.outcome != 'success'
uses: actions/upload-artifact@v4
with:
name: playwright-failure-artifacts-admin
path: |
playwright-smoke/.artifacts/admin/test-results/**/*.png
playwright-smoke/.artifacts/admin/test-results/**/*.webm
playwright-smoke/.artifacts/admin/test-results/**/*.zip
playwright-smoke/.artifacts/admin/test-results/**/error-context.md
retention-days: 21
if-no-files-found: ignore
- name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1

View File

@@ -22,7 +22,7 @@ Monorepo for the Termi blog system.
From the repository root: From the repository root:
```powershell ```powershell
npm run dev pnpm dev
``` ```
This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs. This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
@@ -30,13 +30,14 @@ This starts `frontend + admin + backend` in a single Windows Terminal window wit
Common shortcuts: Common shortcuts:
```powershell ```powershell
npm run dev:mcp pnpm dev:mcp
npm run dev:frontend pnpm dev:frontend
npm run dev:admin pnpm dev:admin
npm run dev:backend pnpm dev:backend
npm run dev:mcp-only pnpm dev:mcp-only
npm run stop pnpm stop
npm run restart pnpm restart
pnpm test:ui
``` ```
### PowerShell entrypoint ### PowerShell entrypoint

View File

@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page') const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage } 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 CategoriesPage = lazy(async () => {
const mod = await import('@/pages/categories-page') const mod = await import('@/pages/categories-page')
return { default: mod.CategoriesPage } return { default: mod.CategoriesPage }
@@ -223,6 +235,56 @@ function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<PublicOnly />} /> <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 <Route
path="/" path="/"
element={ element={

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { GitCompareArrows, RefreshCcw } from 'lucide-react' import { GitCompareArrows, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownWorkbench } from '@/components/markdown-workbench' import { MarkdownWorkbench } from '@/components/markdown-workbench'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -17,15 +18,6 @@ type CompareState = {
draftMarkdown: string 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() { function getDraftKey() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null return null
@@ -35,7 +27,8 @@ function getDraftKey() {
} }
export function PostComparePage({ slugOverride }: { slugOverride?: string }) { 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 [state, setState] = useState<CompareState | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -49,6 +42,28 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
setError(null) setError(null)
const draft = loadDraftWindowSnapshot(getDraftKey()) 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([ const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug), adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug), adminApi.getPostMarkdown(slug),
@@ -63,8 +78,8 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
title: post.title ?? slug, title: post.title ?? slug,
slug, slug,
path: markdown.path, path: markdown.path,
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown, savedMarkdown: markdown.markdown,
draftMarkdown: draft?.markdown ?? markdown.markdown, draftMarkdown: markdown.markdown,
}) })
}) })
} catch (loadError) { } catch (loadError) {

View File

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

View File

@@ -4,9 +4,11 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Download, Download,
ExternalLink,
FilePlus2, FilePlus2,
FileUp, FileUp,
FolderOpen, FolderOpen,
GitCompareArrows,
PencilLine, PencilLine,
RefreshCcw, RefreshCcw,
RotateCcw, RotateCcw,
@@ -54,6 +56,13 @@ import {
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document' import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff' import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge' 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 { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { import type {
@@ -206,6 +215,14 @@ const defaultCreateForm: CreatePostFormState = {
const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit'] const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit']
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff'] const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const 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) { function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) {
switch (panel) { switch (panel) {
@@ -828,6 +845,8 @@ export function PostsPage() {
const [sortKey, setSortKey] = useState('updated_at_desc') const [sortKey, setSortKey] = useState('updated_at_desc')
const [totalPosts, setTotalPosts] = useState(0) const [totalPosts, setTotalPosts] = useState(0)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
const editorPolishDraftKeyRef = useRef<string | null>(null)
const createPolishDraftKeyRef = useRef<string | null>(null)
const { sortBy, sortOrder } = useMemo(() => { const { sortBy, sortOrder } = useMemo(() => {
switch (sortKey) { switch (sortKey) {
@@ -930,6 +949,7 @@ export function PostsPage() {
useEffect(() => { useEffect(() => {
setEditorMode('workspace') setEditorMode('workspace')
setEditorPanels(defaultWorkbenchPanels) setEditorPanels(defaultWorkbenchPanels)
editorPolishDraftKeyRef.current = null
if (!slug) { if (!slug) {
setEditor(null) setEditor(null)
@@ -942,6 +962,12 @@ export function PostsPage() {
void loadEditor(slug) void loadEditor(slug)
}, [loadEditor, slug]) }, [loadEditor, slug])
useEffect(() => {
if (!createDialogOpen) {
createPolishDraftKeyRef.current = null
}
}, [createDialogOpen])
useEffect(() => { useEffect(() => {
if (!metadataDialog && !slug && !createDialogOpen) { if (!metadataDialog && !slug && !createDialogOpen) {
return return
@@ -1024,6 +1050,175 @@ export function PostsPage() {
normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)), normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)),
[createForm], [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(() => { const compareStats = useMemo(() => {
if (!editor) { if (!editor) {
return { 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( const editorPolishHunks = useMemo(
() => () =>
editorPolish editorPolish
@@ -1877,7 +2126,7 @@ export function PostsPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <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" /> <FilePlus2 className="h-4 w-4" />
稿 稿
</Button> </Button>
@@ -1919,6 +2168,7 @@ export function PostsPage() {
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex flex-col gap-3 lg:flex-row"> <div className="flex flex-col gap-3 lg:flex-row">
<Input <Input
data-testid="posts-search"
className="flex-1" className="flex-1"
placeholder="搜索标题、slug、分类、标签或摘要" placeholder="搜索标题、slug、分类、标签或摘要"
value={searchTerm} value={searchTerm}
@@ -1990,6 +2240,7 @@ export function PostsPage() {
<button <button
key={post.id} key={post.id}
type="button" type="button"
data-testid={`post-item-${post.slug}`}
onClick={() => navigate(`/posts/${post.slug}`)} onClick={() => navigate(`/posts/${post.slug}`)}
className={cn( className={cn(
'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all', '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> </div>
<div className="flex flex-wrap items-center gap-2"> <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" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@@ -2148,6 +2399,7 @@ export function PostsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormField label="标题"> <FormField label="标题">
<Input <Input
data-testid="post-editor-title"
value={editor.title} value={editor.title}
onChange={(event) => onChange={(event) =>
setEditor((current) => setEditor((current) =>
@@ -2439,6 +2691,18 @@ export function PostsPage() {
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
{generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'} {generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'}
</Button> </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 <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@@ -2474,11 +2738,12 @@ export function PostsPage() {
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
</Button> </Button>
<Button onClick={() => void saveEditor()} disabled={saving}> <Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'} {saving ? '保存中...' : '保存'}
</Button> </Button>
<Button <Button
data-testid="post-editor-delete"
variant="danger" variant="danger"
onClick={async () => { onClick={async () => {
if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) { if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) {
@@ -2614,6 +2879,7 @@ export function PostsPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormField label="标题"> <FormField label="标题">
<Input <Input
data-testid="post-create-title"
value={createForm.title} value={createForm.title}
onChange={(event) => onChange={(event) =>
setCreateForm((current) => ({ ...current, title: event.target.value })) setCreateForm((current) => ({ ...current, title: event.target.value }))
@@ -2622,6 +2888,7 @@ export function PostsPage() {
</FormField> </FormField>
<FormField label="Slug" hint="留空则根据标题自动生成。"> <FormField label="Slug" hint="留空则根据标题自动生成。">
<Input <Input
data-testid="post-create-slug"
value={createForm.slug} value={createForm.slug}
onChange={(event) => onChange={(event) =>
setCreateForm((current) => ({ ...current, slug: event.target.value })) setCreateForm((current) => ({ ...current, slug: event.target.value }))
@@ -2871,6 +3138,18 @@ export function PostsPage() {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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 <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@@ -2907,6 +3186,7 @@ export function PostsPage() {
</Button> </Button>
<Button <Button
data-testid="post-create-submit"
onClick={async () => { onClick={async () => {
if (!createForm.title.trim()) { if (!createForm.title.trim()) {
toast.error('创建文章时必须填写标题。') toast.error('创建文章时必须填写标题。')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import type { Review } from './api/client';
export type ReviewStatus = 'completed' | 'in-progress' | 'dropped';
export type ParsedReview = Omit<Review, 'tags'> & {
tags: string[];
normalizedStatus: ReviewStatus;
coverIsImage: boolean;
coverUrl: string | null;
linkUrl: string | null;
externalLink: boolean;
};
const reviewCoverCatalog: Record<string, string> = {
'《漫长的季节》': '/review-covers/the-long-season.svg',
'《十三邀》': '/review-covers/thirteen-invites.svg',
'《黑神话:悟空》': '/review-covers/black-myth-wukong.svg',
'《置身事内》': '/review-covers/placed-within.svg',
'《宇宙探索编辑部》': '/review-covers/journey-to-the-west-editorial.svg',
'《疲惫生活中的英雄梦想》': '/review-covers/hero-dreams-in-tired-life.svg',
};
export function normalizeReviewStatus(status: string | null | undefined): ReviewStatus {
const normalized = String(status || '').trim().toLowerCase();
if (normalized === 'published' || normalized === 'completed' || normalized === 'done') {
return 'completed';
}
if (
normalized === 'draft' ||
normalized === 'in-progress' ||
normalized === 'watching' ||
normalized === 'reading' ||
normalized === 'listening'
) {
return 'in-progress';
}
if (normalized === 'dropped' || normalized === 'abandoned') {
return 'dropped';
}
return 'completed';
}
export function parseReviewTags(value: string | null | undefined): string[] {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
export function isImageCover(cover: string | null | undefined) {
const normalized = String(cover || '').trim();
return /^(https?:)?\/\//.test(normalized) || normalized.startsWith('/');
}
export function resolveReviewCover(review: Pick<Review, 'cover' | 'title'>) {
if (isImageCover(review.cover)) {
return String(review.cover).trim();
}
return reviewCoverCatalog[review.title] || null;
}
export function normalizeLinkUrl(value: string | null | undefined) {
const trimmed = String(value || '').trim();
return trimmed ? trimmed : null;
}
export function isExternalLink(value: string | null | undefined) {
return /^(https?:)?\/\//.test(String(value || '').trim());
}
export function parseReview(review: Review): ParsedReview {
return {
...review,
tags: parseReviewTags(review.tags),
normalizedStatus: normalizeReviewStatus(review.status),
coverIsImage: isImageCover(review.cover),
coverUrl: resolveReviewCover(review),
linkUrl: normalizeLinkUrl(review.link_url),
externalLink: isExternalLink(review.link_url),
};
}

View File

@@ -1,194 +0,0 @@
import type { Post, Category, Tag, FriendLink } from '../types';
// Mock data for static site generation
export const mockPosts: Post[] = [
{
id: '1',
slug: 'welcome-to-termi',
title: '欢迎来到 Termi 终端博客',
description: '这是一个基于终端风格的现代博客平台,结合了极客美学与极致性能。',
date: '2024-03-20',
readTime: '3 分钟',
type: 'article',
tags: ['astro', 'svelte', 'tailwind'],
category: '技术',
pinned: true,
image: 'https://picsum.photos/1200/600?random=1'
},
{
id: '2',
slug: 'astro-ssg-guide',
title: 'Astro 静态站点生成指南',
description: '学习如何使用 Astro 构建高性能的静态网站,掌握群岛架构的核心概念。',
date: '2024-03-18',
readTime: '5 分钟',
type: 'article',
tags: ['astro', 'ssg', 'performance'],
category: '前端'
},
{
id: '3',
slug: 'tailwind-v4-features',
title: 'Tailwind CSS v4 新特性解析',
description: '探索 Tailwind CSS v4 带来的全新特性,包括改进的性能和更简洁的配置。',
date: '2024-03-15',
readTime: '4 分钟',
type: 'article',
tags: ['tailwind', 'css', 'design'],
category: '前端'
},
{
id: '4',
slug: 'daily-thought-1',
title: '关于代码与咖啡的思考',
description: '写代码就像冲咖啡,需要耐心和恰到好处的温度。今天尝试了几款新豆子,每一杯都有不同的风味。',
date: '2024-03-14',
readTime: '1 分钟',
type: 'tweet',
tags: ['life', 'coding'],
category: '随笔',
images: [
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400&h=400&fit=crop',
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400&h=400&fit=crop',
'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?w=400&h=400&fit=crop'
]
},
{
id: '5',
slug: 'svelte-5-runes',
title: 'Svelte 5 Runes 完全指南',
description: '深入了解 Svelte 5 的 Runes 系统,掌握下一代响应式编程范式。',
date: '2024-03-10',
readTime: '8 分钟',
type: 'article',
tags: ['svelte', 'javascript', 'frontend'],
category: '前端'
}
];
export const mockCategories: Category[] = [
{ id: '1', name: '技术', slug: 'tech', icon: 'fa-code', count: 3 },
{ id: '2', name: '前端', slug: 'frontend', icon: 'fa-laptop-code', count: 3 },
{ id: '3', name: '随笔', slug: 'essay', icon: 'fa-pen', count: 1 },
{ id: '4', name: '生活', slug: 'life', icon: 'fa-coffee', count: 1 }
];
export const mockTags: Tag[] = [
{ id: '1', name: 'astro', slug: 'astro', count: 1 },
{ id: '2', name: 'svelte', slug: 'svelte', count: 2 },
{ id: '3', name: 'tailwind', slug: 'tailwind', count: 2 },
{ id: '4', name: 'frontend', slug: 'frontend', count: 2 },
{ id: '5', name: 'ssg', slug: 'ssg', count: 1 },
{ id: '6', name: 'css', slug: 'css', count: 1 },
{ id: '7', name: 'javascript', slug: 'javascript', count: 1 },
{ id: '8', name: 'life', slug: 'life', count: 1 },
{ id: '9', name: 'coding', slug: 'coding', count: 1 }
];
export const mockFriendLinks: FriendLink[] = [
{
id: '1',
name: 'Astro 官网',
url: 'https://astro.build',
avatar: 'https://astro.build/favicon.svg',
description: '极速内容驱动的网站框架',
category: '技术博客'
},
{
id: '2',
name: 'Svelte 官网',
url: 'https://svelte.dev',
avatar: 'https://svelte.dev/favicon.png',
description: '控制论增强的 Web 应用',
category: '技术博客'
},
{
id: '3',
name: 'Tailwind CSS',
url: 'https://tailwindcss.com',
avatar: 'https://tailwindcss.com/favicons/favicon-32x32.png',
description: '实用优先的 CSS 框架',
category: '技术博客'
}
];
export const mockSiteConfig = {
name: 'Termi',
description: '终端风格的内容平台',
author: 'InitCool',
url: 'https://termi.dev',
social: {
github: 'https://github.com',
twitter: 'https://twitter.com',
email: 'mailto:hello@termi.dev'
}
};
export const mockSystemStats = [
{ label: 'Last Update', value: '2024-03-20' },
{ label: 'Posts', value: '12' },
{ label: 'Visitors', value: '1.2k' }
];
export const mockTechStack = [
{ name: 'Astro' },
{ name: 'Svelte' },
{ name: 'Tailwind CSS' },
{ name: 'TypeScript' },
{ name: 'Vercel' }
];
export const mockHomeAboutIntro = '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。';
// Helper functions
export function getPinnedPost(): Post | null {
return mockPosts.find(p => p.pinned) || null;
}
export function getRecentPosts(limit: number = 5): Post[] {
return mockPosts.slice(0, limit);
}
export function getAllPosts(): Post[] {
return mockPosts;
}
export function getPostBySlug(slug: string): Post | undefined {
return mockPosts.find(p => p.slug === slug);
}
export function getPostsByTag(tag: string): Post[] {
return mockPosts.filter(p => p.tags.includes(tag));
}
export function getPostsByCategory(category: string): Post[] {
return mockPosts.filter(p => p.category === category);
}
export function getAllCategories(): Category[] {
return mockCategories;
}
export function getAllTags(): Tag[] {
return mockTags;
}
export function getAllFriendLinks(): FriendLink[] {
return mockFriendLinks;
}
export function getSiteConfig() {
return mockSiteConfig;
}
export function getSystemStats() {
return mockSystemStats;
}
export function getTechStack() {
return mockTechStack;
}
export function getHomeAboutIntro() {
return mockHomeAboutIntro;
}

View File

@@ -0,0 +1,384 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { parseReview } from '../../lib/reviews';
export const prerender = false;
const { locale, t } = getI18n(Astro);
const copy =
locale === 'en'
? {
summary: 'Review note',
metadata: 'Metadata',
notes: 'Notes',
tags: 'Tags',
back: 'Back to reviews',
openLink: 'Open related link',
notFoundTitle: 'Review not found',
notFoundDescription:
'The requested review does not exist or is temporarily unavailable.',
rating: 'Rating',
type: 'Type',
status: 'Status',
reviewDate: 'Review date',
updatedAt: 'Updated at',
}
: {
summary: '评价摘要',
metadata: '元信息',
notes: '记录内容',
tags: '标签',
back: '返回评价列表',
openLink: '打开相关链接',
notFoundTitle: '评价不存在',
notFoundDescription: '当前请求的评价不存在,或者暂时无法从后端读取。',
rating: '评分',
type: '类型',
status: '状态',
reviewDate: '记录日期',
updatedAt: '更新时间',
};
let siteSettings = DEFAULT_SITE_SETTINGS;
try {
siteSettings = await apiClient.getSiteSettings();
} catch (error) {
console.error('Failed to load site settings for review detail:', error);
}
const reviewId = Number(Astro.params.id);
let review = null;
if (Number.isFinite(reviewId)) {
try {
review = parseReview(await apiClient.getReview(reviewId));
} catch (error) {
console.error(`Failed to load review ${reviewId}:`, error);
}
}
if (!review) {
Astro.response.status = 404;
}
const typeLabels: Record<string, string> = {
game: t('reviews.typeGame'),
anime: t('reviews.typeAnime'),
music: t('reviews.typeMusic'),
book: t('reviews.typeBook'),
movie: t('reviews.typeMovie'),
};
const statusLabels = {
completed: t('reviews.statusCompleted'),
'in-progress': t('reviews.statusInProgress'),
dropped: t('reviews.statusDropped'),
};
const pageTitle = review
? `${review.title} | ${t('reviews.title')} | ${siteSettings.siteShortName}`
: `${copy.notFoundTitle} | ${siteSettings.siteShortName}`;
const pageDescription = review?.description || copy.notFoundDescription;
const canonical = review ? `/reviews/${review.id}` : '/reviews';
const jsonLd = review
? {
'@context': 'https://schema.org',
'@type': 'Review',
name: review.title,
reviewBody: review.description,
datePublished: review.review_date,
dateModified: review.updated_at,
reviewRating: {
'@type': 'Rating',
ratingValue: review.rating,
bestRating: 5,
worstRating: 1,
},
itemReviewed: {
'@type': 'CreativeWork',
name: review.title,
genre: typeLabels[review.review_type] || review.review_type,
},
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
}
: undefined;
---
<BaseLayout
title={pageTitle}
description={pageDescription}
siteSettings={siteSettings}
canonical={canonical}
ogImage={review?.coverUrl || undefined}
ogType={review ? 'article' : 'website'}
jsonLd={jsonLd}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={review ? `~/reviews/${review.id}` : '~/reviews/not-found'} class="w-full">
<div class="space-y-6 px-4 py-4">
<div class="space-y-4">
<CommandPrompt
command={review ? `sed -n '1,160p' ./reviews/${review.id}.md` : 'ls ./reviews'}
path="~/reviews"
/>
<div class="terminal-panel ml-4">
<div class="terminal-kicker">{copy.summary}</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class={`fas ${review ? 'fa-star' : 'fa-triangle-exclamation'}`}></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">
{review ? review.title : copy.notFoundTitle}
</h1>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{review ? review.description : copy.notFoundDescription}
</p>
</div>
</div>
</div>
</div>
{review ? (
<>
<div>
<CommandPrompt command="jq '.meta' review.json" path="~/reviews" />
<div class="review-detail-shell ml-4 mt-2">
<div class="review-detail-cover terminal-panel">
{review.coverUrl ? (
<ResponsiveImage
src={review.coverUrl}
alt={`${review.title} cover`}
pictureClass="block h-full w-full"
imgClass="review-detail-cover__image"
widths={[640, 960, 1280, 1600]}
sizes="(min-width: 1280px) 42rem, 100vw"
/>
) : (
<div class="review-detail-cover__fallback">
<div class="review-detail-cover__year">{review.review_date.slice(0, 4)}</div>
<div class="review-detail-cover__emoji">{review.cover || '★'}</div>
<div class="review-detail-cover__title">{review.title}</div>
</div>
)}
</div>
<div class="space-y-4">
<div class="terminal-panel review-detail-card">
<div class="review-detail-card__header">
<span class="terminal-kicker">{copy.metadata}</span>
<div class="review-detail-card__rating">
<strong>{review.rating.toFixed(1)}</strong>
<span>/ 5</span>
</div>
</div>
<div class="review-detail-meta-grid">
<div>
<div class="review-detail-meta-grid__label">{copy.type}</div>
<div>{typeLabels[review.review_type] || review.review_type}</div>
</div>
<div>
<div class="review-detail-meta-grid__label">{copy.status}</div>
<div>{statusLabels[review.normalizedStatus]}</div>
</div>
<div>
<div class="review-detail-meta-grid__label">{copy.reviewDate}</div>
<div>{review.review_date}</div>
</div>
<div>
<div class="review-detail-meta-grid__label">{copy.updatedAt}</div>
<div>{review.updated_at}</div>
</div>
</div>
</div>
<div class="terminal-panel review-detail-card">
<div class="terminal-kicker">{copy.tags}</div>
<div class="review-detail-tags mt-4">
{review.tags.length ? (
review.tags.map((tag) => (
<span class="terminal-chip text-xs py-1 px-2.5">#{tag}</span>
))
) : (
<span class="text-sm text-[var(--text-secondary)]">{t('common.noData')}</span>
)}
</div>
</div>
<div class="review-detail-actions">
{review.linkUrl && (
<a
href={review.linkUrl}
class="terminal-action-button"
target={review.externalLink ? '_blank' : undefined}
rel={review.externalLink ? 'noreferrer noopener' : undefined}
>
<i class={`fas ${review.externalLink ? 'fa-arrow-up-right-from-square' : 'fa-arrow-right'}`}></i>
<span>{copy.openLink}</span>
</a>
)}
<a href="/reviews" class="terminal-subtle-link">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">{copy.back}</span>
</a>
</div>
</div>
</div>
</div>
<div>
<CommandPrompt command="cat review.md" path="~/reviews" />
<div class="terminal-panel ml-4 mt-2 review-detail-card">
<div class="terminal-kicker">{copy.notes}</div>
<div class="review-detail-body mt-4">
{review.description}
</div>
</div>
</div>
</>
) : (
<div class="ml-4 rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/60 px-5 py-8">
<p class="text-sm leading-7 text-[var(--text-secondary)]">
{copy.notFoundDescription}
</p>
<a href="/reviews" class="terminal-subtle-link mt-4 inline-flex">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">{copy.back}</span>
</a>
</div>
)}
</div>
</TerminalWindow>
</div>
</BaseLayout>
<style>
.review-detail-shell {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
}
.review-detail-cover {
overflow: hidden;
min-height: 22rem;
padding: 0;
}
.review-detail-cover__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.review-detail-cover__fallback {
min-height: 22rem;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1.5rem;
background:
linear-gradient(160deg, color-mix(in oklab, var(--primary) 18%, var(--terminal-bg)), color-mix(in oklab, var(--secondary) 12%, var(--header-bg)) 48%, color-mix(in oklab, var(--terminal-bg) 96%, transparent)),
radial-gradient(circle at top right, color-mix(in oklab, var(--primary) 24%, transparent), transparent 44%);
}
.review-detail-cover__year {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.review-detail-cover__emoji {
font-size: 4.75rem;
line-height: 1;
align-self: center;
}
.review-detail-cover__title {
font-size: 1.3rem;
font-weight: 800;
line-height: 1.35;
color: var(--title-color);
}
.review-detail-card {
padding: 1rem;
}
.review-detail-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.review-detail-card__rating {
display: flex;
align-items: baseline;
gap: 0.35rem;
color: var(--primary);
}
.review-detail-card__rating strong {
font-size: 2rem;
line-height: 1;
}
.review-detail-meta-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
font-size: 0.95rem;
color: var(--text-secondary);
}
.review-detail-meta-grid__label {
margin-bottom: 0.3rem;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.review-detail-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.review-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
align-items: center;
}
.review-detail-body {
white-space: pre-wrap;
font-size: 0.98rem;
line-height: 1.95;
color: var(--text-secondary);
}
@media (max-width: 1024px) {
.review-detail-shell {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.review-detail-meta-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -6,21 +6,10 @@ import FilterPill from '../../components/ui/FilterPill.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro'; import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
import { apiClient } from '../../lib/api/client'; import { apiClient } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import type { Review } from '../../lib/api/client'; import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
export const prerender = false; export const prerender = false;
type ReviewStatus = 'completed' | 'in-progress' | 'dropped';
type ParsedReview = Omit<Review, 'tags'> & {
tags: string[];
normalizedStatus: ReviewStatus;
coverIsImage: boolean;
coverUrl: string | null;
linkUrl: string | null;
externalLink: boolean;
};
// Fetch reviews from backend API // Fetch reviews from backend API
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = []; let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
@@ -32,60 +21,7 @@ try {
console.error('Failed to fetch reviews:', error); console.error('Failed to fetch reviews:', error);
} }
const normalizeReviewStatus = (status: string | null | undefined): ReviewStatus => { const parsedReviews: ParsedReview[] = reviews.map(parseReview);
const normalized = String(status || '').trim().toLowerCase();
if (normalized === 'published' || normalized === 'completed' || normalized === 'done') {
return 'completed';
}
if (normalized === 'draft' || normalized === 'in-progress' || normalized === 'watching' || normalized === 'reading' || normalized === 'listening') {
return 'in-progress';
}
if (normalized === 'dropped' || normalized === 'abandoned') {
return 'dropped';
}
return 'completed';
};
const isImageCover = (cover: string | null | undefined) => /^(https?:)?\/\//.test(String(cover || '').trim()) || String(cover || '').trim().startsWith('/');
const reviewCoverCatalog: Record<string, string> = {
'《漫长的季节》': '/review-covers/the-long-season.svg',
'《十三邀》': '/review-covers/thirteen-invites.svg',
'《黑神话:悟空》': '/review-covers/black-myth-wukong.svg',
'《置身事内》': '/review-covers/placed-within.svg',
'《宇宙探索编辑部》': '/review-covers/journey-to-the-west-editorial.svg',
'《疲惫生活中的英雄梦想》': '/review-covers/hero-dreams-in-tired-life.svg',
};
const resolveReviewCover = (review: Review) => {
if (isImageCover(review.cover)) {
return String(review.cover).trim();
}
return reviewCoverCatalog[review.title] || null;
};
const normalizeLinkUrl = (value: string | null | undefined) => {
const trimmed = String(value || '').trim();
return trimmed ? trimmed : null;
};
const isExternalLink = (value: string | null | undefined) => /^(https?:)?\/\//.test(String(value || '').trim());
// Parse tags from JSON string
const parsedReviews: ParsedReview[] = reviews.map(r => ({
...r,
tags: r.tags ? JSON.parse(r.tags) as string[] : [],
normalizedStatus: normalizeReviewStatus(r.status),
coverIsImage: isImageCover(r.cover),
coverUrl: resolveReviewCover(r),
linkUrl: normalizeLinkUrl(r.link_url),
externalLink: isExternalLink(r.link_url),
}));
const filteredReviews = selectedType === 'all' const filteredReviews = selectedType === 'all'
? parsedReviews ? parsedReviews
@@ -290,6 +226,7 @@ const statCards = [
style={`--review-accent: ${typeColors[review.review_type] || '#888'};`} style={`--review-accent: ${typeColors[review.review_type] || '#888'};`}
> >
<div class="review-card__poster"> <div class="review-card__poster">
<a href={`/reviews/${review.id}`} class="review-card__poster-link" aria-label={`查看 ${review.title} 详情`}>
{review.coverUrl ? ( {review.coverUrl ? (
<ResponsiveImage <ResponsiveImage
src={review.coverUrl} src={review.coverUrl}
@@ -309,6 +246,7 @@ const statCards = [
<div class="review-card__poster-title">{review.title}</div> <div class="review-card__poster-title">{review.title}</div>
</div> </div>
)} )}
</a>
</div> </div>
<div class="review-card__body"> <div class="review-card__body">
@@ -322,7 +260,11 @@ const statCards = [
{statusLabels[review.normalizedStatus]} {statusLabels[review.normalizedStatus]}
</span> </span>
</div> </div>
<h2 class="review-card__title">{review.title}</h2> <h2 class="review-card__title">
<a href={`/reviews/${review.id}`} class="review-card__title-link">
{review.title}
</a>
</h2>
</div> </div>
<div class="review-card__rating"> <div class="review-card__rating">
<div class="review-card__rating-value">{review.rating || 0}.0</div> <div class="review-card__rating-value">{review.rating || 0}.0</div>
@@ -337,6 +279,10 @@ const statCards = [
<p class="review-card__description">{review.description}</p> <p class="review-card__description">{review.description}</p>
<div class="review-card__meta"> <div class="review-card__meta">
<a href={`/reviews/${review.id}`} class="review-card__link review-card__link--internal">
<i class="fas fa-file-lines"></i>
<span class="font-mono">cat review.md</span>
</a>
<span class="terminal-chip text-xs py-1 px-2.5"> <span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-calendar text-[10px]"></i> <i class="fas fa-calendar text-[10px]"></i>
<span>{review.review_date}</span> <span>{review.review_date}</span>
@@ -629,6 +575,13 @@ const statCards = [
inset 0 1px 0 rgba(255, 255, 255, 0.08); inset 0 1px 0 rgba(255, 255, 255, 0.08);
} }
.review-card__poster-link {
display: block;
height: 100%;
color: inherit;
text-decoration: none;
}
.review-card__poster-image { .review-card__poster-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -715,6 +668,16 @@ const statCards = [
color: var(--title-color); color: var(--title-color);
} }
.review-card__title-link {
color: inherit;
text-decoration: none;
transition: color 180ms ease;
}
.review-card__title-link:hover {
color: var(--review-accent);
}
.review-card__rating { .review-card__rating {
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
@@ -772,6 +735,10 @@ const statCards = [
background: color-mix(in oklab, var(--review-accent) 16%, var(--terminal-bg)); background: color-mix(in oklab, var(--review-accent) 16%, var(--terminal-bg));
} }
.review-card__link--internal {
color: var(--title-color);
}
.review-card__tags { .review-card__tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -23,6 +23,7 @@ export const GET: APIRoute = async ({ request }) => {
let siteSettings = DEFAULT_SITE_SETTINGS let siteSettings = DEFAULT_SITE_SETTINGS
let posts = await api.getRawPosts().catch(() => []) let posts = await api.getRawPosts().catch(() => [])
const reviews = await api.getReviews().catch(() => [])
try { try {
siteSettings = await api.getSiteSettings() siteSettings = await api.getSiteSettings()
@@ -62,7 +63,14 @@ export const GET: APIRoute = async ({ request }) => {
priority: post.pinned ? '0.9' : '0.7', priority: post.pinned ? '0.9' : '0.7',
})) }))
const xmlBody = [...staticUrls, ...postUrls] const reviewUrls = reviews.map((review) => ({
loc: ensureAbsoluteUrl(siteUrl, `/reviews/${review.id}`),
lastmod: new Date(review.updated_at || review.created_at).toISOString(),
changefreq: 'monthly',
priority: '0.6',
}))
const xmlBody = [...staticUrls, ...postUrls, ...reviewUrls]
.map( .map(
(item) => ` (item) => `
<url> <url>

View File

@@ -8,6 +8,11 @@
"dev:admin": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only admin", "dev:admin": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only admin",
"dev:backend": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only backend", "dev:backend": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only backend",
"dev:mcp-only": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only mcp", "dev:mcp-only": "powershell -ExecutionPolicy Bypass -File ./dev.ps1 -Only mcp",
"test:ui": "pnpm --dir ./playwright-smoke test",
"test:ui:frontend": "pnpm --dir ./playwright-smoke test:frontend",
"test:ui:admin": "pnpm --dir ./playwright-smoke test:admin",
"test:ui:headed": "pnpm --dir ./playwright-smoke test:headed",
"test:ui:install-browsers": "pnpm --dir ./playwright-smoke install:browsers",
"stop": "powershell -ExecutionPolicy Bypass -File ./stop-services.ps1", "stop": "powershell -ExecutionPolicy Bypass -File ./stop-services.ps1",
"restart": "powershell -ExecutionPolicy Bypass -File ./restart-services.ps1" "restart": "powershell -ExecutionPolicy Bypass -File ./restart-services.ps1"
} }

3
playwright-smoke/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/

View File

@@ -0,0 +1,33 @@
# Playwright 回归测试
## 本地运行
```powershell
cd playwright-smoke
pnpm install
pnpm test
```
或在仓库根目录直接执行:
```powershell
pnpm test:ui
```
## 可用脚本
- `pnpm test`:跑前台 + 后台全量回归
- `pnpm test:frontend`:只跑前台
- `pnpm test:admin`:只跑后台
- `pnpm test:headed`:有界面调试
## 设计说明
- 使用独立 `mock-server.mjs` 提供前台 SSR、前端交互、后台 CRUD 所需的稳定假数据。
- CI 不依赖真实数据库 / Rust 后端,适合做前后台 UI 回归。
- 每条用例开始前都会调用 mock reset避免数据串扰。
- 本地默认优先走已安装的 `msedge` channelCI 仍使用 `playwright install chromium`
- 当前已覆盖:
- 前台首页过滤、文章详情、评论、搜索、AI 问答、友链申请、订阅确认/管理/退订
- 后台:登录、导航、评论审核、友链审核
- 后台深度回归:分类 CRUD、标签 CRUD、订阅 CRUD / 测试发送 / weekly & monthly digest、文章创建/保存/版本恢复/删除、媒体上传/元数据/替换/删除、站点设置保存/AI 重建索引/Provider 连通性/存储连通性、评测 CRUD / AI 润色、评论画像与黑名单管理

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "termi-playwright-smoke",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:frontend": "playwright test --project=frontend",
"test:admin": "playwright test --project=admin",
"test:headed": "playwright test --headed",
"install:browsers": "playwright install --with-deps chromium"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^24.7.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,92 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, devices } from '@playwright/test'
const mockBaseUrl = 'http://127.0.0.1:5159'
const frontendBaseUrl = 'http://127.0.0.1:4321'
const adminBaseUrl = 'http://127.0.0.1:4322'
const isCi = Boolean(process.env.CI)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, '..')
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 12_000,
},
reporter: [['list'], ['html', { open: 'never' }]],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: true,
},
projects: [
{
name: 'frontend',
testMatch: /frontend\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
channel: isCi ? undefined : 'msedge',
baseURL: frontendBaseUrl,
},
},
{
name: 'admin',
testMatch: /admin\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
channel: isCi ? undefined : 'msedge',
baseURL: adminBaseUrl,
},
},
],
webServer: [
{
command: 'node ./mock-server.mjs',
cwd: __dirname,
url: `${mockBaseUrl}/__playwright/health`,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PLAYWRIGHT_MOCK_PORT: '5159',
PLAYWRIGHT_FRONTEND_ORIGIN: frontendBaseUrl,
PLAYWRIGHT_ADMIN_ORIGIN: adminBaseUrl,
},
},
{
command: 'pnpm dev --host 127.0.0.1 --port 4321',
cwd: path.resolve(repoRoot, 'frontend'),
url: frontendBaseUrl,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PUBLIC_API_BASE_URL: `${mockBaseUrl}/api`,
INTERNAL_API_BASE_URL: `${mockBaseUrl}/api`,
PUBLIC_IMAGE_ALLOWED_HOSTS: '127.0.0.1:5159,127.0.0.1',
},
},
{
command: 'pnpm dev --host 127.0.0.1 --port 4322',
cwd: path.resolve(repoRoot, 'admin'),
url: adminBaseUrl,
reuseExistingServer: !isCi,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
VITE_API_BASE: mockBaseUrl,
VITE_FRONTEND_BASE_URL: frontendBaseUrl,
},
},
],
})

77
playwright-smoke/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,77 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.55.0
version: 1.59.0
'@types/node':
specifier: ^24.7.2
version: 24.12.0
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@playwright/test@1.59.0':
resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
engines: {node: '>=18'}
hasBin: true
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
playwright-core@1.59.0:
resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.0:
resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@playwright/test@1.59.0':
dependencies:
playwright: 1.59.0
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
fsevents@2.3.2:
optional: true
playwright-core@1.59.0: {}
playwright@1.59.0:
dependencies:
playwright-core: 1.59.0
optionalDependencies:
fsevents: 2.3.2
typescript@5.9.3: {}
undici-types@7.16.0: {}

View File

@@ -0,0 +1,309 @@
import { expect, test, type Page } from '@playwright/test'
import { getDebugState, loginAdmin, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => {
await resetMockState(request)
})
function acceptNextDialog(page: Page) {
page.once('dialog', async (dialog) => {
await dialog.accept()
})
}
function buildSvgPayload(name: string, label: string) {
return {
name,
mimeType: 'image/svg+xml',
buffer: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><rect width="1200" height="675" fill="#111827"/><text x="80" y="180" fill="#f8fafc" font-family="monospace" font-size="40">${label}</text></svg>`,
'utf8',
),
}
}
test('后台登录、导航与关键模块页面可加载', async ({ page }) => {
await loginAdmin(page)
const routes = [
{ label: '概览', url: /\/$/, text: 'Astro 终端博客信息架构实战' },
{ label: '数据分析', url: /\/analytics$/, text: 'playwright' },
{ label: '文章', url: /\/posts$/, text: 'playwright-regression-workflow' },
{ label: '分类', url: /\/categories$/, text: '前端工程' },
{ label: '标签', url: /\/tags$/, text: 'Playwright' },
{ label: '备份', url: /\/backups$/, text: '导出' },
{ label: '版本', url: /\/revisions$/, text: 'astro-terminal-blog' },
{ label: '评论', url: /\/comments$/, text: 'Carol' },
{ label: '友链', url: /\/friend-links$/, text: 'Pending Link Review' },
{ label: '评测', url: /\/reviews$/, text: '《漫长的季节》' },
{ label: '媒体库', url: /\/media$/, text: '漫长的季节封面' },
{ label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' },
{ label: '审计', url: /\/audit$/, text: 'playwright-smoke' },
{ label: '设置', url: /\/settings$/, text: 'InitCool' },
]
for (const route of routes) {
await page.getByRole('link', { name: route.label }).click()
await expect(page).toHaveURL(route.url)
await expect(page.locator('main')).toContainText(route.text)
}
})
test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '评论' }).click()
await expect(page.locator('main')).toContainText('Carol')
await page.getByRole('button', { name: '通过' }).first().click()
await page.getByRole('link', { name: '友链' }).click()
await expect(page.locator('main')).toContainText('Pending Link Review')
await page.getByRole('button', { name: '通过' }).first().click()
await page.getByRole('link', { name: '设置' }).click()
await expect(page.locator('main')).toContainText('InitCool')
await expect(page.getByTestId('site-settings-save')).toBeVisible()
})
test('后台可完成分类与标签的创建、更新、删除', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '分类' }).click()
await page.getByTestId('category-name-input').fill('Playwright 深回归分类')
await page.getByTestId('category-slug-input').fill('playwright-deep-category')
await page.getByPlaceholder('介绍这个分类主要收录哪些内容。').fill('用于后台深度回归的分类。')
await page.getByTestId('category-save').click()
await expect(page.getByTestId('category-item-playwright-deep-category')).toBeVisible()
await page.getByTestId('category-item-playwright-deep-category').click()
await page.getByPlaceholder('前端工程专题 - Termi').fill('Playwright 深回归分类 SEO')
await page.getByTestId('category-save').click()
let state = await getDebugState(request)
expect(
state.categories.some(
(item: { slug: string; seo_title: string }) =>
item.slug === 'playwright-deep-category' &&
item.seo_title === 'Playwright 深回归分类 SEO',
),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('category-delete').click()
await expect(page.getByTestId('category-item-playwright-deep-category')).toHaveCount(0)
await page.getByRole('link', { name: '标签' }).click()
await page.getByTestId('tag-name-input').fill('Playwright 深回归标签')
await page.getByTestId('tag-slug-input').fill('playwright-deep-tag')
await page.getByPlaceholder('介绍这个标签常见主题、适合谁看。').fill('用于后台深度回归的标签。')
await page.getByTestId('tag-save').click()
await expect(page.getByTestId('tag-item-playwright-deep-tag')).toBeVisible()
await page.getByTestId('tag-item-playwright-deep-tag').click()
await page.getByPlaceholder('Astro 相关文章 - Termi').fill('Playwright 深回归标签 SEO')
await page.getByTestId('tag-save').click()
state = await getDebugState(request)
expect(
state.tags.some(
(item: { slug: string; seo_title: string }) =>
item.slug === 'playwright-deep-tag' && item.seo_title === 'Playwright 深回归标签 SEO',
),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('tag-delete').click()
await expect(page.getByTestId('tag-item-playwright-deep-tag')).toHaveCount(0)
})
test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '订阅' }).click()
await page.getByPlaceholder('name@example.com').fill('deep-regression@example.com')
await page.getByPlaceholder('例如 站长邮箱 / Discord 运维群').fill('Deep Regression')
await page.getByTestId('subscriptions-save').click()
const row = page
.locator('[data-testid^="subscription-row-"]')
.filter({ hasText: 'deep-regression@example.com' })
await expect(row).toBeVisible()
await row.getByTestId(/subscription-edit-/).click()
await page.getByPlaceholder('例如 站长邮箱 / Discord 运维群').fill('Deep Regression Updated')
await page.getByTestId('subscriptions-save').click()
await expect(row).toContainText('Deep Regression Updated')
await row.getByTestId(/subscription-test-/).click()
await page.getByTestId('subscriptions-send-weekly').click()
await page.getByTestId('subscriptions-send-monthly').click()
let state = await getDebugState(request)
expect(
state.deliveries.some((item: { event_type: string; target: string }) =>
item.event_type === 'subscription.test' && item.target === 'deep-regression@example.com'),
).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.weekly')).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.monthly')).toBeTruthy()
await row.getByTestId(/subscription-delete-/).click()
await expect(row).toHaveCount(0)
state = await getDebugState(request)
expect(
state.subscriptions.some((item: { target: string }) => item.target === 'deep-regression@example.com'),
).toBeFalsy()
})
test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '文章' }).click()
await page.getByTestId('posts-open-create').click()
await page.getByTestId('post-create-title').fill('Playwright 深回归文章')
await page.getByTestId('post-create-slug').fill('playwright-deep-post')
await page.getByTestId('post-create-submit').click()
await expect(page).toHaveURL(/\/posts\/playwright-deep-post$/)
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章')
await page.getByTestId('post-editor-title').fill('Playwright 深回归文章(已更新)')
await page.getByTestId('post-editor-save').click()
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章(已更新)')
let state = await getDebugState(request)
expect(
state.posts.some(
(item: { slug: string; title: string }) =>
item.slug === 'playwright-deep-post' && item.title === 'Playwright 深回归文章(已更新)',
),
).toBeTruthy()
await page.getByTestId('post-editor-close').click()
state = await getDebugState(request)
const createRevision = state.post_revisions.find(
(item: { id: number; post_slug: string; operation: string }) =>
item.post_slug === 'playwright-deep-post' && item.operation === 'create',
)
expect(createRevision).toBeTruthy()
await page.getByRole('link', { name: '版本' }).click()
await page.getByTestId('revisions-slug-filter').fill('playwright-deep-post')
await page.getByTestId(`revision-open-${createRevision.id}`).click()
await page.getByTestId('revision-restore-full').click()
await page.getByRole('link', { name: '文章' }).click()
await page.getByTestId('post-item-playwright-deep-post').click()
await expect(page.getByTestId('post-editor-title')).toHaveValue('Playwright 深回归文章')
acceptNextDialog(page)
await page.getByTestId('post-editor-delete').click()
await expect(page).toHaveURL(/\/posts$/)
state = await getDebugState(request)
expect(state.posts.some((item: { slug: string }) => item.slug === 'playwright-deep-post')).toBeFalsy()
})
test('后台可完成媒体库上传/元数据/替换/删除,并执行设置页关键动作', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-upload-input').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-upload'),
])
await page.getByTestId('media-upload').click()
await expect(page.getByTestId('media-item-0')).toContainText('deep-regression-cover.svg')
await page.getByTestId('media-edit-0').click()
await page.getByPlaceholder('文章封面 / 站点横幅').fill('Deep Regression Cover')
await page.getByPlaceholder('夜色下的终端风格博客封面').fill('Deep Regression Alt')
await page.getByPlaceholder('cover, astro, terminal').fill('playwright, regression')
await page.getByTestId('media-save-metadata').click()
let state = await getDebugState(request)
expect(
state.media.some(
(item: { title: string; alt_text: string; tags: string[] }) =>
item.title === 'Deep Regression Cover' &&
item.alt_text === 'Deep Regression Alt' &&
item.tags.includes('playwright'),
),
).toBeTruthy()
await page.getByTestId('media-replace-input-0').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-replaced'),
])
acceptNextDialog(page)
await page.getByTestId('media-delete-0').click()
state = await getDebugState(request)
expect(state.media.some((item: { title: string }) => item.title === 'Deep Regression Cover')).toBeFalsy()
await page.getByRole('link', { name: '设置' }).click()
await page.getByTestId('site-settings-site-name').fill('InitCool Deep Regression')
await page.getByTestId('site-settings-popup-title').fill('订阅深回归')
await page.getByTestId('site-settings-save').click()
await page.getByTestId('site-settings-reindex').click()
await page.getByTestId('site-settings-test-provider').click()
await page.getByTestId('site-settings-test-image-provider').click()
await page.getByTestId('site-settings-test-storage').click()
state = await getDebugState(request)
expect(state.site_settings.site_name).toBe('InitCool Deep Regression')
expect(state.site_settings.subscription_popup_title).toBe('订阅深回归')
expect(state.site_settings.ai_chunks_count).toBeGreaterThan(128)
})
test('后台可完成评测 CRUD、AI 润色,以及评论画像/黑名单管理', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: '评测' }).click()
await page.getByTestId('review-title').fill('Playwright 深评测')
await page.getByTestId('review-date').fill('2026-04-01')
await page.getByTestId('review-description').fill('这是一段用于深度回归的评测简介。')
await page.getByTestId('review-save').click()
await expect(page.locator('main')).toContainText('Playwright 深评测')
await page.getByTestId('review-ai-polish').click()
await expect(page.getByText('AI 点评润色对比')).toBeVisible()
await page.getByTestId('review-ai-adopt').click()
await page.getByTestId('review-save').click()
let state = await getDebugState(request)
expect(
state.reviews.some((item: { title: string }) => item.title === 'Playwright 深评测'),
).toBeTruthy()
acceptNextDialog(page)
await page.getByTestId('review-delete').click()
state = await getDebugState(request)
expect(
state.reviews.some((item: { title: string }) => item.title === 'Playwright 深评测'),
).toBeFalsy()
await page.getByRole('link', { name: '评论' }).click()
await page.getByRole('button', { name: 'AI 分析' }).click()
await expect(page.locator('main')).toContainText('建议保持观察')
await page.getByPlaceholder('输入要封禁的值').fill('203.0.113.55')
await page.getByPlaceholder('原因(可选)').first().fill('playwright deep regression')
await page.getByTestId('comment-blacklist-add').click()
state = await getDebugState(request)
const createdRule = state.comment_blacklist.find(
(item: { id: number; matcher_value: string }) => item.matcher_value === '203.0.113.55',
)
expect(createdRule).toBeTruthy()
await page.getByTestId(`blacklist-toggle-${createdRule.id}`).click()
await page.getByTestId(`blacklist-toggle-${createdRule.id}`).click()
acceptNextDialog(page)
await page.getByTestId(`blacklist-delete-${createdRule.id}`).click()
state = await getDebugState(request)
expect(
state.comment_blacklist.some((item: { matcher_value: string }) => item.matcher_value === '203.0.113.55'),
).toBeFalsy()
})

View File

@@ -0,0 +1,89 @@
import { expect, test } from '@playwright/test'
import { getDebugState, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => {
await resetMockState(request)
})
test('首页过滤、热门区和文章详情链路可用', async ({ page }) => {
await page.goto('/')
await expect(page.locator('#home-results-count')).toContainText(/条结果/)
await page.locator('[data-home-category-filter="测试体系"]').click()
await expect(page.locator('#home-active-category-text')).toHaveText('测试体系')
await page.locator('[data-home-tag-filter="Playwright"]').click()
await expect(page.locator('#home-active-tag-text')).toHaveText('Playwright')
await page.locator('[data-home-popular-range="30d"]').click()
await expect(page.locator('#home-stats-window-pill')).toHaveText('30d')
await page.locator('a[href="/articles/playwright-regression-workflow"]').first().click()
await expect(page).toHaveURL(/\/articles\/playwright-regression-workflow$/)
await expect(page.getByRole('heading', { name: 'Playwright 回归工作流设计' })).toBeVisible()
await expect(page.locator('.paragraph-comment-marker').first()).toBeVisible()
})
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
await page.goto('/articles/astro-terminal-blog')
await page.locator('#toggle-comment-form').click()
await page.locator('#comment-form input[name="nickname"]').fill('Playwright Visitor')
await page.locator('#comment-form input[name="email"]').fill('visitor@example.com')
await page.locator('#comment-form textarea[name="content"]').fill('这是一条来自回归测试的新评论。')
await page.locator('#comment-form input[name="captchaAnswer"]').fill('7')
await page.getByRole('button', { name: '提交' }).click()
await expect(page.locator('#comment-message')).toContainText('提交')
const commentState = await getDebugState(request)
expect(commentState.comments.some((item: { author: string }) => item.author === 'Playwright Visitor')).toBeTruthy()
await page.goto('/search?q=playwright')
await expect(page.getByText('Playwright 回归工作流设计')).toBeVisible()
await page.goto('/ask')
await page.locator('#ai-question').fill('这个博客主要写什么内容?')
await page.locator('#ai-submit').click()
await expect(page.locator('#ai-answer')).toContainText('Playwright 回归工作流')
})
test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, request }) => {
await page.goto('/friends')
await page.locator('input[name="siteName"]').fill('Playwright Friend')
await page.locator('input[name="siteUrl"]').fill('https://playwright-friend.example')
await page.locator('textarea[name="description"]').fill('回归测试用的友链申请。')
await page.locator('label', { hasText: '[其他]' }).click()
await page.locator('#has-reciprocal').check()
await page.getByRole('button', { name: '提交申请' }).click()
await expect(page.locator('#form-message')).toContainText('提交')
const friendState = await getDebugState(request)
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
await page.goto('/')
await page.locator('[data-subscription-popup-open]').click()
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅')
const subscriptionState = await getDebugState(request)
const latest = subscriptionState.subscriptions.find(
(item: { target: string }) => item.target === 'playwright-subscriber@example.com',
)
expect(latest).toBeTruthy()
await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`)
await expect(page.getByText('订阅已确认')).toBeVisible()
await page.goto(`/subscriptions/manage?token=${encodeURIComponent(latest.manage_token)}`)
await page.getByRole('textbox', { name: '称呼' }).fill('回归通知')
await page.getByRole('button', { name: '保存偏好' }).click()
await expect(page.locator('[data-manage-status]')).toContainText('偏好已保存')
await page.goto(`/subscriptions/unsubscribe?token=${encodeURIComponent(latest.manage_token)}`)
await page.getByRole('button', { name: '确认退订' }).click()
await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订')
})

View File

@@ -0,0 +1,29 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test'
export const MOCK_BASE_URL = 'http://127.0.0.1:5159'
export const ADMIN_COOKIE = {
name: 'termi_admin_session',
value: 'mock-admin-session',
domain: '127.0.0.1',
path: '/',
}
export async function resetMockState(request: APIRequestContext) {
const response = await request.post(`${MOCK_BASE_URL}/__playwright/reset`)
expect(response.ok()).toBeTruthy()
}
export async function getDebugState(request: APIRequestContext) {
const response = await request.get(`${MOCK_BASE_URL}/__playwright/state`)
expect(response.ok()).toBeTruthy()
return response.json()
}
export async function loginAdmin(page: Page) {
await page.goto('/login')
await page.getByLabel('用户名').fill('admin')
await page.getByLabel('密码').fill('admin123')
await page.getByRole('button', { name: '进入后台' }).click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByText('当前登录admin')).toBeVisible()
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"types": ["node", "@playwright/test"],
"strict": true,
"noEmit": true,
"allowJs": false,
"resolveJsonModule": true
},
"include": ["playwright.config.ts", "tests/**/*.ts"]
}