test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
This commit is contained in:
167
.gitea/workflows/ui-regression.yml
Normal file
167
.gitea/workflows/ui-regression.yml
Normal 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
|
||||||
17
README.md
17
README.md
@@ -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
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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('创建文章时必须填写标题。')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
92
frontend/src/lib/reviews.ts
Normal file
92
frontend/src/lib/reviews.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
384
frontend/src/pages/reviews/[id].astro
Normal file
384
frontend/src/pages/reviews/[id].astro
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
3
playwright-smoke/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
33
playwright-smoke/README.md
Normal file
33
playwright-smoke/README.md
Normal 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` channel,CI 仍使用 `playwright install chromium`。
|
||||||
|
- 当前已覆盖:
|
||||||
|
- 前台:首页过滤、文章详情、评论、搜索、AI 问答、友链申请、订阅确认/管理/退订
|
||||||
|
- 后台:登录、导航、评论审核、友链审核
|
||||||
|
- 后台深度回归:分类 CRUD、标签 CRUD、订阅 CRUD / 测试发送 / weekly & monthly digest、文章创建/保存/版本恢复/删除、媒体上传/元数据/替换/删除、站点设置保存/AI 重建索引/Provider 连通性/存储连通性、评测 CRUD / AI 润色、评论画像与黑名单管理
|
||||||
3211
playwright-smoke/mock-server.mjs
Normal file
3211
playwright-smoke/mock-server.mjs
Normal file
File diff suppressed because it is too large
Load Diff
17
playwright-smoke/package.json
Normal file
17
playwright-smoke/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
playwright-smoke/playwright.config.ts
Normal file
92
playwright-smoke/playwright.config.ts
Normal 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
77
playwright-smoke/pnpm-lock.yaml
generated
Normal 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: {}
|
||||||
309
playwright-smoke/tests/admin.spec.ts
Normal file
309
playwright-smoke/tests/admin.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
89
playwright-smoke/tests/frontend.spec.ts
Normal file
89
playwright-smoke/tests/frontend.spec.ts
Normal 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('成功退订')
|
||||||
|
})
|
||||||
29
playwright-smoke/tests/helpers.ts
Normal file
29
playwright-smoke/tests/helpers.ts
Normal 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()
|
||||||
|
}
|
||||||
14
playwright-smoke/tsconfig.json
Normal file
14
playwright-smoke/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user