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:
@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/posts-page')
|
||||
return { default: mod.PostsPage }
|
||||
})
|
||||
const PostPreviewPage = lazy(async () => {
|
||||
const mod = await import('@/pages/post-preview-page')
|
||||
return { default: mod.PostPreviewPage }
|
||||
})
|
||||
const PostComparePage = lazy(async () => {
|
||||
const mod = await import('@/pages/post-compare-page')
|
||||
return { default: mod.PostComparePage }
|
||||
})
|
||||
const PostPolishPage = lazy(async () => {
|
||||
const mod = await import('@/pages/post-polish-page')
|
||||
return { default: mod.PostPolishPage }
|
||||
})
|
||||
const CategoriesPage = lazy(async () => {
|
||||
const mod = await import('@/pages/categories-page')
|
||||
return { default: mod.CategoriesPage }
|
||||
@@ -223,6 +235,56 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicOnly />} />
|
||||
<Route
|
||||
path="/posts/preview"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<LazyRoute>
|
||||
<PostPreviewPage />
|
||||
</LazyRoute>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/posts/:slug/preview"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<LazyRoute>
|
||||
<PostPreviewPage />
|
||||
</LazyRoute>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/posts/compare"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<LazyRoute>
|
||||
<PostComparePage />
|
||||
</LazyRoute>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/posts/:slug/compare"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<LazyRoute>
|
||||
<PostComparePage />
|
||||
</LazyRoute>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/posts/polish"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<LazyRoute>
|
||||
<PostPolishPage />
|
||||
</LazyRoute>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NativeSelectProps = React.ComponentProps<'select'>
|
||||
type NativeSelectProps = React.ComponentProps<'select'> & {
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
@@ -78,8 +80,11 @@ function getNextEnabledIndex(options: SelectOption[], currentIndex: number, dire
|
||||
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
(
|
||||
{
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
children,
|
||||
className,
|
||||
'data-testid': dataTestId,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
id,
|
||||
@@ -434,6 +439,9 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
data-testid={dataTestId}
|
||||
id={id}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
@@ -454,8 +462,11 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
<button
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-haspopup="listbox"
|
||||
className={triggerClasses}
|
||||
data-testid={dataTestId}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
disabled={disabled}
|
||||
onBlur={(event) => {
|
||||
|
||||
@@ -61,22 +61,38 @@ export function savePolishWindowResult(
|
||||
return payload
|
||||
}
|
||||
|
||||
export function consumePolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
function parsePolishWindowResult(raw: string | null) {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(storageKey)
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as PolishWindowResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readPolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
return parsePolishWindowResult(window.localStorage.getItem(storageKey))
|
||||
}
|
||||
|
||||
export function consumePolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
const parsed = parsePolishWindowResult(window.localStorage.getItem(storageKey))
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(storageKey)
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export function CategoriesPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
data-testid="categories-search"
|
||||
placeholder="按分类名 / slug / 描述搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
@@ -229,6 +230,7 @@ export function CategoriesPage() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`category-item-${item.slug}`}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setForm(toFormState(item))
|
||||
@@ -286,6 +288,7 @@ export function CategoriesPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<FormField label="分类名称" hint="例如:前端工程、随笔、工具链。">
|
||||
<Input
|
||||
data-testid="category-name-input"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="输入分类名称"
|
||||
@@ -293,6 +296,7 @@ export function CategoriesPage() {
|
||||
</FormField>
|
||||
<FormField label="分类 slug" hint="留空时自动从英文名称生成;中文建议手填。">
|
||||
<Input
|
||||
data-testid="category-slug-input"
|
||||
value={form.slug}
|
||||
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
|
||||
placeholder="frontend-engineering"
|
||||
@@ -377,7 +381,7 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
<Button onClick={() => void handleSave()} disabled={saving} data-testid="category-save">
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
|
||||
</Button>
|
||||
@@ -388,6 +392,7 @@ export function CategoriesPage() {
|
||||
variant="ghost"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={!selectedItem || deleting}
|
||||
data-testid="category-delete"
|
||||
className="text-rose-600 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -898,6 +898,7 @@ export function CommentsPage() {
|
||||
setManualMatcherValue('')
|
||||
}}
|
||||
disabled={!manualMatcherValue.trim()}
|
||||
data-testid="comment-blacklist-add"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
新增
|
||||
@@ -908,6 +909,7 @@ export function CommentsPage() {
|
||||
{blacklist.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
data-testid={`blacklist-item-${item.id}`}
|
||||
className="rounded-2xl border border-border/70 bg-background/40 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -929,6 +931,7 @@ export function CommentsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingBlacklistId === item.id}
|
||||
data-testid={`blacklist-toggle-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActingBlacklistId(item.id)
|
||||
@@ -959,6 +962,7 @@ export function CommentsPage() {
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={actingBlacklistId === item.id}
|
||||
data-testid={`blacklist-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条黑名单规则吗?')) {
|
||||
return
|
||||
|
||||
@@ -58,6 +58,24 @@ const defaultMetadataForm: MediaMetadataFormState = {
|
||||
notes: '',
|
||||
}
|
||||
|
||||
function normalizeMediaTags(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse {
|
||||
return {
|
||||
...item,
|
||||
tags: normalizeMediaTags(item.tags),
|
||||
}
|
||||
}
|
||||
|
||||
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
|
||||
if (!item) {
|
||||
return defaultMetadataForm
|
||||
@@ -67,7 +85,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor
|
||||
title: item.title ?? '',
|
||||
altText: item.alt_text ?? '',
|
||||
caption: item.caption ?? '',
|
||||
tags: item.tags.join(', '),
|
||||
tags: normalizeMediaTags(item.tags).join(', '),
|
||||
notes: item.notes ?? '',
|
||||
}
|
||||
}
|
||||
@@ -111,8 +129,9 @@ export function MediaPage() {
|
||||
}
|
||||
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
|
||||
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
|
||||
const normalizedItems = result.items.map(normalizeMediaItem)
|
||||
startTransition(() => {
|
||||
setItems(result.items)
|
||||
setItems(normalizedItems)
|
||||
setProvider(result.provider)
|
||||
setBucket(result.bucket)
|
||||
})
|
||||
@@ -219,6 +238,7 @@ export function MediaPage() {
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!selectedKeys.length || batchDeleting}
|
||||
data-testid="media-batch-delete"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
|
||||
return
|
||||
@@ -267,6 +287,7 @@ export function MediaPage() {
|
||||
<option value="uploads/">上传到通用目录</option>
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="media-search"
|
||||
placeholder="按对象 key 搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
@@ -275,6 +296,7 @@ export function MediaPage() {
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Input
|
||||
data-testid="media-upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
@@ -300,6 +322,7 @@ export function MediaPage() {
|
||||
/>
|
||||
<Button
|
||||
disabled={!uploadFiles.length || uploading}
|
||||
data-testid="media-upload"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setUploading(true)
|
||||
@@ -399,6 +422,7 @@ export function MediaPage() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
disabled={metadataSaving}
|
||||
data-testid="media-save-metadata"
|
||||
onClick={async () => {
|
||||
if (!activeItem) {
|
||||
return
|
||||
@@ -423,7 +447,7 @@ export function MediaPage() {
|
||||
title: result.title,
|
||||
alt_text: result.alt_text,
|
||||
caption: result.caption,
|
||||
tags: result.tags,
|
||||
tags: normalizeMediaTags(result.tags),
|
||||
notes: result.notes,
|
||||
}
|
||||
: item,
|
||||
@@ -473,10 +497,12 @@ export function MediaPage() {
|
||||
{filteredItems.map((item, index) => {
|
||||
const selected = selectedKeys.includes(item.key)
|
||||
const replaceInputId = `replace-media-${index}`
|
||||
const itemTags = normalizeMediaTags(item.tags)
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
data-testid={`media-item-${index}`}
|
||||
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
|
||||
>
|
||||
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
|
||||
@@ -504,9 +530,9 @@ export function MediaPage() {
|
||||
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
||||
</div>
|
||||
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
|
||||
{item.tags.length ? (
|
||||
{itemTags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.slice(0, 4).map((tag) => (
|
||||
{itemTags.slice(0, 4).map((tag) => (
|
||||
<Badge key={`${item.key}-${tag}`} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
@@ -515,7 +541,12 @@ export function MediaPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveKey(item.key)}
|
||||
data-testid={`media-edit-${index}`}
|
||||
>
|
||||
元数据
|
||||
</Button>
|
||||
<Button
|
||||
@@ -541,6 +572,7 @@ export function MediaPage() {
|
||||
</Button>
|
||||
<input
|
||||
id={replaceInputId}
|
||||
data-testid={`media-replace-input-${index}`}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -583,6 +615,7 @@ export function MediaPage() {
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deletingKey === item.key || replacingKey === item.key}
|
||||
data-testid={`media-delete-${index}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -17,15 +18,6 @@ type CompareState = {
|
||||
draftMarkdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
@@ -35,7 +27,8 @@ function getDraftKey() {
|
||||
}
|
||||
|
||||
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const { slug: routeSlug } = useParams<{ slug?: string }>()
|
||||
const slug = slugOverride ?? routeSlug ?? ''
|
||||
const [state, setState] = useState<CompareState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -49,6 +42,28 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
setError(null)
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
|
||||
if (draft && (!slug || draft.slug === slug)) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
path: draft.path,
|
||||
savedMarkdown: draft.savedMarkdown,
|
||||
draftMarkdown: draft.markdown,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
throw new Error('缺少文章 slug,无法加载改动对比。')
|
||||
}
|
||||
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
@@ -63,8 +78,8 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
|
||||
draftMarkdown: draft?.markdown ?? markdown.markdown,
|
||||
savedMarkdown: markdown.markdown,
|
||||
draftMarkdown: markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ExternalLink, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
@@ -17,15 +18,6 @@ type PreviewState = {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
@@ -35,7 +27,8 @@ function getDraftKey() {
|
||||
}
|
||||
|
||||
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const { slug: routeSlug } = useParams<{ slug?: string }>()
|
||||
const slug = slugOverride ?? routeSlug ?? ''
|
||||
const [state, setState] = useState<PreviewState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -50,7 +43,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
|
||||
if (draft && draft.slug === slug) {
|
||||
if (draft && (!slug || draft.slug === slug)) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
@@ -66,6 +59,10 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
throw new Error('缺少文章 slug,无法加载独立预览。')
|
||||
}
|
||||
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FilePlus2,
|
||||
FileUp,
|
||||
FolderOpen,
|
||||
GitCompareArrows,
|
||||
PencilLine,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
@@ -54,6 +56,13 @@ import {
|
||||
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
|
||||
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
|
||||
import {
|
||||
consumePolishWindowResult,
|
||||
readPolishWindowResult,
|
||||
saveDraftWindowSnapshot,
|
||||
type DraftWindowSnapshot,
|
||||
type PolishWindowResult,
|
||||
} from '@/lib/post-draft-window'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
@@ -206,6 +215,14 @@ const defaultCreateForm: CreatePostFormState = {
|
||||
const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit']
|
||||
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
|
||||
const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const
|
||||
const ADMIN_BASENAME =
|
||||
((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(/\/$/, '')
|
||||
const POLISH_RESULT_STORAGE_PREFIX = 'termi-admin-post-polish-result:'
|
||||
|
||||
function buildAdminRoute(path: string) {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${ADMIN_BASENAME}${normalizedPath}` || normalizedPath
|
||||
}
|
||||
|
||||
function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) {
|
||||
switch (panel) {
|
||||
@@ -828,6 +845,8 @@ export function PostsPage() {
|
||||
const [sortKey, setSortKey] = useState('updated_at_desc')
|
||||
const [totalPosts, setTotalPosts] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const editorPolishDraftKeyRef = useRef<string | null>(null)
|
||||
const createPolishDraftKeyRef = useRef<string | null>(null)
|
||||
|
||||
const { sortBy, sortOrder } = useMemo(() => {
|
||||
switch (sortKey) {
|
||||
@@ -930,6 +949,7 @@ export function PostsPage() {
|
||||
useEffect(() => {
|
||||
setEditorMode('workspace')
|
||||
setEditorPanels(defaultWorkbenchPanels)
|
||||
editorPolishDraftKeyRef.current = null
|
||||
|
||||
if (!slug) {
|
||||
setEditor(null)
|
||||
@@ -942,6 +962,12 @@ export function PostsPage() {
|
||||
void loadEditor(slug)
|
||||
}, [loadEditor, slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createDialogOpen) {
|
||||
createPolishDraftKeyRef.current = null
|
||||
}
|
||||
}, [createDialogOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadataDialog && !slug && !createDialogOpen) {
|
||||
return
|
||||
@@ -1024,6 +1050,175 @@ export function PostsPage() {
|
||||
normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)),
|
||||
[createForm],
|
||||
)
|
||||
|
||||
const buildEditorDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> | null => {
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
title: editor.title.trim() || editor.slug,
|
||||
slug: editor.slug,
|
||||
path: editor.path,
|
||||
markdown: buildDraftMarkdownForWindow(editor),
|
||||
savedMarkdown: editor.savedMarkdown,
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const buildCreateDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> => {
|
||||
const fallbackSlug = createForm.slug.trim() || 'new-post'
|
||||
|
||||
return {
|
||||
title: createForm.title.trim() || createForm.slug.trim() || '新建草稿',
|
||||
slug: fallbackSlug,
|
||||
path: buildVirtualPostPath(fallbackSlug),
|
||||
markdown: buildCreateMarkdownForWindow(createForm),
|
||||
savedMarkdown: buildCreateMarkdownForWindow(defaultCreateForm),
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const openDraftWorkbenchWindow = useCallback(
|
||||
(
|
||||
path: string,
|
||||
snapshot: Omit<DraftWindowSnapshot, 'createdAt'>,
|
||||
extraQuery?: Record<string, string>,
|
||||
) => {
|
||||
const draftKey = saveDraftWindowSnapshot(snapshot)
|
||||
const url = new URL(buildAdminRoute(path), window.location.origin)
|
||||
url.searchParams.set('draftKey', draftKey)
|
||||
|
||||
Object.entries(extraQuery ?? {}).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
const popup = window.open(
|
||||
url.toString(),
|
||||
'_blank',
|
||||
'popup=yes,width=1560,height=980,resizable=yes,scrollbars=yes',
|
||||
)
|
||||
|
||||
if (!popup) {
|
||||
toast.error('浏览器拦截了独立工作台窗口,请允许当前站点打开新窗口后重试。')
|
||||
return null
|
||||
}
|
||||
|
||||
popup.focus()
|
||||
return draftKey
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyExternalPolishResult = useCallback(
|
||||
(result: PolishWindowResult) => {
|
||||
if (result.target === 'editor') {
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setEditor((current) =>
|
||||
current ? applyPolishedEditorState(current, result.markdown) : current,
|
||||
)
|
||||
setEditorPolish(null)
|
||||
setEditorMode('workspace')
|
||||
})
|
||||
toast.success('独立 AI 润色结果已回填到当前文章。')
|
||||
return true
|
||||
}
|
||||
|
||||
if (!createDialogOpen) {
|
||||
return false
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
|
||||
setCreatePolish(null)
|
||||
setCreateMode('workspace')
|
||||
})
|
||||
toast.success('独立 AI 润色结果已回填到新建草稿。')
|
||||
return true
|
||||
},
|
||||
[createDialogOpen, editor],
|
||||
)
|
||||
|
||||
const flushPendingPolishResult = useCallback(
|
||||
(draftKey: string | null) => {
|
||||
const pending = readPolishWindowResult(draftKey)
|
||||
if (!pending || !applyExternalPolishResult(pending)) {
|
||||
return false
|
||||
}
|
||||
|
||||
consumePolishWindowResult(draftKey)
|
||||
return true
|
||||
},
|
||||
[applyExternalPolishResult],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const tryFlushAll = () => {
|
||||
if (flushPendingPolishResult(editorPolishDraftKeyRef.current)) {
|
||||
editorPolishDraftKeyRef.current = null
|
||||
}
|
||||
if (flushPendingPolishResult(createPolishDraftKeyRef.current)) {
|
||||
createPolishDraftKeyRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin || !event.data) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = event.data as Partial<PolishWindowResult> & { type?: string }
|
||||
if (
|
||||
payload.type !== 'termi-admin-post-polish-apply' ||
|
||||
typeof payload.draftKey !== 'string' ||
|
||||
typeof payload.markdown !== 'string'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const result: PolishWindowResult = {
|
||||
draftKey: payload.draftKey,
|
||||
markdown: payload.markdown,
|
||||
target: payload.target === 'create' ? 'create' : 'editor',
|
||||
createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : Date.now(),
|
||||
}
|
||||
|
||||
if (!applyExternalPolishResult(result)) {
|
||||
return
|
||||
}
|
||||
|
||||
consumePolishWindowResult(result.draftKey)
|
||||
if (result.target === 'editor') {
|
||||
editorPolishDraftKeyRef.current = null
|
||||
} else {
|
||||
createPolishDraftKeyRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (!event.key?.startsWith(POLISH_RESULT_STORAGE_PREFIX)) {
|
||||
return
|
||||
}
|
||||
|
||||
tryFlushAll()
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
window.addEventListener('storage', handleStorage)
|
||||
window.addEventListener('focus', tryFlushAll)
|
||||
tryFlushAll()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
window.removeEventListener('focus', tryFlushAll)
|
||||
}
|
||||
}, [applyExternalPolishResult, flushPendingPolishResult])
|
||||
|
||||
const compareStats = useMemo(() => {
|
||||
if (!editor) {
|
||||
return {
|
||||
@@ -1324,6 +1519,60 @@ export function PostsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openEditorPreviewWindow = useCallback(() => {
|
||||
const snapshot = buildEditorDraftSnapshot()
|
||||
if (!snapshot) {
|
||||
toast.error('请先打开一篇文章,再启动独立预览窗口。')
|
||||
return
|
||||
}
|
||||
|
||||
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/preview`, snapshot)
|
||||
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const openEditorCompareWindow = useCallback(() => {
|
||||
const snapshot = buildEditorDraftSnapshot()
|
||||
if (!snapshot) {
|
||||
toast.error('请先打开一篇文章,再启动独立对比窗口。')
|
||||
return
|
||||
}
|
||||
|
||||
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/compare`, snapshot)
|
||||
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const openEditorPolishWindow = useCallback(() => {
|
||||
const snapshot = buildEditorDraftSnapshot()
|
||||
if (!snapshot) {
|
||||
toast.error('请先打开一篇文章,再启动独立 AI 润色工作台。')
|
||||
return
|
||||
}
|
||||
|
||||
const draftKey = openDraftWorkbenchWindow('/posts/polish', snapshot, {
|
||||
target: 'editor',
|
||||
})
|
||||
|
||||
if (draftKey) {
|
||||
editorPolishDraftKeyRef.current = draftKey
|
||||
}
|
||||
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const openCreatePreviewWindow = useCallback(() => {
|
||||
openDraftWorkbenchWindow('/posts/preview', buildCreateDraftSnapshot())
|
||||
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const openCreateCompareWindow = useCallback(() => {
|
||||
openDraftWorkbenchWindow('/posts/compare', buildCreateDraftSnapshot())
|
||||
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const openCreatePolishWindow = useCallback(() => {
|
||||
const draftKey = openDraftWorkbenchWindow('/posts/polish', buildCreateDraftSnapshot(), {
|
||||
target: 'create',
|
||||
})
|
||||
|
||||
if (draftKey) {
|
||||
createPolishDraftKeyRef.current = draftKey
|
||||
}
|
||||
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
|
||||
|
||||
const editorPolishHunks = useMemo(
|
||||
() =>
|
||||
editorPolish
|
||||
@@ -1877,7 +2126,7 @@ export function PostsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={openCreateDialog}>
|
||||
<Button variant="outline" onClick={openCreateDialog} data-testid="posts-open-create">
|
||||
<FilePlus2 className="h-4 w-4" />
|
||||
新建草稿
|
||||
</Button>
|
||||
@@ -1919,6 +2168,7 @@ export function PostsPage() {
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row">
|
||||
<Input
|
||||
data-testid="posts-search"
|
||||
className="flex-1"
|
||||
placeholder="搜索标题、slug、分类、标签或摘要"
|
||||
value={searchTerm}
|
||||
@@ -1990,6 +2240,7 @@ export function PostsPage() {
|
||||
<button
|
||||
key={post.id}
|
||||
type="button"
|
||||
data-testid={`post-item-${post.slug}`}
|
||||
onClick={() => navigate(`/posts/${post.slug}`)}
|
||||
className={cn(
|
||||
'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all',
|
||||
@@ -2099,7 +2350,7 @@ export function PostsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={closeEditorDialog}>
|
||||
<Button variant="outline" onClick={closeEditorDialog} data-testid="post-editor-close">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回文章列表
|
||||
</Button>
|
||||
@@ -2148,6 +2399,7 @@ export function PostsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="post-editor-title"
|
||||
value={editor.title}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
@@ -2439,6 +2691,18 @@ export function PostsPage() {
|
||||
<Bot className="h-4 w-4" />
|
||||
{generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openEditorPreviewWindow}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
独立预览
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openEditorCompareWindow}>
|
||||
<GitCompareArrows className="h-4 w-4" />
|
||||
独立对比
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openEditorPolishWindow}>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -2474,11 +2738,12 @@ export function PostsPage() {
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
恢复
|
||||
</Button>
|
||||
<Button onClick={() => void saveEditor()} disabled={saving}>
|
||||
<Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="post-editor-delete"
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) {
|
||||
@@ -2614,6 +2879,7 @@ export function PostsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="post-create-title"
|
||||
value={createForm.title}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, title: event.target.value }))
|
||||
@@ -2622,6 +2888,7 @@ export function PostsPage() {
|
||||
</FormField>
|
||||
<FormField label="Slug" hint="留空则根据标题自动生成。">
|
||||
<Input
|
||||
data-testid="post-create-slug"
|
||||
value={createForm.slug}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, slug: event.target.value }))
|
||||
@@ -2871,6 +3138,18 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={openCreatePreviewWindow}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
独立预览
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openCreateCompareWindow}>
|
||||
<GitCompareArrows className="h-4 w-4" />
|
||||
独立对比
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openCreatePolishWindow}>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
独立润色
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -2907,6 +3186,7 @@ export function PostsPage() {
|
||||
恢复模板
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="post-create-submit"
|
||||
onClick={async () => {
|
||||
if (!createForm.title.trim()) {
|
||||
toast.error('创建文章时必须填写标题。')
|
||||
|
||||
@@ -305,6 +305,7 @@ export function ReviewsPage() {
|
||||
<button
|
||||
key={review.id}
|
||||
type="button"
|
||||
data-testid={`review-item-${review.id}`}
|
||||
onClick={() => {
|
||||
setSelectedId(review.id)
|
||||
setForm(toFormState(review))
|
||||
@@ -363,6 +364,7 @@ export function ReviewsPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
data-testid="review-save"
|
||||
onClick={async () => {
|
||||
if (!form.title.trim()) {
|
||||
toast.error('标题不能为空。')
|
||||
@@ -411,6 +413,7 @@ export function ReviewsPage() {
|
||||
{selectedReview ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
data-testid="review-delete"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条评测吗?')) {
|
||||
@@ -453,6 +456,7 @@ export function ReviewsPage() {
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="review-title"
|
||||
value={form.title}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, title: event.target.value }))
|
||||
@@ -487,6 +491,7 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
<FormField label="评测日期">
|
||||
<Input
|
||||
data-testid="review-date"
|
||||
type="date"
|
||||
value={form.reviewDate}
|
||||
onChange={(event) =>
|
||||
@@ -583,6 +588,7 @@ export function ReviewsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="review-ai-polish"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
@@ -603,6 +609,7 @@ export function ReviewsPage() {
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
data-testid="review-description"
|
||||
value={form.description}
|
||||
onChange={(event) => {
|
||||
const nextDescription = event.target.value
|
||||
@@ -625,6 +632,7 @@ export function ReviewsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="review-ai-adopt"
|
||||
onClick={() => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -248,6 +248,7 @@ export function RevisionsPage() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
data-testid="revisions-slug-filter"
|
||||
value={slugFilter}
|
||||
onChange={(event) => setSlugFilter(event.target.value)}
|
||||
placeholder="按 slug 过滤,例如 hello-world"
|
||||
@@ -304,7 +305,12 @@ export function RevisionsPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openDetail(item.id)}
|
||||
data-testid={`revision-open-${item.id}`}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
查看 / 对比
|
||||
</Button>
|
||||
@@ -371,6 +377,7 @@ export function RevisionsPage() {
|
||||
key={mode}
|
||||
size="sm"
|
||||
disabled={restoring !== null || !selected.item.has_markdown}
|
||||
data-testid={`revision-restore-${mode}`}
|
||||
onClick={() => void runRestore(mode)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
|
||||
@@ -485,13 +485,14 @@ export function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadSettings(true)}>
|
||||
<Button variant="outline" onClick={() => void loadSettings(true)} data-testid="site-settings-refresh">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={reindexing}
|
||||
data-testid="site-settings-reindex"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setReindexing(true)
|
||||
@@ -510,6 +511,7 @@ export function SiteSettingsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
data-testid="site-settings-save"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
@@ -543,6 +545,7 @@ export function SiteSettingsPage() {
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="站点名称">
|
||||
<Input
|
||||
data-testid="site-settings-site-name"
|
||||
value={form.site_name ?? ''}
|
||||
onChange={(event) => updateField('site_name', event.target.value)}
|
||||
/>
|
||||
@@ -724,6 +727,7 @@ export function SiteSettingsPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
|
||||
<Input
|
||||
data-testid="site-settings-popup-title"
|
||||
value={form.subscription_popup_title}
|
||||
onChange={(event) =>
|
||||
updateField('subscription_popup_title', event.target.value)
|
||||
@@ -1105,6 +1109,7 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-provider"
|
||||
disabled={testingProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -1243,6 +1248,7 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-image-provider"
|
||||
disabled={testingImageProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -1396,6 +1402,7 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-storage"
|
||||
disabled={testingR2Storage}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -188,6 +188,7 @@ export function SubscriptionsPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={digesting !== null}
|
||||
data-testid="subscriptions-send-weekly"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('weekly')
|
||||
@@ -206,6 +207,7 @@ export function SubscriptionsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={digesting !== null}
|
||||
data-testid="subscriptions-send-monthly"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('monthly')
|
||||
@@ -314,7 +316,12 @@ export function SubscriptionsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={submitting}
|
||||
onClick={() => void submitForm()}
|
||||
data-testid="subscriptions-save"
|
||||
>
|
||||
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
|
||||
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
|
||||
</Button>
|
||||
@@ -349,7 +356,7 @@ export function SubscriptionsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
|
||||
@@ -382,6 +389,7 @@ export function SubscriptionsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid={`subscription-edit-${item.id}`}
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
@@ -402,6 +410,7 @@ export function SubscriptionsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-test-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
@@ -422,6 +431,7 @@ export function SubscriptionsPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
data-testid={`subscription-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
|
||||
@@ -218,6 +218,7 @@ export function TagsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
data-testid="tags-search"
|
||||
placeholder="按标签名 / slug / 描述搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
@@ -229,6 +230,7 @@ export function TagsPage() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`tag-item-${item.slug}`}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setForm(toFormState(item))
|
||||
@@ -286,6 +288,7 @@ export function TagsPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<FormField label="标签名称" hint="例如:astro、rust、workflow。">
|
||||
<Input
|
||||
data-testid="tag-name-input"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="输入标签名称"
|
||||
@@ -293,6 +296,7 @@ export function TagsPage() {
|
||||
</FormField>
|
||||
<FormField label="标签 slug" hint="留空时自动从英文名称生成;中文建议手填。">
|
||||
<Input
|
||||
data-testid="tag-slug-input"
|
||||
value={form.slug}
|
||||
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
|
||||
placeholder="astro"
|
||||
@@ -377,7 +381,7 @@ export function TagsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
<Button onClick={() => void handleSave()} disabled={saving} data-testid="tag-save">
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
|
||||
</Button>
|
||||
@@ -388,6 +392,7 @@ export function TagsPage() {
|
||||
variant="ghost"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={!selectedItem || deleting}
|
||||
data-testid="tag-delete"
|
||||
className="text-rose-600 hover:text-rose-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
Reference in New Issue
Block a user