casualroom/apps/fenghuo/web/components/articles/context.tsx

720 lines
18 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import React, { createContext, useContext, useCallback, useState, useMemo, useEffect } from 'react';
import { useQuery, keepPreviousData, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTRPC, usePost } from '@fenghuo/client';
import { Prisma } from '@fenghuo/db';
import type { Article } from '@fenghuo/common';
import { PostStatus, PostType } from '@fenghuo/common/post';
import type { FilterState, PaginationState, ArticleStats, ArticleAction, BatchActionType } from '@/lib/articles/types';
import { DEFAULT_PAGE_SIZE } from '@/lib/articles/constants';
import { type Editor } from '@tiptap/react';
import { toast } from '@nice/ui/components/sonner';
import { useAuth } from '@/components/providers/auth-provider';
// 后端返回的stats类型
interface BackendStats {
all: number;
published: number;
draft: number;
pending: number;
private: number;
trash: number;
}
// Context 类型定义
interface ArticlesContextValue {
// 数据
articles: Article[];
stats: ArticleStats;
isLoading: boolean;
// 状态
filters: FilterState;
pagination: PaginationState;
selectedArticles: string[];
quickEditId: string | null;
batchAction: string;
// 筛选和分页操作
updateFilters: (updates: Partial<FilterState>) => void;
setCurrentPage: (page: number) => void;
// 选择操作
handleSelectAll: (checked: boolean) => void;
handleSelectArticle: (articleId: string, checked: boolean) => void;
resetSelection: () => void;
setQuickEditId: (id: string | null) => void;
setBatchAction: (action: string) => void;
// 文章操作
handleQuickAction: (articleId: string, action: string) => void;
handleBatchAction: () => void;
handleArticleAction: (articleId: string, action: ArticleAction) => void;
handleQuickEditSave: (articleId: string, updates: Partial<Article>) => void;
updateVisibility: (articleId: string, visibility: 'public' | 'private') => void;
refetchArticles: () => void;
}
const ArticlesContext = createContext<ArticlesContextValue | null>(null);
// Hook 来使用 Context
export function useArticlesContext() {
const context = useContext(ArticlesContext);
if (!context) {
throw new Error('useArticlesContext must be used within ArticlesProvider');
}
return context;
}
// 将后端stats转换为前端期望的格式
function transformStats(backendStats: BackendStats): ArticleStats {
return {
all: backendStats.all,
published: backendStats.published,
draft: backendStats.draft,
archived: backendStats.private, // 将private映射为archived
deleted: backendStats.trash ?? 0, // 将trash映射为deleted
totalViews: 0, // TODO: 后续从其他接口获取
totalLikes: 0, // TODO: 后续从其他接口获取
};
}
// Provider 组件
export function ArticlesProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const trpc = useTRPC();
const { update: updateArticle, softDeleteByIds } = usePost();
// 状态管理
const [filters, setFilters] = useState<FilterState>({
searchTerm: '',
statusFilter: 'all',
categoryFilter: 'all',
sortBy: 'created-desc',
});
const [pagination, setPagination] = useState<PaginationState>({
currentPage: 1,
pageSize: DEFAULT_PAGE_SIZE,
totalPages: 1,
totalCount: 0,
});
const [selectedArticles, setSelectedArticles] = useState<string[]>([]);
const [quickEditId, setQuickEditId] = useState<string | null>(null);
const [batchAction, setBatchAction] = useState<string>('');
// 构建查询条件
const { where, orderBy } = useMemo(() => {
const where: Prisma.PostWhereInput = {};
const orderBy: Prisma.PostOrderByWithRelationInput = {};
const andConditions: Prisma.PostWhereInput[] = [];
if (filters.statusFilter === 'trash') {
andConditions.push({ deletedAt: { not: null } } as any);
} else {
andConditions.push({ deletedAt: null } as any);
if (filters.statusFilter !== 'all') {
andConditions.push({ status: filters.statusFilter as any });
}
}
if (filters.searchTerm) {
andConditions.push({
OR: [
{ title: { contains: filters.searchTerm, mode: 'insensitive' } },
{ content: { contains: filters.searchTerm, mode: 'insensitive' } },
],
});
}
if (filters.categoryFilter !== 'all') {
andConditions.push({ terms: { some: { id: filters.categoryFilter } } });
}
if (andConditions.length > 0) {
where.AND = andConditions;
}
// 排序
switch (filters.sortBy) {
case 'created-asc':
orderBy.createdAt = 'asc';
break;
case 'created-desc':
orderBy.createdAt = 'desc';
break;
case 'published-asc':
orderBy.publishedAt = 'asc';
break;
case 'published-desc':
orderBy.publishedAt = 'desc';
break;
case 'title-asc':
orderBy.title = 'asc';
break;
case 'title-desc':
orderBy.title = 'desc';
break;
}
return { where, orderBy };
}, [filters]);
// 数据查询
const {
data: articlesData,
isLoading: isLoadingArticles,
refetch: refetchArticles,
} = useQuery(
trpc.post.findManyWithPagination.queryOptions(
{
page: pagination.currentPage,
pageSize: pagination.pageSize,
where,
orderBy,
},
{
placeholderData: keepPreviousData,
},
),
);
const { data: backendStatsData, isLoading: isLoadingStats } = useQuery(trpc.post.getStats.queryOptions({ where }));
// 转换后端stats数据
const stats = useMemo(() => {
if (!backendStatsData) {
return {
all: 0,
published: 0,
draft: 0,
archived: 0,
deleted: 0,
totalViews: 0,
totalLikes: 0,
};
}
return transformStats(backendStatsData as any);
}, [backendStatsData]);
// 更新分页信息
useEffect(() => {
if (articlesData) {
setPagination((prev) => ({
...prev,
totalPages: articlesData.totalPages,
totalCount: articlesData.totalCount,
}));
}
}, [articlesData]);
// 筛选和分页操作
const updateFilters = useCallback((updates: Partial<FilterState>) => {
setFilters((prev) => ({ ...prev, ...updates }));
setPagination((prev) => ({ ...prev, currentPage: 1 }));
}, []);
const setCurrentPage = useCallback((page: number) => {
setPagination((prev) => ({ ...prev, currentPage: page }));
}, []);
// 选择操作
const handleSelectAll = useCallback(
(checked: boolean) => {
if (checked) {
setSelectedArticles(articlesData?.items.map((a) => a.id) ?? []);
} else {
setSelectedArticles([]);
}
},
[articlesData?.items],
);
const handleSelectArticle = useCallback((articleId: string, checked: boolean) => {
setSelectedArticles((prev) => (checked ? [...prev, articleId] : prev.filter((id) => id !== articleId)));
}, []);
const resetSelection = useCallback(() => {
setSelectedArticles([]);
setBatchAction('');
}, []);
const updateVisibility = useCallback(
(articleId: string, visibility: 'public' | 'private') => {
updateArticle.mutate({
where: { id: articleId },
data: { visibility },
});
},
[updateArticle],
);
// 文章操作
const handleQuickAction = useCallback(
(articleId: string, action: string) => {
switch (action) {
case 'publish':
updateArticle.mutate({
where: { id: articleId },
data: { status: 'PUBLISHED', publishedAt: new Date() },
});
break;
case 'draft':
updateArticle.mutate({
where: { id: articleId },
data: { status: 'DRAFT' },
});
break;
case 'archive':
updateArticle.mutate({
where: { id: articleId },
data: { status: 'ARCHIVED' },
});
break;
case 'delete':
softDeleteByIds.mutate({ ids: [articleId] });
break;
default:
console.warn('未知快速操作', action);
}
},
[updateArticle, softDeleteByIds],
);
// 批量操作
const handleBatchAction = useCallback(() => {
if (selectedArticles.length === 0 || !batchAction) return;
const actionHandlers = {
publish: { status: 'PUBLISHED', publishedAt: new Date() },
draft: { status: 'DRAFT' },
archive: { status: 'ARCHIVED' },
};
if (batchAction in actionHandlers) {
const data = actionHandlers[batchAction as keyof typeof actionHandlers];
updateArticle.mutate({
where: { id: { in: selectedArticles } } as any,
data,
});
} else if (batchAction === 'delete') {
softDeleteByIds.mutate({ ids: selectedArticles });
}
resetSelection();
}, [batchAction, selectedArticles, updateArticle, softDeleteByIds, resetSelection]);
const handleArticleAction = useCallback(
(articleId: string, action: ArticleAction) => {
switch (action) {
case 'edit':
router.push(`/editor?id=${articleId}`);
break;
case 'publish':
updateArticle.mutate({
where: { id: articleId },
data: { status: 'PUBLISHED', publishedAt: new Date() },
});
break;
case 'unpublish':
updateArticle.mutate({ where: { id: articleId }, data: { status: 'DRAFT' } });
break;
case 'archive':
updateArticle.mutate({ where: { id: articleId }, data: { status: 'ARCHIVED' } });
break;
case 'delete':
softDeleteByIds.mutate({ ids: [articleId] });
break;
case 'duplicate':
// TODO: 实现复制功能
toast.info('此功能正在开发中');
break;
case 'preview':
// TODO: 实现预览功能
toast.info('此功能正在开发中');
break;
case 'share':
// TODO: 实现分享功能
toast.info('此功能正在开发中');
console.log(`分享文章: ${articleId}`);
break;
}
},
[router, updateArticle, softDeleteByIds],
);
const handleQuickEditSave = useCallback(
(articleId: string, updates: Partial<Article>) => {
updateArticle.mutate({
where: { id: articleId },
data: updates as any,
});
setQuickEditId(null);
},
[updateArticle],
);
const contextValue: ArticlesContextValue = {
// 数据
articles: articlesData?.items ?? [],
stats,
isLoading: isLoadingArticles || isLoadingStats,
// 状态
filters,
pagination,
selectedArticles,
quickEditId,
batchAction,
// 筛选和分页操作
updateFilters,
setCurrentPage,
// 选择操作
handleSelectAll,
handleSelectArticle,
resetSelection,
setQuickEditId,
setBatchAction,
// 文章操作
handleQuickAction,
handleBatchAction,
handleArticleAction,
handleQuickEditSave,
updateVisibility,
refetchArticles,
};
return <ArticlesContext.Provider value={contextValue}>{children}</ArticlesContext.Provider>;
}
// 文章编辑器状态类型
interface EditorState {
// 文章基本信息
id?: string;
title: string;
excerpt: string;
content: string;
status: PostStatus;
publishedAt?: Date;
organizationId: string;
order: number;
// 分类信息
terms: { id: string; taxonomyId: string }[];
// 编辑器状态
isLoading: boolean;
isSaving: boolean;
lastSaved?: Date;
hasUnsavedChanges: boolean;
}
// 上下文值类型
interface EditorContextValue {
// 状态
state: EditorState;
// TipTap 编辑器实例
editor: Editor | null;
// 动作
setEditor: (editor: Editor | null) => void;
updateContent: (content: string) => void;
updateField: <K extends keyof EditorState>(field: K, value: EditorState[K]) => void;
// 保存操作
save: (options?: { newStatus?: PostStatus; successMessage?: string }) => Promise<void>;
// 重置状态
reset: () => void;
}
const defaultState: EditorState = {
title: '',
excerpt: '',
content: '',
status: PostStatus.DRAFT,
organizationId: '',
order: 0,
terms: [],
isLoading: false,
isSaving: false,
hasUnsavedChanges: false,
};
const EditorContext = createContext<EditorContextValue | null>(null);
export function useEditorContext() {
const context = useContext(EditorContext);
if (!context) {
throw new Error('useEditorContext must be used within EditorProvider');
}
return context;
}
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const router = useRouter();
const trpc = useTRPC();
const { create, update } = usePost();
const searchParams = useSearchParams();
const articleId = searchParams.get('id');
// 获取当前用户信息
const { user: currentUser } = useAuth();
const [state, setState] = useState<EditorState>(defaultState);
const [editor, setEditor] = useState<Editor | null>(null);
// 获取文章数据
const { data: postData, isLoading } = useQuery({
...trpc.post.findFirst.queryOptions({
where: { id: articleId || '' },
select: {
id: true,
title: true,
content: true,
excerpt: true,
status: true,
publishedAt: true,
organizationId: true,
order: true,
terms: { select: { id: true, taxonomyId: true } },
},
}),
enabled: !!articleId,
});
// 获取分类数据用于验证
const { data: taxonomiesData } = useQuery({
...trpc.taxonomy.findMany.queryOptions({
where: {
postTypes: {
has: PostType.ARTICLE,
},
},
}),
});
// 初始化文章数据
useEffect(() => {
if (postData) {
setState((prev) => ({
...prev,
id: postData.id,
title: postData.title || '',
content: postData.content || '',
excerpt: postData.excerpt || '',
status: postData.status as PostStatus,
publishedAt: postData.publishedAt ? new Date(postData.publishedAt) : undefined,
organizationId: postData.organizationId || '',
order: postData.order,
terms: (postData as any).terms?.map((t: any) => ({ id: t.id, taxonomyId: t.taxonomyId })) || [],
isLoading: false,
}));
// 设置编辑器内容
if (editor && postData.content) {
editor.commands.setContent(postData.content);
}
} else if (!articleId) {
// 新文章重置为默认状态并设置默认organizationId
setState((prev) => ({
...prev,
isLoading: false,
organizationId: currentUser?.organizationId || '',
}));
}
}, [postData, editor, articleId, currentUser]);
// 设置加载状态
useEffect(() => {
setState((prev) => ({ ...prev, isLoading }));
}, [isLoading]);
// 监听编辑器内容变化
useEffect(() => {
if (!editor) return;
const handleUpdate = () => {
const content = editor.getHTML();
setState((prev) => ({
...prev,
content,
hasUnsavedChanges: true,
}));
};
editor.on('update', handleUpdate);
return () => {
editor.off('update', handleUpdate);
};
}, [editor]);
// 自动保存
useEffect(() => {
if (!state.hasUnsavedChanges) return;
const timer = setTimeout(() => {
// 自动保存时不显示成功消息,静默保存
save({ successMessage: undefined });
}, 30000); // 30秒自动保存
return () => clearTimeout(timer);
}, [state.hasUnsavedChanges]);
const updateContent = useCallback((content: string) => {
setState((prev) => ({
...prev,
content,
hasUnsavedChanges: true,
}));
}, []);
const updateField = useCallback(<K extends keyof EditorState>(field: K, value: EditorState[K]) => {
setState((prev) => ({
...prev,
[field]: value,
hasUnsavedChanges: true,
}));
}, []);
const save = useCallback(
async (options?: { newStatus?: PostStatus; successMessage?: string }) => {
setState((prev) => ({ ...prev, isSaving: true }));
try {
const finalStatus = options?.newStatus || state.status;
// 验证必填字段 - 只有发布和归档状态才需要验证
if (finalStatus === PostStatus.PUBLISHED || finalStatus === PostStatus.ARCHIVED) {
if (!state.title.trim()) {
toast.error('保存失败', { description: '文章标题不能为空。' });
return;
}
// 验证分类选择
if (taxonomiesData) {
for (const taxonomy of taxonomiesData) {
if (!state.terms.some((t) => t.taxonomyId === taxonomy.id)) {
toast.error('保存失败', { description: `请选择一个${taxonomy.name}` });
return;
}
}
}
}
const newState = { ...state };
if (options?.newStatus) {
newState.status = options.newStatus;
if (options.newStatus === PostStatus.PUBLISHED && !newState.publishedAt) {
newState.publishedAt = new Date();
}
}
// 获取当前编辑器内容
const currentContent = editor?.getHTML() || state.content;
const commonData = {
title: newState.title,
content: currentContent, // 包含编辑器内容
excerpt: newState.excerpt,
status: newState.status,
order: newState.order,
publishedAt: newState.publishedAt,
type: PostType.ARTICLE,
};
if (state.id) {
// 更新现有文章
await update.mutateAsync({
where: { id: state.id },
data: {
...commonData,
organizationId: newState.organizationId || undefined,
terms: newState.terms.length ? { set: newState.terms.map(({ id }) => ({ id })) } : { set: [] },
},
});
// 只有在显式保存时才显示成功消息
if (options?.successMessage !== undefined) {
toast.success(options.successMessage || '文章已保存');
}
} else {
// 创建新文章 - organizationId 现在是可选的
const effectiveOrganizationId = newState.organizationId || currentUser?.organizationId || undefined;
const result = await create.mutateAsync({
data: {
...commonData,
organizationId: effectiveOrganizationId,
authorId: currentUser!.id, // 使用当前用户ID
terms: newState.terms.length ? { connect: newState.terms.map(({ id }) => ({ id })) } : undefined,
},
});
// 更新URL和状态
router.replace(`/editor?id=${result.id}`);
newState.id = result.id;
if (effectiveOrganizationId) {
newState.organizationId = effectiveOrganizationId;
}
// 只有在显式保存时才显示成功消息
if (options?.successMessage !== undefined) {
toast.success(options.successMessage || '文章已创建');
}
}
setState((prev) => ({
...prev,
...newState,
content: currentContent,
hasUnsavedChanges: false,
lastSaved: new Date(),
}));
} catch (error: any) {
// 只有在显式保存时才显示错误消息
if (options?.successMessage !== undefined) {
toast.error(`操作失败: ${error.message}`);
} else {
console.error('自动保存失败:', error);
}
} finally {
setState((prev) => ({ ...prev, isSaving: false }));
}
},
[state, editor, taxonomiesData, update, create, router, currentUser],
);
const reset = useCallback(() => {
setState(defaultState);
if (editor) {
editor.commands.clearContent();
}
}, [editor]);
const contextValue: EditorContextValue = {
state,
editor,
setEditor,
updateContent,
updateField,
save,
reset,
};
return <EditorContext.Provider value={contextValue}>{children}</EditorContext.Provider>;
}