From 99b308e8004932cd4478135d7f8ff903054928b5 Mon Sep 17 00:00:00 2001 From: limitcool Date: Tue, 31 Mar 2026 00:12:02 +0800 Subject: [PATCH] chore: checkpoint admin editor and perf work --- admin/src/App.tsx | 142 +- admin/src/components/app-shell.tsx | 14 + admin/src/components/lazy-monaco.tsx | 72 + admin/src/components/markdown-preview.tsx | 7 +- admin/src/components/markdown-workbench.tsx | 11 +- admin/src/components/ui/select.tsx | 477 ++++- admin/src/index.css | 19 +- admin/src/lib/api.ts | 79 + admin/src/lib/types.ts | 138 ++ admin/src/pages/analytics-page.tsx | 413 ++++ admin/src/pages/media-page.tsx | 190 ++ admin/src/pages/post-polish-page.tsx | 4 +- admin/src/pages/posts-page.tsx | 1725 ++++++++++++----- admin/src/pages/reviews-page.tsx | 248 ++- admin/src/pages/site-settings-page.tsx | 690 +++++-- backend/Cargo.lock | 966 ++++++++- backend/Cargo.toml | 4 +- backend/assets/views/admin/site_settings.html | 5 +- backend/content/posts/redis.md | 21 +- backend/content/posts/tmux.md | 20 +- backend/migration/src/lib.rs | 8 + .../m20260329_000014_create_query_events.rs | 73 + ..._add_image_ai_settings_to_site_settings.rs | 101 + ..._add_r2_media_settings_to_site_settings.rs | 128 ++ ...media_storage_provider_to_site_settings.rs | 53 + backend/src/controllers/admin_api.rs | 329 +++- backend/src/controllers/ai.rs | 173 +- backend/src/controllers/review.rs | 25 +- backend/src/controllers/search.rs | 49 +- backend/src/controllers/site_settings.rs | 93 +- backend/src/models/_entities/mod.rs | 1 + backend/src/models/_entities/prelude.rs | 1 + backend/src/models/_entities/query_events.rs | 33 + backend/src/models/_entities/site_settings.rs | 12 + backend/src/services/ai.rs | 731 ++++++- backend/src/services/analytics.rs | 441 +++++ backend/src/services/mod.rs | 2 + backend/src/services/storage.rs | 513 +++++ frontend/src/components/Header.astro | 4 +- frontend/src/layouts/BaseLayout.astro | 16 +- frontend/src/pages/about/index.astro | 6 +- frontend/src/pages/articles/[slug].astro | 23 +- frontend/src/pages/articles/index.astro | 11 +- frontend/src/pages/ask/index.astro | 6 +- frontend/src/pages/index.astro | 21 +- 45 files changed, 7265 insertions(+), 833 deletions(-) create mode 100644 admin/src/components/lazy-monaco.tsx create mode 100644 admin/src/pages/analytics-page.tsx create mode 100644 admin/src/pages/media-page.tsx create mode 100644 backend/migration/src/m20260329_000014_create_query_events.rs create mode 100644 backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs create mode 100644 backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs create mode 100644 backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs create mode 100644 backend/src/models/_entities/query_events.rs create mode 100644 backend/src/services/analytics.rs create mode 100644 backend/src/services/storage.rs diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 0d6163f..6a82de9 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,5 +1,7 @@ import { createContext, + lazy, + Suspense, startTransition, useContext, useEffect, @@ -22,13 +24,40 @@ import { Toaster, toast } from 'sonner' import { AppShell } from '@/components/app-shell' import { adminApi, ApiError } from '@/lib/api' import type { AdminSessionResponse } from '@/lib/types' -import { CommentsPage } from '@/pages/comments-page' -import { DashboardPage } from '@/pages/dashboard-page' -import { FriendLinksPage } from '@/pages/friend-links-page' import { LoginPage } from '@/pages/login-page' -import { PostsPage } from '@/pages/posts-page' -import { ReviewsPage } from '@/pages/reviews-page' -import { SiteSettingsPage } from '@/pages/site-settings-page' + +const DashboardPage = lazy(async () => { + const mod = await import('@/pages/dashboard-page') + return { default: mod.DashboardPage } +}) +const AnalyticsPage = lazy(async () => { + const mod = await import('@/pages/analytics-page') + return { default: mod.AnalyticsPage } +}) +const PostsPage = lazy(async () => { + const mod = await import('@/pages/posts-page') + return { default: mod.PostsPage } +}) +const CommentsPage = lazy(async () => { + const mod = await import('@/pages/comments-page') + return { default: mod.CommentsPage } +}) +const FriendLinksPage = lazy(async () => { + const mod = await import('@/pages/friend-links-page') + return { default: mod.FriendLinksPage } +}) +const MediaPage = lazy(async () => { + const mod = await import('@/pages/media-page') + return { default: mod.MediaPage } +}) +const ReviewsPage = lazy(async () => { + const mod = await import('@/pages/reviews-page') + return { default: mod.ReviewsPage } +}) +const SiteSettingsPage = lazy(async () => { + const mod = await import('@/pages/site-settings-page') + return { default: mod.SiteSettingsPage } +}) type SessionContextValue = { session: AdminSessionResponse @@ -69,6 +98,26 @@ function AppLoadingScreen() { ) } +function RouteLoadingScreen() { + return ( +
+
+
+ +
+
+

正在加载页面模块

+

大型编辑器与工作台页面会按需加载。

+
+
+
+ ) +} + +function LazyRoute({ children }: { children: ReactNode }) { + return }>{children} +} + function RequireAuth({ children }: { children: ReactNode }) { const { session } = useSession() @@ -151,14 +200,79 @@ function AppRoutes() { } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> + > + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/admin/src/components/app-shell.tsx b/admin/src/components/app-shell.tsx index eae44c4..ae1d8f8 100644 --- a/admin/src/components/app-shell.tsx +++ b/admin/src/components/app-shell.tsx @@ -1,6 +1,8 @@ import { + BarChart3, BookOpenText, ExternalLink, + Image as ImageIcon, LayoutDashboard, Link2, LogOut, @@ -25,6 +27,12 @@ const primaryNav = [ description: '站点运营总览', icon: LayoutDashboard, }, + { + to: '/analytics', + label: '数据分析', + description: '搜索词与 AI 问答洞察', + icon: BarChart3, + }, { to: '/posts', label: '文章', @@ -49,6 +57,12 @@ const primaryNav = [ description: '评测内容库', icon: BookOpenText, }, + { + to: '/media', + label: '媒体库', + description: '对象存储图片管理', + icon: ImageIcon, + }, { to: '/settings', label: '设置', diff --git a/admin/src/components/lazy-monaco.tsx b/admin/src/components/lazy-monaco.tsx new file mode 100644 index 0000000..1502928 --- /dev/null +++ b/admin/src/components/lazy-monaco.tsx @@ -0,0 +1,72 @@ +import { lazy, Suspense } from 'react' + +import type { DiffEditorProps, EditorProps } from '@monaco-editor/react' + +const MonacoEditor = lazy(async () => { + const mod = await import('@monaco-editor/react') + return { default: mod.default } +}) + +const MonacoDiffEditor = lazy(async () => { + const mod = await import('@monaco-editor/react') + return { default: mod.DiffEditor } +}) + +function MonacoLoading({ + height, + width, + className, + loading, +}: { + height?: string | number + width?: string | number + className?: string + loading?: React.ReactNode +}) { + return ( +
+ {loading ?? ( +
+ 正在加载编辑器... +
+ )} +
+ ) +} + +export function LazyEditor(props: EditorProps) { + return ( + + } + > + + + ) +} + +export function LazyDiffEditor(props: DiffEditorProps) { + return ( + + } + > + + + ) +} diff --git a/admin/src/components/markdown-preview.tsx b/admin/src/components/markdown-preview.tsx index 12756ce..11edadf 100644 --- a/admin/src/components/markdown-preview.tsx +++ b/admin/src/components/markdown-preview.tsx @@ -1,6 +1,6 @@ import DOMPurify from 'dompurify' import { marked } from 'marked' -import { useMemo } from 'react' +import { useDeferredValue, useMemo } from 'react' import { cn } from '@/lib/utils' @@ -15,10 +15,11 @@ marked.setOptions({ }) export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) { + const deferredMarkdown = useDeferredValue(markdown) const html = useMemo(() => { - const rendered = marked.parse(markdown || '暂无内容。') + const rendered = marked.parse(deferredMarkdown || '暂无内容。') return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '') - }, [markdown]) + }, [deferredMarkdown]) return (
diff --git a/admin/src/components/markdown-workbench.tsx b/admin/src/components/markdown-workbench.tsx index 778928f..6a0e023 100644 --- a/admin/src/components/markdown-workbench.tsx +++ b/admin/src/components/markdown-workbench.tsx @@ -2,9 +2,10 @@ import type { ReactNode } from 'react' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' -import Editor, { DiffEditor, type BeforeMount } from '@monaco-editor/react' +import type { BeforeMount } from '@monaco-editor/react' import { Expand, Minimize2, Sparkles } from 'lucide-react' +import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -16,6 +17,7 @@ type MarkdownWorkbenchProps = { originalValue: string diffValue?: string path: string + workspaceHeightClassName?: string readOnly?: boolean mode: MarkdownWorkbenchMode visiblePanels: MarkdownWorkbenchPanel[] @@ -114,6 +116,7 @@ export function MarkdownWorkbench({ originalValue, diffValue, path, + workspaceHeightClassName = 'h-[560px]', readOnly = false, mode, visiblePanels, @@ -128,7 +131,7 @@ export function MarkdownWorkbench({ onVisiblePanelsChange, }: MarkdownWorkbenchProps) { const [fullscreen, setFullscreen] = useState(false) - const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : 'h-[560px]' + const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName const diffContent = diffValue ?? value const polishEnabled = allowPolish ?? Boolean(polishPanel) const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels) @@ -262,7 +265,7 @@ export function MarkdownWorkbench({ {panel === 'edit' ? (
- - >( - ({ className, ...props }, ref) => ( - { + nativeSelectRef.current = node + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } else if (forwardedRef) { + forwardedRef.current = node + } + }} + tabIndex={-1} + value={isControlled ? currentValue : internalValue} + > + {children} + + + + + {menu} +
+ ) + }, ) Select.displayName = 'Select' diff --git a/admin/src/index.css b/admin/src/index.css index dab8bd2..7ee90e4 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -116,6 +116,23 @@ a { button, input, -textarea { +textarea, +select { font: inherit; } + +@keyframes custom-select-pop { + from { + opacity: 0; + transform: translateY(-2px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.custom-select-popover { + animation: custom-select-pop 0.1s ease-out; +} diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 0860ddf..34b6d7f 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -1,9 +1,19 @@ import type { + AdminAnalyticsResponse, + AdminAiImageProviderTestResponse, AdminAiReindexResponse, AdminAiProviderTestResponse, + AdminImageUploadResponse, + AdminMediaDeleteResponse, + AdminMediaListResponse, + AdminPostCoverImageRequest, + AdminPostCoverImageResponse, AdminDashboardResponse, AdminPostMetadataResponse, AdminPostPolishResponse, + AdminReviewPolishRequest, + AdminReviewPolishResponse, + AdminR2ConnectivityResponse, AdminSessionResponse, AdminSiteSettingsResponse, CommentListQuery, @@ -117,6 +127,7 @@ export const adminApi = { method: 'DELETE', }), dashboard: () => request('/api/admin/dashboard'), + analytics: () => request('/api/admin/analytics'), getSiteSettings: () => request('/api/admin/site-settings'), updateSiteSettings: (payload: SiteSettingsPayload) => request('/api/admin/site-settings', { @@ -139,6 +150,48 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ provider }), }), + testAiImageProvider: (provider: { + provider: string + api_base: string | null + api_key: string | null + image_model: string | null + }) => + request('/api/admin/ai/test-image-provider', { + method: 'POST', + body: JSON.stringify({ + provider: provider.provider, + api_base: provider.api_base, + api_key: provider.api_key, + image_model: provider.image_model, + }), + }), + uploadReviewCoverImage: (file: File) => { + const formData = new FormData() + formData.append('file', file, file.name) + + return request('/api/admin/storage/review-cover', { + method: 'POST', + body: formData, + }) + }, + testR2Storage: () => + request('/api/admin/storage/r2/test', { + method: 'POST', + }), + listMediaObjects: (query?: { prefix?: string; limit?: number }) => + request( + appendQueryParams('/api/admin/storage/media', { + prefix: query?.prefix, + limit: query?.limit, + }), + ), + deleteMediaObject: (key: string) => + request( + `/api/admin/storage/media?key=${encodeURIComponent(key)}`, + { + method: 'DELETE', + }, + ), generatePostMetadata: (markdown: string) => request('/api/admin/ai/post-metadata', { method: 'POST', @@ -149,6 +202,32 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ markdown }), }), + polishReviewDescription: (payload: AdminReviewPolishRequest) => + request('/api/admin/ai/polish-review', { + method: 'POST', + body: JSON.stringify({ + title: payload.title, + review_type: payload.reviewType, + rating: payload.rating, + review_date: payload.reviewDate, + status: payload.status, + tags: payload.tags, + description: payload.description, + }), + }), + generatePostCoverImage: (payload: AdminPostCoverImageRequest) => + request('/api/admin/ai/post-cover', { + method: 'POST', + body: JSON.stringify({ + title: payload.title, + description: payload.description, + category: payload.category, + tags: payload.tags, + post_type: payload.postType, + slug: payload.slug, + markdown: payload.markdown, + }), + }), listPosts: (query?: PostListQuery) => request( appendQueryParams('/api/posts', { diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 8ed7c64..c68923d 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -71,6 +71,58 @@ export interface AdminDashboardResponse { recent_reviews: DashboardReviewItem[] } +export interface AnalyticsOverview { + total_searches: number + total_ai_questions: number + searches_last_24h: number + ai_questions_last_24h: number + searches_last_7d: number + ai_questions_last_7d: number + unique_search_terms_last_7d: number + unique_ai_questions_last_7d: number + avg_search_results_last_7d: number + avg_ai_latency_ms_last_7d: number | null +} + +export interface AnalyticsTopQuery { + query: string + count: number + last_seen_at: string +} + +export interface AnalyticsRecentEvent { + id: number + event_type: string + query: string + result_count: number | null + success: boolean | null + response_mode: string | null + provider: string | null + chat_model: string | null + latency_ms: number | null + created_at: string +} + +export interface AnalyticsProviderBucket { + provider: string + count: number +} + +export interface AnalyticsDailyBucket { + date: string + searches: number + ai_questions: number +} + +export interface AdminAnalyticsResponse { + overview: AnalyticsOverview + top_search_terms: AnalyticsTopQuery[] + top_ai_questions: AnalyticsTopQuery[] + recent_events: AnalyticsRecentEvent[] + providers_last_7d: AnalyticsProviderBucket[] + daily_activity: AnalyticsDailyBucket[] +} + export interface AdminSiteSettingsResponse { id: number site_name: string | null @@ -96,6 +148,10 @@ export interface AdminSiteSettingsResponse { ai_api_base: string | null ai_api_key: string | null ai_chat_model: string | null + ai_image_provider: string | null + ai_image_api_base: string | null + ai_image_api_key: string | null + ai_image_model: string | null ai_providers: AiProviderConfig[] ai_active_provider_id: string | null ai_embedding_model: string | null @@ -105,6 +161,12 @@ export interface AdminSiteSettingsResponse { ai_last_indexed_at: string | null ai_chunks_count: number ai_local_embedding: string + media_storage_provider: string | null + media_r2_account_id: string | null + media_r2_bucket: string | null + media_r2_public_base_url: string | null + media_r2_access_key_id: string | null + media_r2_secret_access_key: string | null } export interface AiProviderConfig { @@ -114,6 +176,7 @@ export interface AiProviderConfig { api_base: string | null api_key: string | null chat_model: string | null + image_model: string | null } export interface SiteSettingsPayload { @@ -140,12 +203,22 @@ export interface SiteSettingsPayload { aiApiBase?: string | null aiApiKey?: string | null aiChatModel?: string | null + aiImageProvider?: string | null + aiImageApiBase?: string | null + aiImageApiKey?: string | null + aiImageModel?: string | null aiProviders?: AiProviderConfig[] aiActiveProviderId?: string | null aiEmbeddingModel?: string | null aiSystemPrompt?: string | null aiTopK?: number | null aiChunkSize?: number | null + mediaStorageProvider?: string | null + mediaR2AccountId?: string | null + mediaR2Bucket?: string | null + mediaR2PublicBaseUrl?: string | null + mediaR2AccessKeyId?: string | null + mediaR2SecretAccessKey?: string | null } export interface AdminAiReindexResponse { @@ -160,6 +233,42 @@ export interface AdminAiProviderTestResponse { reply_preview: string } +export interface AdminAiImageProviderTestResponse { + provider: string + endpoint: string + image_model: string + result_preview: string +} + +export interface AdminImageUploadResponse { + url: string + key: string +} + +export interface AdminR2ConnectivityResponse { + bucket: string + public_base_url: string +} + +export interface AdminMediaObjectResponse { + key: string + url: string + size_bytes: number + last_modified: string | null +} + +export interface AdminMediaListResponse { + provider: string + bucket: string + public_base_url: string + items: AdminMediaObjectResponse[] +} + +export interface AdminMediaDeleteResponse { + deleted: boolean + key: string +} + export interface MusicTrack { title: string artist?: string | null @@ -182,6 +291,35 @@ export interface AdminPostPolishResponse { polished_markdown: string } +export interface AdminReviewPolishRequest { + title: string + reviewType: string + rating: number + reviewDate?: string | null + status: string + tags: string[] + description: string +} + +export interface AdminReviewPolishResponse { + polished_description: string +} + +export interface AdminPostCoverImageRequest { + title: string + description?: string | null + category?: string | null + tags: string[] + postType: string + slug?: string | null + markdown: string +} + +export interface AdminPostCoverImageResponse { + image_url: string + prompt: string +} + export interface PostRecord { created_at: string updated_at: string diff --git a/admin/src/pages/analytics-page.tsx b/admin/src/pages/analytics-page.tsx new file mode 100644 index 0000000..14e7f3f --- /dev/null +++ b/admin/src/pages/analytics-page.tsx @@ -0,0 +1,413 @@ +import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { adminApi, ApiError } from '@/lib/api' +import type { AdminAnalyticsResponse } from '@/lib/types' + +function StatCard({ + label, + value, + note, + icon: Icon, +}: { + label: string + value: string + note: string + icon: typeof Search +}) { + return ( + + +
+

{label}

+
{value}
+

{note}

+
+
+ +
+
+
+ ) +} + +function formatEventType(value: string) { + return value === 'ai_question' ? 'AI 问答' : '站内搜索' +} + +function formatSuccess(value: boolean | null) { + if (value === null) { + return '未记录' + } + + return value ? '成功' : '失败' +} + +export function AnalyticsPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadAnalytics = useCallback(async (showToast = false) => { + try { + if (showToast) { + setRefreshing(true) + } + + const next = await adminApi.analytics() + startTransition(() => { + setData(next) + }) + + if (showToast) { + toast.success('数据分析已刷新。') + } + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return + } + toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + void loadAnalytics(false) + }, [loadAnalytics]) + + const maxDailyTotal = useMemo(() => { + if (!data?.daily_activity.length) { + return 1 + } + + return Math.max( + ...data.daily_activity.map((item) => item.searches + item.ai_questions), + 1, + ) + }, [data]) + + if (loading || !data) { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ + +
+
+ ) + } + + const statCards = [ + { + label: '累计搜索', + value: String(data.overview.total_searches), + note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)} 条`, + icon: Search, + }, + { + label: '累计 AI 提问', + value: String(data.overview.total_ai_questions), + note: `近 7 天 ${data.overview.ai_questions_last_7d} 次`, + icon: BrainCircuit, + }, + { + label: '24 小时活跃', + value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h), + note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`, + icon: Clock3, + }, + { + label: '近 7 天去重词', + value: String( + data.overview.unique_search_terms_last_7d + + data.overview.unique_ai_questions_last_7d, + ), + note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`, + icon: BarChart3, + }, + ] + + return ( +
+
+
+ 数据分析 +
+

前台搜索与 AI 问答洞察

+

+ 这里会记录用户真实提交过的站内搜索词和 AI 提问,方便你判断内容需求、热点问题和接入质量。 +

+
+
+ +
+ + +
+
+ +
+ {statCards.map((item) => ( + + ))} +
+ +
+
+ + +
+ 最近记录 + + 最近一批真实发生的搜索和 AI 问答请求。 + +
+ {data.recent_events.length} 条 +
+ + + + + 类型 + 内容 + 结果 + 时间 + + + + {data.recent_events.map((event) => ( + + +
+ + {formatEventType(event.event_type)} + + {event.response_mode ? ( +

+ {event.response_mode} +

+ ) : null} +
+
+ +
+

{event.query}

+

+ {event.provider ? `${event.provider}` : '未记录渠道'} + {event.chat_model ? ` / ${event.chat_model}` : ''} +

+
+
+ +
{formatSuccess(event.success)}
+
+ {event.result_count !== null ? `${event.result_count} 条/源` : '无'} +
+ {event.latency_ms !== null ? ( +
+ {event.latency_ms} ms +
+ ) : null} +
+ {event.created_at} +
+ ))} +
+
+
+
+ +
+ + +
+ 热门搜索词 + + 近 7 天最常被搜索的关键词。 + +
+ {data.top_search_terms.length} 个 +
+ + {data.top_search_terms.length ? ( + data.top_search_terms.map((item) => ( +
+
+

{item.query}

+ {item.count} +
+

+ 最近一次:{item.last_seen_at} +

+
+ )) + ) : ( +

最近 7 天还没有站内搜索记录。

+ )} +
+
+ + + +
+ 热门 AI 问题 + + 近 7 天重复出现最多的提问。 + +
+ {data.top_ai_questions.length} 个 +
+ + {data.top_ai_questions.length ? ( + data.top_ai_questions.map((item) => ( +
+
+

{item.query}

+ {item.count} +
+

+ 最近一次:{item.last_seen_at} +

+
+ )) + ) : ( +

最近 7 天还没有 AI 提问记录。

+ )} +
+
+
+
+ +
+ + + 分析侧栏 + + 24 小时、7 天和模型渠道维度的快速摘要。 + + + +
+

+ 24 小时搜索 +

+

{data.overview.searches_last_24h}

+
+
+

+ 24 小时 AI 提问 +

+

{data.overview.ai_questions_last_24h}

+
+
+

+ AI 平均耗时 +

+

+ {data.overview.avg_ai_latency_ms_last_7d !== null + ? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms` + : '暂无'} +

+

统计范围:最近 7 天

+
+
+
+ + + + 模型渠道分布 + + 最近 7 天 AI 请求实际使用的 provider 厂商。 + + + + {data.providers_last_7d.length ? ( + data.providers_last_7d.map((item) => ( +
+ {item.provider} + {item.count} +
+ )) + ) : ( +

最近 7 天还没有 AI 渠道数据。

+ )} +
+
+ + + + 7 天走势 + + 搜索与 AI 问答的日维度活动量。 + + + + {data.daily_activity.map((item) => { + const total = item.searches + item.ai_questions + const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%` + + return ( +
+
+ {item.date} + + 搜索 {item.searches} / AI {item.ai_questions} + +
+
+
+
+
+ ) + })} + + +
+
+
+ ) +} diff --git a/admin/src/pages/media-page.tsx b/admin/src/pages/media-page.tsx new file mode 100644 index 0000000..691a98a --- /dev/null +++ b/admin/src/pages/media-page.tsx @@ -0,0 +1,190 @@ +import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Select } from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { adminApi, ApiError } from '@/lib/api' +import type { AdminMediaObjectResponse } from '@/lib/types' + +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) { + return '0 B' + } + const units = ['B', 'KB', 'MB', 'GB'] + let size = value + let unitIndex = 0 + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex += 1 + } + return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}` +} + +export function MediaPage() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [deletingKey, setDeletingKey] = useState(null) + const [prefixFilter, setPrefixFilter] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + const [provider, setProvider] = useState(null) + const [bucket, setBucket] = useState(null) + + const loadItems = useCallback(async (showToast = false) => { + try { + if (showToast) { + setRefreshing(true) + } + const prefix = prefixFilter === 'all' ? undefined : prefixFilter + const result = await adminApi.listMediaObjects({ prefix, limit: 200 }) + startTransition(() => { + setItems(result.items) + setProvider(result.provider) + setBucket(result.bucket) + }) + if (showToast) { + toast.success('媒体对象列表已刷新。') + } + } catch (error) { + toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。') + } finally { + setLoading(false) + setRefreshing(false) + } + }, [prefixFilter]) + + useEffect(() => { + void loadItems(false) + }, [loadItems]) + + const filteredItems = useMemo(() => { + const keyword = searchTerm.trim().toLowerCase() + if (!keyword) { + return items + } + return items.filter((item) => item.key.toLowerCase().includes(keyword)) + }, [items, searchTerm]) + + return ( +
+
+
+ 媒体库 +
+

对象存储媒体管理

+

+ 查看当前对象存储里的封面资源,支持筛选、复制链接和删除无用对象。 +

+
+
+ +
+ +
+
+ + + + 当前存储 + + Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'} + + + + + setSearchTerm(event.target.value)} + /> + + + + {loading ? ( + + ) : ( +
+ {filteredItems.map((item) => ( + +
+ {item.key} +
+ +
+

{item.key}

+
+ {formatBytes(item.size_bytes)} + {item.last_modified ? {item.last_modified} : null} +
+
+
+ + +
+
+
+ ))} + + {!filteredItems.length ? ( + + + +

当前筛选条件下没有媒体对象。

+
+
+ ) : null} +
+ )} +
+ ) +} diff --git a/admin/src/pages/post-polish-page.tsx b/admin/src/pages/post-polish-page.tsx index c9eabfe..3ca47da 100644 --- a/admin/src/pages/post-polish-page.tsx +++ b/admin/src/pages/post-polish-page.tsx @@ -1,4 +1,3 @@ -import { DiffEditor } from '@monaco-editor/react' import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react' import { startTransition, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -8,6 +7,7 @@ import { editorTheme, sharedOptions, } from '@/components/markdown-workbench' +import { LazyDiffEditor } from '@/components/lazy-monaco' import { MarkdownPreview } from '@/components/markdown-preview' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -191,7 +191,7 @@ export function PostPolishPage() { 当前合并结果
- } +type MetadataProposalField = 'title' | 'slug' | 'description' | 'category' | 'tags' + +type MetadataProposalSelection = Record + +type MetadataSnapshot = { + title: string + slug: string + description: string + category: string + tags: string[] +} + +type MetadataProposalState = { + current: MetadataSnapshot + suggested: MetadataSnapshot + selected: MetadataProposalSelection +} + +type MetadataDialogTarget = 'editor' | 'create' + +type MetadataDialogState = { + target: MetadataDialogTarget + title: string + path: string + proposal: MetadataProposalState +} + +type MetadataDraftSource = Pick< + CreatePostFormState, + 'title' | 'slug' | 'description' | 'category' | 'tags' +> + +const editorMetadataProposalFields: MetadataProposalField[] = [ + 'title', + 'description', + 'category', + 'tags', +] +const createMetadataProposalFields: MetadataProposalField[] = [ + 'title', + 'slug', + 'description', + 'category', + 'tags', +] +const FRONTEND_DEV_ORIGIN = 'http://localhost:4321' + const defaultCreateForm: CreatePostFormState = { title: '', slug: '', @@ -103,8 +156,9 @@ const defaultCreateForm: CreatePostFormState = { markdown: '# 未命名文章\n', } -const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview'] +const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit'] const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff'] +const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) { switch (panel) { @@ -123,20 +177,6 @@ function normalizeWorkbenchPanels(panels: MarkdownWorkbenchPanel[]) { return nextPanels.length ? nextPanels : [...defaultWorkbenchPanels] } -function toggleWorkbenchPanel( - panels: MarkdownWorkbenchPanel[], - panel: MarkdownWorkbenchPanel, -) { - const currentPanels = normalizeWorkbenchPanels(panels) - const nextPanels = currentPanels.includes(panel) - ? currentPanels.filter((item) => item !== panel) - : orderedWorkbenchPanels.filter( - (item) => currentPanels.includes(item) || item === panel, - ) - - return nextPanels.length ? nextPanels : defaultWorkbenchPanels.slice(0, 1) -} - function formatWorkbenchStateLabel( mode: MarkdownWorkbenchMode, panels: MarkdownWorkbenchPanel[], @@ -157,6 +197,204 @@ function parseImageList(value: string) { .filter(Boolean) } +function parseTagList(value: string) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function resolveCoverPreviewUrl(value: string) { + const trimmed = value.trim() + if (!trimmed) { + return '' + } + + if (/^(?:https?:\/\/|data:|blob:)/i.test(trimmed)) { + return trimmed + } + + if (typeof window === 'undefined') { + return trimmed + } + + if (trimmed.startsWith('/')) { + if (import.meta.env.DEV) { + return new URL(trimmed, FRONTEND_DEV_ORIGIN).toString() + } + + return new URL(trimmed, window.location.origin).toString() + } + + return trimmed +} + +function normalizeMetadataText(value: string) { + return value.trim() +} + +function normalizeMetadataTags(tags: string[]) { + return tags + .map((item) => item.trim()) + .filter(Boolean) +} + +function createEmptyMetadataSelection(): MetadataProposalSelection { + return { + title: false, + slug: false, + description: false, + category: false, + tags: false, + } +} + +function metadataTagsEqual(left: string[], right: string[]) { + const normalizedLeft = normalizeMetadataTags(left) + const normalizedRight = normalizeMetadataTags(right) + + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((item, index) => item === normalizedRight[index]) + ) +} + +function metadataProposalFieldsForTarget(target: MetadataDialogTarget) { + return target === 'editor' ? editorMetadataProposalFields : createMetadataProposalFields +} + +function buildMetadataSnapshot(form: MetadataDraftSource): MetadataSnapshot { + return { + title: form.title, + slug: form.slug, + description: form.description, + category: form.category, + tags: parseTagList(form.tags), + } +} + +function buildMetadataSelection( + current: MetadataSnapshot, + suggested: MetadataSnapshot, + fields: MetadataProposalField[], +): MetadataProposalSelection { + const selection = createEmptyMetadataSelection() + + fields.forEach((field) => { + switch (field) { + case 'title': + selection.title = + normalizeMetadataText(current.title) !== normalizeMetadataText(suggested.title) + break + case 'slug': + selection.slug = + normalizeMetadataText(current.slug) !== normalizeMetadataText(suggested.slug) + break + case 'description': + selection.description = + normalizeMetadataText(current.description) !== + normalizeMetadataText(suggested.description) + break + case 'category': + selection.category = + normalizeMetadataText(current.category) !== normalizeMetadataText(suggested.category) + break + case 'tags': + selection.tags = !metadataTagsEqual(current.tags, suggested.tags) + break + default: + break + } + }) + + return selection +} + +function buildMetadataProposal( + form: MetadataDraftSource, + generated: AdminPostMetadataResponse, + target: MetadataDialogTarget, +): MetadataProposalState { + const fields = metadataProposalFieldsForTarget(target) + const current = buildMetadataSnapshot(form) + const suggested: MetadataSnapshot = { + title: generated.title.trim(), + slug: generated.slug.trim(), + description: generated.description.trim(), + category: generated.category.trim(), + tags: normalizeMetadataTags(generated.tags), + } + + return { + current, + suggested, + selected: buildMetadataSelection(current, suggested, fields), + } +} + +function metadataFieldLabel(field: MetadataProposalField) { + switch (field) { + case 'title': + return '标题' + case 'slug': + return 'Slug' + case 'description': + return '摘要' + case 'category': + return '分类' + case 'tags': + return '标签' + default: + return field + } +} + +function metadataFieldChanged( + proposal: MetadataProposalState, + field: MetadataProposalField, +) { + switch (field) { + case 'title': + return ( + normalizeMetadataText(proposal.current.title) !== + normalizeMetadataText(proposal.suggested.title) + ) + case 'slug': + return ( + normalizeMetadataText(proposal.current.slug) !== + normalizeMetadataText(proposal.suggested.slug) + ) + case 'description': + return ( + normalizeMetadataText(proposal.current.description) !== + normalizeMetadataText(proposal.suggested.description) + ) + case 'category': + return ( + normalizeMetadataText(proposal.current.category) !== + normalizeMetadataText(proposal.suggested.category) + ) + case 'tags': + return !metadataTagsEqual(proposal.current.tags, proposal.suggested.tags) + default: + return false + } +} + +function countSelectedMetadataFields( + selection: MetadataProposalSelection, + fields: MetadataProposalField[], +) { + return fields.filter((field) => selection[field]).length +} + +function countChangedMetadataFields( + proposal: MetadataProposalState, + fields: MetadataProposalField[], +) { + return fields.filter((field) => metadataFieldChanged(proposal, field)).length +} + function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, '\n') if (!normalized.startsWith('---\n')) { @@ -400,11 +638,17 @@ export function PostsPage() { const [deleting, setDeleting] = useState(false) const [importing, setImporting] = useState(false) const [generatingMetadata, setGeneratingMetadata] = useState(false) + const [generatingEditorMetadataProposal, setGeneratingEditorMetadataProposal] = + useState(false) + const [generatingEditorCover, setGeneratingEditorCover] = useState(false) + const [generatingCreateCover, setGeneratingCreateCover] = useState(false) const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false) const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false) const [editor, setEditor] = useState(null) + const [metadataDialog, setMetadataDialog] = useState(null) const [editorMode, setEditorMode] = useState('workspace') const [editorPanels, setEditorPanels] = useState(defaultWorkbenchPanels) + const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createMode, setCreateMode] = useState('workspace') const [createPanels, setCreatePanels] = useState(defaultWorkbenchPanels) const [createForm, setCreateForm] = useState(defaultCreateForm) @@ -413,6 +657,8 @@ export function PostsPage() { const [searchTerm, setSearchTerm] = useState('') const [typeFilter, setTypeFilter] = useState('all') const [pinnedFilter, setPinnedFilter] = useState('all') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(POSTS_PAGE_SIZE_OPTIONS[0]) const loadPosts = useCallback(async (showToast = false) => { try { @@ -450,6 +696,7 @@ export function PostsPage() { startTransition(() => { setEditor(buildEditorState(post, markdown.markdown, markdown.path)) + setMetadataDialog(null) setEditorMode('workspace') setEditorPanels(defaultWorkbenchPanels) setEditorPolish(null) @@ -481,27 +728,64 @@ export function PostsPage() { if (!slug) { setEditor(null) + setMetadataDialog(null) setEditorPolish(null) return } + setCreateDialogOpen(false) void loadEditor(slug) }, [loadEditor, slug]) + useEffect(() => { + if (!metadataDialog && !slug && !createDialogOpen) { + return + } + + const previousOverflow = document.body.style.overflow + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (metadataDialog) { + setMetadataDialog(null) + return + } + + if (slug) { + navigate('/posts', { replace: true }) + return + } + + if (createDialogOpen) { + setCreateDialogOpen(false) + } + } + } + + document.body.style.overflow = 'hidden' + window.addEventListener('keydown', handleKeyDown) + + return () => { + document.body.style.overflow = previousOverflow + window.removeEventListener('keydown', handleKeyDown) + } + }, [createDialogOpen, metadataDialog, navigate, slug]) + + const normalizedSearchTerm = searchTerm.trim().toLowerCase() const filteredPosts = useMemo(() => { return posts.filter((post) => { const matchesSearch = - !searchTerm || + !normalizedSearchTerm || [ post.title ?? '', post.slug, post.category ?? '', post.description ?? '', post.post_type ?? '', + postTagsToList(post.tags).join(' '), ] .join('\n') .toLowerCase() - .includes(searchTerm.toLowerCase()) + .includes(normalizedSearchTerm) const matchesType = typeFilter === 'all' || (post.post_type ?? 'article') === typeFilter const pinnedValue = Boolean(post.pinned) @@ -512,7 +796,43 @@ export function PostsPage() { return matchesSearch && matchesType && matchesPinned }) - }, [pinnedFilter, posts, searchTerm, typeFilter]) + }, [normalizedSearchTerm, pinnedFilter, posts, typeFilter]) + + useEffect(() => { + setCurrentPage(1) + }, [pageSize, pinnedFilter, searchTerm, typeFilter]) + + const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize)) + const safeCurrentPage = Math.min(currentPage, totalPages) + + useEffect(() => { + setCurrentPage((current) => Math.min(current, totalPages)) + }, [totalPages]) + + const paginatedPosts = useMemo(() => { + const startIndex = (safeCurrentPage - 1) * pageSize + return filteredPosts.slice(startIndex, startIndex + pageSize) + }, [filteredPosts, pageSize, safeCurrentPage]) + + const paginationItems = useMemo(() => { + const maxVisiblePages = 5 + const halfWindow = Math.floor(maxVisiblePages / 2) + let startPage = Math.max(1, safeCurrentPage - halfWindow) + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1) + + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1) + } + + return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index) + }, [safeCurrentPage, totalPages]) + + const pageStart = filteredPosts.length ? (safeCurrentPage - 1) * pageSize + 1 : 0 + const pageEnd = filteredPosts.length ? Math.min(safeCurrentPage * pageSize, filteredPosts.length) : 0 + const pinnedPostCount = useMemo( + () => posts.filter((post) => Boolean(post.pinned)).length, + [posts], + ) const markdownDirty = useMemo(() => { if (!editor) { @@ -541,6 +861,14 @@ export function PostsPage() { return countLineDiff(editor.savedMarkdown, buildDraftMarkdownForWindow(editor)) }, [editor]) + const editorCoverPreviewUrl = useMemo( + () => resolveCoverPreviewUrl(editor?.image ?? ''), + [editor?.image], + ) + const createCoverPreviewUrl = useMemo( + () => resolveCoverPreviewUrl(createForm.image), + [createForm.image], + ) const saveEditor = useCallback(async () => { if (!editor) { @@ -621,31 +949,138 @@ export function PostsPage() { ) const generateCreateMetadata = useCallback(async () => { - if (!createForm.markdown.trim()) { + const sourceMarkdown = buildCreateMarkdownForWindow(createForm) + if (!stripFrontmatter(sourceMarkdown).trim()) { toast.error('先写一点正文,再让 AI 帮你补元数据。') return } try { setGeneratingMetadata(true) - const generated = await adminApi.generatePostMetadata(createForm.markdown) + const generated = await adminApi.generatePostMetadata(sourceMarkdown) + const nextProposal = buildMetadataProposal(createForm, generated, 'create') + const changedCount = countChangedMetadataFields( + nextProposal, + createMetadataProposalFields, + ) + startTransition(() => { - setCreateForm((current) => ({ - ...current, - title: generated.title, - slug: generated.slug, - description: generated.description, - category: generated.category, - tags: generated.tags.join(', '), - })) + setMetadataDialog({ + target: 'create', + title: createForm.title.trim() || createForm.slug.trim() || '新建草稿', + path: createForm.slug.trim() + ? `backend/content/posts/${createForm.slug.trim()}.md` + : 'backend/content/posts/new-post.md', + proposal: nextProposal, + }) }) - toast.success('AI 已根据正文回填标题、摘要、分类、标签和 slug。') + + if (changedCount) { + toast.success(`AI 已生成 ${changedCount} 项元数据建议,可以先对比再回填。`) + } else { + toast.success('AI 已完成分析,这一版元数据和当前内容基本一致。') + } } catch (error) { toast.error(error instanceof ApiError ? error.message : 'AI 元数据生成失败。') } finally { setGeneratingMetadata(false) } - }, [createForm.markdown]) + }, [createForm]) + + const generateEditorMetadataProposal = useCallback(async () => { + if (!editor) { + return + } + + const sourceMarkdown = buildDraftMarkdownForWindow(editor) + if (!stripFrontmatter(sourceMarkdown).trim()) { + toast.error('先准备一点正文,再让 AI 给这篇旧文章补元数据。') + return + } + + try { + setGeneratingEditorMetadataProposal(true) + const generated = await adminApi.generatePostMetadata(sourceMarkdown) + const nextProposal = buildMetadataProposal(editor, generated, 'editor') + const changedCount = countChangedMetadataFields( + nextProposal, + editorMetadataProposalFields, + ) + + startTransition(() => { + setMetadataDialog({ + target: 'editor', + title: editor.title.trim() || editor.slug, + path: editor.path, + proposal: nextProposal, + }) + }) + + if (changedCount) { + toast.success(`AI 已生成 ${changedCount} 项元数据建议,可以逐项合并。`) + } else { + toast.success('AI 已完成分析,这一版元数据和当前内容基本一致。') + } + } catch (error) { + toast.error(error instanceof ApiError ? error.message : 'AI 元数据提案生成失败。') + } finally { + setGeneratingEditorMetadataProposal(false) + } + }, [editor]) + + const generateEditorCover = useCallback(async () => { + if (!editor) { + return + } + + try { + setGeneratingEditorCover(true) + const result = await adminApi.generatePostCoverImage({ + title: editor.title, + description: emptyToNull(editor.description), + category: emptyToNull(editor.category), + tags: parseTagList(editor.tags), + postType: emptyToNull(editor.postType) ?? 'article', + slug: editor.slug, + markdown: buildDraftMarkdownForWindow(editor), + }) + + startTransition(() => { + setEditor((current) => + current ? { ...current, image: result.image_url } : current, + ) + }) + toast.success('AI 封面图已生成,并回填到封面图 URL。') + } catch (error) { + toast.error(error instanceof ApiError ? error.message : 'AI 封面图生成失败。') + } finally { + setGeneratingEditorCover(false) + } + }, [editor]) + + const generateCreateCover = useCallback(async () => { + try { + setGeneratingCreateCover(true) + const result = await adminApi.generatePostCoverImage({ + title: createForm.title, + description: emptyToNull(createForm.description), + category: emptyToNull(createForm.category), + tags: parseTagList(createForm.tags), + postType: emptyToNull(createForm.postType) ?? 'article', + slug: emptyToNull(createForm.slug), + markdown: buildCreateMarkdownForWindow(createForm), + }) + + startTransition(() => { + setCreateForm((current) => ({ ...current, image: result.image_url })) + }) + toast.success('AI 封面图已生成,并回填到封面图 URL。') + } catch (error) { + toast.error(error instanceof ApiError ? error.message : 'AI 封面图生成失败。') + } finally { + setGeneratingCreateCover(false) + } + }, [createForm]) const editorPolishHunks = useMemo( () => @@ -810,7 +1245,7 @@ export function PostsPage() {
- { + setMetadataDialog((current) => { + if (!current || !metadataFieldChanged(current.proposal, field)) { + return current + } + + return { + ...current, + proposal: { + ...current.proposal, + selected: { + ...current.proposal.selected, + [field]: !current.proposal.selected[field], + }, + }, + } + }) + }, []) + + const updateMetadataSuggestedField = useCallback( + (field: MetadataProposalField, value: string) => { + setMetadataDialog((current) => { + if (!current) { + return current + } + + const nextSuggested = { + ...current.proposal.suggested, + title: + field === 'title' ? value : current.proposal.suggested.title, + slug: field === 'slug' ? value : current.proposal.suggested.slug, + description: + field === 'description' ? value : current.proposal.suggested.description, + category: + field === 'category' ? value : current.proposal.suggested.category, + tags: + field === 'tags' + ? normalizeMetadataTags(parseTagList(value)) + : current.proposal.suggested.tags, + } + const nextProposal: MetadataProposalState = { + ...current.proposal, + suggested: nextSuggested, + selected: { + ...current.proposal.selected, + }, + } + + if (!metadataFieldChanged(nextProposal, field)) { + nextProposal.selected[field] = false + } + + return { + ...current, + proposal: nextProposal, + } + }) + }, + [], + ) + + const acceptAllMetadata = useCallback(() => { + setMetadataDialog((current) => + current + ? { + ...current, + proposal: { + ...current.proposal, + selected: buildMetadataSelection( + current.proposal.current, + current.proposal.suggested, + metadataProposalFieldsForTarget(current.target), + ), + }, + } + : current, + ) + }, []) + + const resetMetadataProposal = useCallback(() => { + setMetadataDialog((current) => + current + ? { + ...current, + proposal: { + ...current.proposal, + selected: createEmptyMetadataSelection(), + }, + } + : current, + ) + }, []) + + const applyMetadataProposal = useCallback(() => { + if (!metadataDialog) { + return + } + + const fields = metadataProposalFieldsForTarget(metadataDialog.target) + if (!countSelectedMetadataFields(metadataDialog.proposal.selected, fields)) { + toast.error('先勾选至少一项要合并的元数据。') + return + } + + const { selected, suggested } = metadataDialog.proposal + + startTransition(() => { + if (metadataDialog.target === 'editor') { + setEditor((current) => + current + ? { + ...current, + title: selected.title ? suggested.title : current.title, + description: selected.description + ? suggested.description + : current.description, + category: selected.category ? suggested.category : current.category, + tags: selected.tags ? suggested.tags.join(', ') : current.tags, + } + : current, + ) + } else { + setCreateForm((current) => ({ + ...current, + title: selected.title ? suggested.title : current.title, + slug: selected.slug ? suggested.slug : current.slug, + description: selected.description + ? suggested.description + : current.description, + category: selected.category ? suggested.category : current.category, + tags: selected.tags ? suggested.tags.join(', ') : current.tags, + })) + } + + setMetadataDialog(null) + }) + toast.success( + metadataDialog.target === 'editor' + ? '选中的 AI 元数据建议已合并到当前草稿。' + : '选中的 AI 元数据建议已回填到新建草稿。', + ) + }, [metadataDialog]) + + const metadataDialogFields = metadataDialog + ? metadataProposalFieldsForTarget(metadataDialog.target) + : editorMetadataProposalFields + + const closeEditorDialog = useCallback(() => { + navigate('/posts', { replace: true }) + }, [navigate]) + + const closeCreateDialog = useCallback(() => { + setCreateDialogOpen(false) + }, []) + + const openCreateDialog = useCallback(() => { + navigate('/posts', { replace: true }) + setCreateMode('workspace') + setCreatePanels(defaultWorkbenchPanels) + setCreateDialogOpen(true) + }, [navigate]) + return (

内容库

- 现在可以直接在后台使用 VS Code 风格编辑器维护 Markdown,支持整文件夹导入、AI 元数据回填,并把预览、diff 和 AI 润色全部收进同一个 Monaco 工作台里。 + 现在可以直接在后台使用 VS Code 风格编辑器维护 Markdown,支持整文件夹导入、AI 元数据对比回填、AI 封面图生成,并把预览、diff 和 AI 润色全部收进同一个 Monaco 工作台里。

- @@ -1039,25 +1636,34 @@ export function PostsPage() {
-
- - +
+ +
- 内容导航 - 左侧快速筛选和切换文章,右侧专注编辑当前内容。 + 文章列表 + + 保持列表浏览,搜索、筛选、翻页都在这里完成;新建和编辑统一在页内窗口里处理。 +
{filteredPosts.length} / {posts.length}
-
-
- setSearchTerm(event.target.value)} - /> -
+
+ setSearchTerm(event.target.value)} + /> + {searchTerm ? ( + + ) : null} +
+
+
- -
-
-

- 已筛选 -

-

{filteredPosts.length}

-
-
-

- 置顶 -

-

- {posts.filter((post) => post.pinned).length} -

-
-
-

- 模式 -

-

- {editor ? '编辑现有文章' : '创建新草稿'} -

-
+
+ 已筛选 {filteredPosts.length} + 置顶 {pinnedPostCount} + + 第 {safeCurrentPage} / {totalPages} 页 + + + {editor ? '正在编辑' : createDialogOpen ? '正在新建' : '列表浏览'} +
- + + {loading ? ( ) : ( -
- {filteredPosts.map((post) => { - const active = post.slug === slug +
+
+ {paginatedPosts.map((post) => { + const active = post.slug === slug - return ( -
-

- {post.description ?? '暂无摘要。'} -

-
- {post.category ?? '未分类'} -
- - ) - })} +

+ {post.description ?? '暂无摘要。'} +

+
+ {post.category ?? '未分类'} +
+ + ) + })} - {!filteredPosts.length ? ( -
- 当前筛选条件下没有匹配的文章。 + {!filteredPosts.length ? ( +
+ 当前筛选条件下没有匹配的文章。 +
+ ) : null} +
+ + {filteredPosts.length ? ( +
+
+

+ 当前显示第 {pageStart} - {pageEnd} 条,共 {filteredPosts.length} 条结果。 +

+
+ + {paginationItems.map((page) => ( + + ))} + +
+
) : null}
@@ -1157,13 +1803,42 @@ export function PostsPage() { - {editorLoading ? ( + {editorLoading && slug ? ( ) : editor ? ( -
-
- - +
+
+ +
+
+
+ 页内编辑窗口 + Esc 关闭 + {markdownDirty ? 未保存 : null} +
+
+

编辑文章

+

+ 文章列表保留在背后,随时可以返回;面板切换和 AI 润色统一放在 Monaco 顶栏里。 +

+
+
+
+ + +
+
+
+ +
+ +
{editor.title || editor.slug} {formatPostType(editor.postType)} @@ -1173,43 +1848,25 @@ export function PostsPage() { {editor.slug} -
-
-

- 差异 -

-
- +{compareStats.additions} - -{compareStats.deletions} -
-
-
-

- 正文统计 -

-

- {editor.markdown.length} 字符 · {editor.markdown.split(/\r?\n/).length} 行 -

-
+
+ +{compareStats.additions} + -{compareStats.deletions} + {editor.markdown.length} 字符 + {editor.markdown.split(/\r?\n/).length} 行
-

- Markdown 文件 -

-

- {editor.path} -

-

- 创建于 {formatDateTime(editor.createdAt)} +

{editor.path}

+

+ 创建于 {formatDateTime(editor.createdAt)} · 更新于 {formatDateTime(editor.updatedAt)}

- - + + 文章属性 - 标题、分类、摘要和标签在这里集中维护。 + 把 Medium 那种最常改的字段集中在一个安静的检查器里。 @@ -1278,10 +1935,12 @@ export function PostsPage() { - - + + 媒体与发布 - 封面、多图和置顶状态与正文分开配置。 + + 参考 Ghost 的做法,把封面和发布开关放到正文之外,减少主编辑区噪音。 + @@ -1294,6 +1953,41 @@ export function PostsPage() { } /> +
+ + {editorCoverPreviewUrl ? ( + + ) : null} +
+ {editorCoverPreviewUrl ? ( +
+
+

+ 当前封面预览 +

+
+ {editor.title +
+ ) : null}