TemplateSquare.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import React, { useState, useEffect } from 'react';
  2. import { publicTemplateClient } from '@/client/api';
  3. import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/client/components/ui/card';
  4. import { Button } from '@/client/components/ui/button';
  5. import { Badge } from '@/client/components/ui/badge';
  6. import { Skeleton } from '@/client/components/ui/skeleton';
  7. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select';
  8. import { Input } from '@/client/components/ui/input';
  9. import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/client/components/ui/tabs';
  10. import { Eye, Download, Star, Search } from 'lucide-react';
  11. import { useAuth } from '@/client/home/hooks/AuthProvider';
  12. import { toast } from 'sonner';
  13. import { useNavigate } from 'react-router-dom';
  14. interface Template {
  15. id: number;
  16. title: string;
  17. description: string | null;
  18. category: string;
  19. isFree: number;
  20. downloadCount: number;
  21. previewUrl?: string;
  22. file: {
  23. id: number;
  24. name: string;
  25. fullUrl: string;
  26. type: string | null;
  27. size: number | null;
  28. } | null;
  29. }
  30. const TemplateSquare: React.FC = () => {
  31. const [templates, setTemplates] = useState<Template[]>([]);
  32. const [categories, setCategories] = useState<string[]>([]);
  33. const [loading, setLoading] = useState(true);
  34. const [category, setCategory] = useState<string>('all');
  35. const [isFree, setIsFree] = useState<string>('all');
  36. const [searchKeyword, setSearchKeyword] = useState('');
  37. const [pagination, setPagination] = useState({
  38. current: 1,
  39. pageSize: 12,
  40. total: 0
  41. });
  42. const { user } = useAuth();
  43. const navigate = useNavigate();
  44. useEffect(() => {
  45. fetchTemplates();
  46. fetchCategories();
  47. }, [category, isFree, pagination.current]);
  48. const fetchTemplates = async () => {
  49. try {
  50. setLoading(true);
  51. const response = await publicTemplateClient.$get({
  52. query: {
  53. page: pagination.current,
  54. pageSize: pagination.pageSize,
  55. category: category !== 'all' ? category : undefined,
  56. isFree: isFree !== 'all' ? parseInt(isFree) : undefined
  57. }
  58. });
  59. if (response.ok) {
  60. const data = await response.json();
  61. setTemplates(data.data);
  62. setPagination(prev => ({ ...prev, total: data.pagination.total }));
  63. }
  64. } catch (error) {
  65. console.error('Failed to fetch templates:', error);
  66. toast.error('获取模板列表失败');
  67. } finally {
  68. setLoading(false);
  69. }
  70. };
  71. const fetchCategories = async () => {
  72. try {
  73. const response = await publicTemplateClient.categories.$get();
  74. if (response.ok) {
  75. const data = await response.json();
  76. setCategories(data);
  77. }
  78. } catch (error) {
  79. console.error('Failed to fetch categories:', error);
  80. }
  81. };
  82. const handlePreview = async (template: Template) => {
  83. try {
  84. const response = await publicTemplateClient[':id'].preview.$get({
  85. param: { id: template.id.toString() }
  86. });
  87. if (response.ok) {
  88. const data = await response.json();
  89. window.open(data.previewUrl, '_blank');
  90. } else {
  91. toast.error('获取预览失败');
  92. }
  93. } catch (error) {
  94. console.error('Failed to preview template:', error);
  95. toast.error('预览失败');
  96. }
  97. };
  98. const handleDownload = async (template: Template) => {
  99. if (!user) {
  100. toast.error('请先登录');
  101. navigate('/login');
  102. return;
  103. }
  104. if (!user.membership && !template.isFree) {
  105. toast.error('需要购买会员才能下载');
  106. navigate('/pricing');
  107. return;
  108. }
  109. try {
  110. const response = await publicTemplateClient[':id'].download.$post({
  111. param: { id: template.id.toString() }
  112. });
  113. if (response.ok) {
  114. const data = await response.json();
  115. window.open(data.downloadUrl, '_blank');
  116. toast.success('下载已开始');
  117. } else if (response.status === 403) {
  118. toast.error('需要购买会员才能下载');
  119. navigate('/pricing');
  120. } else {
  121. toast.error('下载失败');
  122. }
  123. } catch (error) {
  124. console.error('Failed to download template:', error);
  125. toast.error('下载失败');
  126. }
  127. };
  128. const handlePageChange = (page: number) => {
  129. setPagination(prev => ({ ...prev, current: page }));
  130. };
  131. const TemplateCard = ({ template }: { template: Template }) => (
  132. <Card className="hover:shadow-lg transition-shadow">
  133. <CardHeader>
  134. <div className="flex justify-between items-start">
  135. <CardTitle className="text-lg">{template.title}</CardTitle>
  136. <Badge variant={template.isFree ? "default" : "secondary"}>
  137. {template.isFree ? "免费" : "会员"}
  138. </Badge>
  139. </div>
  140. <CardDescription className="line-clamp-2">
  141. {template.description || '暂无描述'}
  142. </CardDescription>
  143. </CardHeader>
  144. <CardContent>
  145. <div className="flex items-center justify-between text-sm text-gray-600">
  146. <span className="flex items-center gap-1">
  147. <Star className="w-4 h-4" />
  148. <span>下载 {template.downloadCount}</span>
  149. </span>
  150. <Badge variant="outline">{template.category}</Badge>
  151. </div>
  152. </CardContent>
  153. <CardFooter className="flex gap-2">
  154. <Button
  155. variant="outline"
  156. size="sm"
  157. onClick={() => handlePreview(template)}
  158. className="flex-1"
  159. >
  160. <Eye className="w-4 h-4 mr-2" />
  161. 预览
  162. </Button>
  163. <Button
  164. size="sm"
  165. onClick={() => handleDownload(template)}
  166. className="flex-1"
  167. disabled={!template.isFree && !user?.membership}
  168. >
  169. <Download className="w-4 h-4 mr-2" />
  170. 下载
  171. </Button>
  172. </CardFooter>
  173. </Card>
  174. );
  175. const LoadingSkeleton = () => (
  176. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  177. {[...Array(8)].map((_, i) => (
  178. <Card key={i}>
  179. <CardHeader>
  180. <Skeleton className="h-4 w-3/4" />
  181. <Skeleton className="h-3 w-full mt-2" />
  182. <Skeleton className="h-3 w-2/3" />
  183. </CardHeader>
  184. <CardContent>
  185. <Skeleton className="h-3 w-1/2" />
  186. </CardContent>
  187. <CardFooter className="flex gap-2">
  188. <Skeleton className="h-8 flex-1" />
  189. <Skeleton className="h-8 flex-1" />
  190. </CardFooter>
  191. </Card>
  192. ))}
  193. </div>
  194. );
  195. return (
  196. <div className="container mx-auto py-8 px-4">
  197. <div className="mb-8">
  198. <h1 className="text-3xl font-bold mb-2">模板广场</h1>
  199. <p className="text-gray-600">精选各类文档模板,助您高效办公</p>
  200. </div>
  201. <div className="mb-6">
  202. <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
  203. <div className="md:col-span-2">
  204. <div className="relative">
  205. <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
  206. <Input
  207. placeholder="搜索模板..."
  208. value={searchKeyword}
  209. onChange={(e) => setSearchKeyword(e.target.value)}
  210. className="pl-10"
  211. />
  212. </div>
  213. </div>
  214. <Select value={category} onValueChange={setCategory}>
  215. <SelectTrigger>
  216. <SelectValue placeholder="全部分类" />
  217. </SelectTrigger>
  218. <SelectContent>
  219. <SelectItem value="all">全部分类</SelectItem>
  220. {categories.map(cat => (
  221. <SelectItem key={cat} value={cat}>{cat}</SelectItem>
  222. ))}
  223. </SelectContent>
  224. </Select>
  225. <Select value={isFree} onValueChange={setIsFree}>
  226. <SelectTrigger>
  227. <SelectValue placeholder="全部类型" />
  228. </SelectTrigger>
  229. <SelectContent>
  230. <SelectItem value="all">全部类型</SelectItem>
  231. <SelectItem value="1">免费</SelectItem>
  232. <SelectItem value="0">会员</SelectItem>
  233. </SelectContent>
  234. </Select>
  235. </div>
  236. </div>
  237. <Tabs defaultValue="all" className="mb-6">
  238. <TabsList>
  239. <TabsTrigger value="all" onClick={() => setIsFree('all')}>全部</TabsTrigger>
  240. <TabsTrigger value="free" onClick={() => setIsFree('1')}>免费模板</TabsTrigger>
  241. <TabsTrigger value="vip" onClick={() => setIsFree('0')}>会员专享</TabsTrigger>
  242. </TabsList>
  243. </Tabs>
  244. {loading ? (
  245. <LoadingSkeleton />
  246. ) : templates.length === 0 ? (
  247. <div className="text-center py-12">
  248. <p className="text-gray-500">暂无模板</p>
  249. </div>
  250. ) : (
  251. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  252. {templates.map(template => (
  253. <TemplateCard key={template.id} template={template} />
  254. ))}
  255. </div>
  256. )}
  257. {pagination.total > pagination.pageSize && (
  258. <div className="mt-8 flex justify-center">
  259. <div className="flex gap-2">
  260. {Array.from({ length: Math.ceil(pagination.total / pagination.pageSize) }, (_, i) => (
  261. <Button
  262. key={i}
  263. variant={pagination.current === i + 1 ? "default" : "outline"}
  264. size="sm"
  265. onClick={() => handlePageChange(i + 1)}
  266. >
  267. {i + 1}
  268. </Button>
  269. ))}
  270. </div>
  271. </div>
  272. )}
  273. </div>
  274. );
  275. };
  276. export default TemplateSquare;