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

720 lines
18 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>;
}