|
|
@@ -0,0 +1,299 @@
|
|
|
+import React, { useState, useEffect } from 'react';
|
|
|
+import { publicTemplateClient } from '@/client/api';
|
|
|
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/client/home/components/ui/card';
|
|
|
+import { Button } from '@/client/home/components/ui/button';
|
|
|
+import { Badge } from '@/client/home/components/ui/badge';
|
|
|
+import { Skeleton } from '@/client/home/components/ui/skeleton';
|
|
|
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/home/components/ui/select';
|
|
|
+import { Input } from '@/client/home/components/ui/input';
|
|
|
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/client/home/components/ui/tabs';
|
|
|
+import { Eye, Download, Star, Search } from 'lucide-react';
|
|
|
+import { useAuth } from '@/client/home/hooks/AuthProvider';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { useNavigate } from 'react-router-dom';
|
|
|
+
|
|
|
+interface Template {
|
|
|
+ id: number;
|
|
|
+ title: string;
|
|
|
+ description: string | null;
|
|
|
+ category: string;
|
|
|
+ isFree: number;
|
|
|
+ downloadCount: number;
|
|
|
+ file: {
|
|
|
+ id: number;
|
|
|
+ name: string;
|
|
|
+ fullUrl: string;
|
|
|
+ type: string | null;
|
|
|
+ size: number | null;
|
|
|
+ } | null;
|
|
|
+}
|
|
|
+
|
|
|
+const TemplateSquare: React.FC = () => {
|
|
|
+ const [templates, setTemplates] = useState<Template[]>([]);
|
|
|
+ const [categories, setCategories] = useState<string[]>([]);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [category, setCategory] = useState<string>('');
|
|
|
+ const [isFree, setIsFree] = useState<string>('');
|
|
|
+ const [searchKeyword, setSearchKeyword] = useState('');
|
|
|
+ const [pagination, setPagination] = useState({
|
|
|
+ current: 1,
|
|
|
+ pageSize: 12,
|
|
|
+ total: 0
|
|
|
+ });
|
|
|
+
|
|
|
+ const { user } = useAuth();
|
|
|
+ const navigate = useNavigate();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ fetchTemplates();
|
|
|
+ fetchCategories();
|
|
|
+ }, [category, isFree, pagination.current]);
|
|
|
+
|
|
|
+ const fetchTemplates = async () => {
|
|
|
+ try {
|
|
|
+ setLoading(true);
|
|
|
+ const response = await publicTemplateClient.$get({
|
|
|
+ query: {
|
|
|
+ page: pagination.current,
|
|
|
+ pageSize: pagination.pageSize,
|
|
|
+ category: category || undefined,
|
|
|
+ isFree: isFree ? parseInt(isFree) : undefined
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ setTemplates(data.data);
|
|
|
+ setPagination(prev => ({ ...prev, total: data.pagination.total }));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to fetch templates:', error);
|
|
|
+ toast.error('获取模板列表失败');
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const fetchCategories = async () => {
|
|
|
+ try {
|
|
|
+ const response = await publicTemplateClient.categories.$get();
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ setCategories(data);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to fetch categories:', error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handlePreview = async (template: Template) => {
|
|
|
+ try {
|
|
|
+ const response = await publicTemplateClient[':id'].preview.$get({
|
|
|
+ param: { id: template.id.toString() }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ window.open(data.previewUrl, '_blank');
|
|
|
+ } else {
|
|
|
+ toast.error('获取预览失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to preview template:', error);
|
|
|
+ toast.error('预览失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDownload = async (template: Template) => {
|
|
|
+ if (!user) {
|
|
|
+ toast.error('请先登录');
|
|
|
+ navigate('/login');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!user.membership && !template.isFree) {
|
|
|
+ toast.error('需要购买会员才能下载');
|
|
|
+ navigate('/pricing');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await publicTemplateClient[':id'].download.$post({
|
|
|
+ param: { id: template.id.toString() }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ window.open(data.downloadUrl, '_blank');
|
|
|
+ toast.success('下载已开始');
|
|
|
+ } else if (response.status === 403) {
|
|
|
+ toast.error('需要购买会员才能下载');
|
|
|
+ navigate('/pricing');
|
|
|
+ } else {
|
|
|
+ toast.error('下载失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to download template:', error);
|
|
|
+ toast.error('下载失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handlePageChange = (page: number) => {
|
|
|
+ setPagination(prev => ({ ...prev, current: page }));
|
|
|
+ };
|
|
|
+
|
|
|
+ const TemplateCard = ({ template }: { template: Template }) => (
|
|
|
+ <Card className="hover:shadow-lg transition-shadow">
|
|
|
+ <CardHeader>
|
|
|
+ <div className="flex justify-between items-start">
|
|
|
+ <CardTitle className="text-lg">{template.title}</CardTitle>
|
|
|
+ <Badge variant={template.isFree ? "default" : "secondary"}>
|
|
|
+ {template.isFree ? "免费" : "会员"}
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+ <CardDescription className="line-clamp-2">
|
|
|
+ {template.description || '暂无描述'}
|
|
|
+ </CardDescription>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="flex items-center justify-between text-sm text-gray-600">
|
|
|
+ <span className="flex items-center gap-1">
|
|
|
+ <Star className="w-4 h-4" />
|
|
|
+ <span>下载 {template.downloadCount}</span>
|
|
|
+ </span>
|
|
|
+ <Badge variant="outline">{template.category}</Badge>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ <CardFooter className="flex gap-2">
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handlePreview(template)}
|
|
|
+ className="flex-1"
|
|
|
+ >
|
|
|
+ <Eye className="w-4 h-4 mr-2" />
|
|
|
+ 预览
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handleDownload(template)}
|
|
|
+ className="flex-1"
|
|
|
+ disabled={!template.isFree && !user?.membership}
|
|
|
+ >
|
|
|
+ <Download className="w-4 h-4 mr-2" />
|
|
|
+ 下载
|
|
|
+ </Button>
|
|
|
+ </CardFooter>
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+
|
|
|
+ const LoadingSkeleton = () => (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
|
+ {[...Array(8)].map((_, i) => (
|
|
|
+ <Card key={i}>
|
|
|
+ <CardHeader>
|
|
|
+ <Skeleton className="h-4 w-3/4" />
|
|
|
+ <Skeleton className="h-3 w-full mt-2" />
|
|
|
+ <Skeleton className="h-3 w-2/3" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Skeleton className="h-3 w-1/2" />
|
|
|
+ </CardContent>
|
|
|
+ <CardFooter className="flex gap-2">
|
|
|
+ <Skeleton className="h-8 flex-1" />
|
|
|
+ <Skeleton className="h-8 flex-1" />
|
|
|
+ </CardFooter>
|
|
|
+ </Card>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="container mx-auto py-8 px-4">
|
|
|
+ <div className="mb-8">
|
|
|
+ <h1 className="text-3xl font-bold mb-2">模板广场</h1>
|
|
|
+ <p className="text-gray-600">精选各类文档模板,助您高效办公</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="mb-6">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
|
+ <div className="md:col-span-2">
|
|
|
+ <div className="relative">
|
|
|
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
|
+ <Input
|
|
|
+ placeholder="搜索模板..."
|
|
|
+ value={searchKeyword}
|
|
|
+ onChange={(e) => setSearchKeyword(e.target.value)}
|
|
|
+ className="pl-10"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Select value={category} onValueChange={setCategory}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="全部分类" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="">全部分类</SelectItem>
|
|
|
+ {categories.map(cat => (
|
|
|
+ <SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
|
|
+ ))}
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+
|
|
|
+ <Select value={isFree} onValueChange={setIsFree}>
|
|
|
+ <SelectTrigger>
|
|
|
+ <SelectValue placeholder="全部类型" />
|
|
|
+ </SelectTrigger>
|
|
|
+ <SelectContent>
|
|
|
+ <SelectItem value="">全部类型</SelectItem>
|
|
|
+ <SelectItem value="1">免费</SelectItem>
|
|
|
+ <SelectItem value="0">会员</SelectItem>
|
|
|
+ </SelectContent>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Tabs defaultValue="all" className="mb-6">
|
|
|
+ <TabsList>
|
|
|
+ <TabsTrigger value="all" onClick={() => setIsFree('')}>全部</TabsTrigger>
|
|
|
+ <TabsTrigger value="free" onClick={() => setIsFree('1')}>免费模板</TabsTrigger>
|
|
|
+ <TabsTrigger value="vip" onClick={() => setIsFree('0')}>会员专享</TabsTrigger>
|
|
|
+ </TabsList>
|
|
|
+ </Tabs>
|
|
|
+
|
|
|
+ {loading ? (
|
|
|
+ <LoadingSkeleton />
|
|
|
+ ) : templates.length === 0 ? (
|
|
|
+ <div className="text-center py-12">
|
|
|
+ <p className="text-gray-500">暂无模板</p>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
|
+ {templates.map(template => (
|
|
|
+ <TemplateCard key={template.id} template={template} />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {pagination.total > pagination.pageSize && (
|
|
|
+ <div className="mt-8 flex justify-center">
|
|
|
+ <div className="flex gap-2">
|
|
|
+ {Array.from({ length: Math.ceil(pagination.total / pagination.pageSize) }, (_, i) => (
|
|
|
+ <Button
|
|
|
+ key={i}
|
|
|
+ variant={pagination.current === i + 1 ? "default" : "outline"}
|
|
|
+ size="sm"
|
|
|
+ onClick={() => handlePageChange(i + 1)}
|
|
|
+ >
|
|
|
+ {i + 1}
|
|
|
+ </Button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default TemplateSquare;
|