Browse Source

✨ feat(template): 添加模板管理系统

- 新增模板实体、服务和CRUD API
- 创建后台模板管理页面,支持增删改查
- 实现前台模板广场,支持分类筛选和搜索
- 添加文件预览和下载功能,支持权限控制
- 集成Office Online预览服务

📝 docs(api): 补充模板相关API文档

- 添加模板列表、分类、热门模板等接口
- 完善预览和下载接口的OpenAPI定义

⚡️ perf(service): 优化模板查询性能

- 使用索引字段查询可用模板
- 实现热门模板缓存策略
yourname 3 tháng trước cách đây
mục cha
commit
6a4a3673cd

+ 164 - 0
src/client/admin/components/DataTable.tsx

@@ -0,0 +1,164 @@
+import React from 'react';
+import {
+  ColumnDef,
+  flexRender,
+  getCoreRowModel,
+  getPaginationRowModel,
+  getFilteredRowModel,
+  useReactTable,
+} from '@tanstack/react-table';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/client/components/ui/table';
+import { Input } from '@/client/components/ui/input';
+import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
+import { Skeleton } from '@/client/components/ui/skeleton';
+
+interface DataTableProps<TData, TValue> {
+  columns: ColumnDef<TData, TValue>[];
+  data: TData[];
+  loading?: boolean;
+  searchPlaceholder?: string;
+  onSearch?: (value: string) => void;
+}
+
+export function DataTable<TData, TValue>({
+  columns,
+  data,
+  loading = false,
+  searchPlaceholder = "搜索...",
+  onSearch,
+}: DataTableProps<TData, TValue>) {
+  const [globalFilter, setGlobalFilter] = React.useState('');
+
+  const table = useReactTable({
+    data,
+    columns,
+    getCoreRowModel: getCoreRowModel(),
+    getPaginationRowModel: getPaginationRowModel(),
+    getFilteredRowModel: getFilteredRowModel(),
+    state: {
+      globalFilter,
+    },
+    onGlobalFilterChange: setGlobalFilter,
+  });
+
+  const handleSearchChange = (value: string) => {
+    setGlobalFilter(value);
+    if (onSearch) {
+      onSearch(value);
+    }
+  };
+
+  if (loading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex items-center justify-between">
+          <Skeleton className="h-10 w-[250px]" />
+        </div>
+        <div className="rounded-md border">
+          <Table>
+            <TableHeader>
+              {table.getHeaderGroups().map((headerGroup) => (
+                <TableRow key={headerGroup.id}>
+                  {headerGroup.headers.map((header) => (
+                    <TableHead key={header.id}>
+                      <Skeleton className="h-4 w-20" />
+                    </TableHead>
+                  ))}
+                </TableRow>
+              ))}
+            </TableHeader>
+            <TableBody>
+              {[...Array(5)].map((_, i) => (
+                <TableRow key={i}>
+                  {columns.map((_, j) => (
+                    <TableCell key={j}>
+                      <Skeleton className="h-4 w-full" />
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))}
+            </TableBody>
+          </Table>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Search */}
+      <div className="flex items-center justify-between">
+        <Input
+          placeholder={searchPlaceholder}
+          value={globalFilter}
+          onChange={(event) => handleSearchChange(event.target.value)}
+          className="max-w-sm"
+        />
+      </div>
+
+      {/* Table */}
+      <div className="rounded-md border">
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id}>
+                {headerGroup.headers.map((header) => {
+                  return (
+                    <TableHead key={header.id}>
+                      {header.isPlaceholder
+                        ? null
+                        : flexRender(
+                            header.column.columnDef.header,
+                            header.getContext()
+                          )}
+                    </TableHead>
+                  );
+                })}
+              </TableRow>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {table.getRowModel().rows?.length ? (
+              table.getRowModel().rows.map((row) => (
+                <TableRow
+                  key={row.id}
+                  data-state={row.getIsSelected() && "selected"}
+                >
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell key={cell.id}>
+                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell colSpan={columns.length} className="h-24 text-center">
+                  没有找到结果
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </div>
+
+      {/* Pagination */}
+      <DataTablePagination
+        currentPage={table.getState().pagination.pageIndex + 1}
+        pageSize={table.getState().pagination.pageSize}
+        totalCount={table.getFilteredRowModel().rows.length}
+        onPageChange={(page, pageSize) => {
+          table.setPageIndex(page - 1);
+          table.setPageSize(pageSize);
+        }}
+      />
+    </div>
+  );
+}

+ 9 - 1
src/client/admin/menu.tsx

@@ -11,7 +11,8 @@ import {
   File,
   FileText,
   CreditCard,
-  Wallet
+  Wallet,
+  FileTemplate
 } from 'lucide-react';
 
 export interface MenuItem {
@@ -125,6 +126,13 @@ export const useMenu = () => {
       path: '/admin/settings',
       permission: 'settings:manage'
     },
+    {
+      key: 'templates',
+      label: '模板管理',
+      icon: <FileTemplate className="h-4 w-4" />,
+      path: '/admin/templates',
+      permission: 'template:manage'
+    },
   ];
 
   // 用户菜单项

+ 321 - 0
src/client/admin/pages/Templates.tsx

@@ -0,0 +1,321 @@
+import React, { useState, useEffect } from 'react';
+import { templateClient } from '@/client/api';
+import { DataTable } from '@/client/admin/components/DataTable';
+import { Button } from '@/client/admin/components/ui/button';
+import { Input } from '@/client/admin/components/ui/input';
+import { Label } from '@/client/admin/components/ui/label';
+import { Textarea } from '@/client/admin/components/ui/textarea';
+import { Badge } from '@/client/admin/components/ui/badge';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from '@/client/admin/components/ui/dialog';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/client/admin/components/ui/select';
+import { Switch } from '@/client/admin/components/ui/switch';
+import { toast } from 'react-toastify';
+import { Plus, Edit, Trash2, Eye, Download } from 'lucide-react';
+import { MinioUploader } from '@/client/admin/components/MinioUploader';
+
+interface Template {
+  id: number;
+  title: string;
+  description: string | null;
+  category: string;
+  isFree: number;
+  isDisabled: number;
+  downloadCount: number;
+  file: {
+    id: number;
+    name: string;
+    fullUrl: string;
+  } | null;
+}
+
+const Templates: React.FC = () => {
+  const [templates, setTemplates] = useState<Template[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
+  const [formData, setFormData] = useState({
+    title: '',
+    description: '',
+    category: '',
+    isFree: false,
+    isDisabled: false,
+    fileId: null as number | null,
+  });
+
+  useEffect(() => {
+    fetchTemplates();
+  }, []);
+
+  const fetchTemplates = async () => {
+    try {
+      setLoading(true);
+      const response = await templateClient.$get();
+      if (response.ok) {
+        const data = await response.json();
+        setTemplates(data.data);
+      }
+    } catch (error) {
+      console.error('Failed to fetch templates:', error);
+      toast.error('获取模板列表失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleSubmit = async () => {
+    try {
+      const data = {
+        ...formData,
+        isFree: formData.isFree ? 1 : 0,
+        isDisabled: formData.isDisabled ? 1 : 0,
+      };
+
+      let response;
+      if (editingTemplate) {
+        response = await templateClient[':id'].$put({
+          param: { id: editingTemplate.id.toString() },
+          json: data
+        });
+      } else {
+        response = await templateClient.$post({
+          json: data
+        });
+      }
+
+      if (response.ok) {
+        toast.success(editingTemplate ? '更新成功' : '创建成功');
+        setIsModalOpen(false);
+        resetForm();
+        fetchTemplates();
+      }
+    } catch (error) {
+      console.error('Failed to save template:', error);
+      toast.error('保存失败');
+    }
+  };
+
+  const handleDelete = async (id: number) => {
+    if (!window.confirm('确定要删除该模板吗?')) return;
+
+    try {
+      const response = await templateClient[':id'].$delete({
+        param: { id: id.toString() }
+      });
+      
+      if (response.ok) {
+        toast.success('删除成功');
+        fetchTemplates();
+      }
+    } catch (error) {
+      console.error('Failed to delete template:', error);
+      toast.error('删除失败');
+    }
+  };
+
+  const resetForm = () => {
+    setFormData({
+      title: '',
+      description: '',
+      category: '',
+      isFree: false,
+      isDisabled: false,
+      fileId: null,
+    });
+    setEditingTemplate(null);
+  };
+
+  const openModal = (template?: Template) => {
+    if (template) {
+      setEditingTemplate(template);
+      setFormData({
+        title: template.title,
+        description: template.description || '',
+        category: template.category,
+        isFree: template.isFree === 1,
+        isDisabled: template.isDisabled === 1,
+        fileId: template.file?.id || null,
+      });
+    } else {
+      resetForm();
+    }
+    setIsModalOpen(true);
+  };
+
+  const columns = [
+    {
+      accessorKey: 'title',
+      header: '标题',
+    },
+    {
+      accessorKey: 'category',
+      header: '分类',
+      cell: ({ row }: any) => (
+        <Badge variant="outline">{row.getValue('category')}</Badge>
+      ),
+    },
+    {
+      accessorKey: 'isFree',
+      header: '类型',
+      cell: ({ row }: any) => (
+        <Badge variant={row.getValue('isFree') ? "default" : "secondary"}>
+          {row.getValue('isFree') ? '免费' : '会员'}
+        </Badge>
+      ),
+    },
+    {
+      accessorKey: 'downloadCount',
+      header: '下载次数',
+    },
+    {
+      accessorKey: 'isDisabled',
+      header: '状态',
+      cell: ({ row }: any) => (
+        <Badge variant={row.getValue('isDisabled') ? "destructive" : "default"}>
+          {row.getValue('isDisabled') ? '禁用' : '启用'}
+        </Badge>
+      ),
+    },
+    {
+      id: 'actions',
+      header: '操作',
+      cell: ({ row }: any) => {
+        const template = row.original;
+        return (
+          <div className="flex gap-2">
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => window.open(template.file?.fullUrl, '_blank')}
+            >
+              <Eye className="w-4 h-4" />
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => openModal(template)}
+            >
+              <Edit className="w-4 h-4" />
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => handleDelete(template.id)}
+            >
+              <Trash2 className="w-4 h-4" />
+            </Button>
+          </div>
+        );
+      },
+    },
+  ];
+
+  return (
+    <div className="container mx-auto py-8">
+      <div className="flex justify-between items-center mb-6">
+        <h1 className="text-2xl font-bold">模板管理</h1>
+        <Button onClick={() => openModal()}>
+          <Plus className="w-4 h-4 mr-2" />
+          新建模板
+        </Button>
+      </div>
+
+      <DataTable
+        columns={columns}
+        data={templates}
+        loading={loading}
+        searchPlaceholder="搜索模板..."
+      />
+
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="max-w-2xl">
+          <DialogHeader>
+            <DialogTitle>
+              {editingTemplate ? '编辑模板' : '新建模板'}
+            </DialogTitle>
+            <DialogDescription>
+              {editingTemplate ? '修改模板信息' : '创建新的模板'}
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            <div>
+              <Label>标题</Label>
+              <Input
+                value={formData.title}
+                onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+                placeholder="请输入模板标题"
+              />
+            </div>
+
+            <div>
+              <Label>描述</Label>
+              <Textarea
+                value={formData.description}
+                onChange={(e) => setFormData({ ...formData, description: e.target.value })}
+                placeholder="请输入模板描述"
+                rows={3}
+              />
+            </div>
+
+            <div>
+              <Label>分类</Label>
+              <Input
+                value={formData.category}
+                onChange={(e) => setFormData({ ...formData, category: e.target.value })}
+                placeholder="请输入分类"
+              />
+            </div>
+
+            <div>
+              <Label>文件</Label>
+              <MinioUploader
+                onUploadSuccess={(file) => setFormData({ ...formData, fileId: file.id })}
+                accept=".doc,.docx,.pdf,.xls,.xlsx,.ppt,.pptx"
+              />
+            </div>
+
+            <div className="flex items-center justify-between">
+              <Label>免费下载</Label>
+              <Switch
+                checked={formData.isFree}
+                onCheckedChange={(checked) => setFormData({ ...formData, isFree: checked })}
+              />
+            </div>
+
+            <div className="flex items-center justify-between">
+              <Label>禁用</Label>
+              <Switch
+                checked={formData.isDisabled}
+                onCheckedChange={(checked) => setFormData({ ...formData, isDisabled: checked })}
+              />
+            </div>
+          </div>
+
+          <div className="flex justify-end gap-2 mt-6">
+            <Button variant="outline" onClick={() => setIsModalOpen(false)}>
+              取消
+            </Button>
+            <Button onClick={handleSubmit}>
+              {editingTemplate ? '更新' : '创建'}
+            </Button>
+          </div>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};
+
+export default Templates;

+ 6 - 0
src/client/admin/routes.tsx

@@ -10,6 +10,7 @@ import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import MembershipPlans from './pages/MembershipPlans';
 import { PaymentsPage } from './pages/Payments';
+import Templates from './pages/Templates';
 
 export const router = createBrowserRouter([
   {
@@ -57,6 +58,11 @@ export const router = createBrowserRouter([
         element: <PaymentsPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'templates',
+        element: <Templates />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,

+ 28 - 12
src/client/api.ts

@@ -1,31 +1,47 @@
-
-import { hc } from 'hono/client'
-import type {
-  AuthRoutes, UserRoutes, RoleRoutes,
-  FileRoutes, MembershipPlanRoutes, PaymentRoutes
-} from '@/server/api';
+import { hc } from 'hono/client';
+import type { AuthRoutes, UserRoutes, RoleRoutes, FileRoutes, MembershipPlanRoutes, PaymentRoutes } from '@/server/api';
 import { axiosFetch } from './utils/axios-fetch';
 
-export const authClient = hc<AuthRoutes>('/', {
+// 管理后台客户端
+export const authClient = hc<AuthRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1.auth;
 
-export const userClient = hc<UserRoutes>('/', {
+export const userClient = hc<UserRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1.users;
 
-export const roleClient = hc<RoleRoutes>('/', {
+export const roleClient = hc<RoleRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1.roles;
 
-export const fileClient = hc<FileRoutes>('/', {
+export const fileClient = hc<FileRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1.files;
 
-export const membershipPlanClient = hc<MembershipPlanRoutes>('/', {
+export const membershipPlanClient = hc<MembershipPlanRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1['membership-plans'];
 
-export const paymentClient = hc<PaymentRoutes>('/', {
+export const paymentClient = hc<PaymentRoutes>('/api/v1', {
   fetch: axiosFetch,
 }).api.v1.payments;
+
+// 模板管理客户端(需要认证)
+export const templateClient = hc<any>('/api/v1', {
+  fetch: axiosFetch,
+}).api.v1.templates;
+
+// 公共模板客户端(无需认证)
+export const publicTemplateClient = hc<any>('/api/v1/public', {
+  fetch: axiosFetch,
+}).templates;
+
+// 类型定义
+import type { InferRequestType, InferResponseType } from 'hono/client';
+
+// 模板相关类型
+export type TemplateResponse = InferResponseType<typeof publicTemplateClient.$get, 200>['data'][0];
+export type TemplateListResponse = InferResponseType<typeof publicTemplateClient.$get, 200>;
+export type TemplatePreviewResponse = InferResponseType<typeof publicTemplateClient[':id']['preview']['$get'], 200>;
+export type TemplateDownloadResponse = InferResponseType<typeof publicTemplateClient[':id']['download']['$post'], 200>;

+ 235 - 0
src/client/home/components/FilePreview.tsx

@@ -0,0 +1,235 @@
+import React, { useState, useEffect } from 'react';
+import { publicTemplateClient } from '@/client/api';
+import { Card, CardContent, 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 { Loader2, Download, Eye, AlertCircle } from 'lucide-react';
+import { toast } from 'sonner';
+import { useAuth } from '@/client/home/hooks/AuthProvider';
+import { useNavigate } from 'react-router-dom';
+
+interface FilePreviewProps {
+  templateId: number;
+  onClose?: () => void;
+}
+
+const FilePreview: React.FC<FilePreviewProps> = ({ templateId, onClose }) => {
+  const [template, setTemplate] = useState<any>(null);
+  const [loading, setLoading] = useState(true);
+  const [previewUrl, setPreviewUrl] = useState<string>('');
+  const { user } = useAuth();
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    fetchTemplate();
+  }, [templateId]);
+
+  const fetchTemplate = async () => {
+    try {
+      setLoading(true);
+      const response = await publicTemplateClient[':id'].preview.$get({
+        param: { id: templateId.toString() }
+      });
+      
+      if (response.ok) {
+        const data = await response.json();
+        setTemplate(data.template);
+        setPreviewUrl(data.previewUrl);
+      } else {
+        toast.error('获取模板信息失败');
+      }
+    } catch (error) {
+      console.error('Failed to fetch template:', error);
+      toast.error('获取模板信息失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleDownload = async () => {
+    if (!user) {
+      toast.error('请先登录');
+      navigate('/login');
+      return;
+    }
+
+    if (!template.isFree && !user.membership) {
+      toast.error('需要购买会员才能下载');
+      navigate('/pricing');
+      return;
+    }
+
+    try {
+      const response = await publicTemplateClient[':id'].download.$post({
+        param: { id: templateId.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 getFileIcon = (fileType: string) => {
+    switch (fileType) {
+      case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+        return '📝';
+      case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+        return '📊';
+      case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
+        return '📊';
+      case 'application/pdf':
+        return '📄';
+      default:
+        return '📁';
+    }
+  };
+
+  const renderPreview = () => {
+    if (!previewUrl) {
+      return (
+        <div className="flex flex-col items-center justify-center h-96 text-gray-500">
+          <AlertCircle className="w-16 h-16 mb-4" />
+          <p>无法预览此文件类型</p>
+          <Button onClick={handleDownload} className="mt-4">
+            <Download className="w-4 h-4 mr-2" />
+            直接下载
+          </Button>
+        </div>
+      );
+    }
+
+    // 检查是否为Office文档支持的预览格式
+    const isOfficeDocument = previewUrl.includes('.doc') || 
+                           previewUrl.includes('.docx') || 
+                           previewUrl.includes('.xls') || 
+                           previewUrl.includes('.xlsx') || 
+                           previewUrl.includes('.ppt') || 
+                           previewUrl.includes('.pptx');
+
+    if (isOfficeDocument) {
+      return (
+        <iframe
+          src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(previewUrl)}`}
+          width="100%"
+          height="600px"
+          frameBorder="0"
+          title="文档预览"
+        />
+      );
+    }
+
+    // PDF文件预览
+    if (previewUrl.includes('.pdf')) {
+      return (
+        <iframe
+          src={previewUrl}
+          width="100%"
+          height="600px"
+          frameBorder="0"
+          title="PDF预览"
+        />
+      );
+    }
+
+    // 图片文件预览
+    if (previewUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
+      return (
+        <img
+          src={previewUrl}
+          alt={template?.title}
+          className="max-w-full h-auto mx-auto"
+          style={{ maxHeight: '600px' }}
+        />
+      );
+    }
+
+    // 默认预览
+    return (
+      <iframe
+        src={previewUrl}
+        width="100%"
+        height="600px"
+        frameBorder="0"
+        title="文件预览"
+      />
+    );
+  };
+
+  if (loading) {
+    return (
+      <div className="flex items-center justify-center h-96">
+        <Loader2 className="w-8 h-8 animate-spin" />
+      </div>
+    );
+  }
+
+  if (!template) {
+    return (
+      <div className="flex flex-col items-center justify-center h-96 text-gray-500">
+        <AlertCircle className="w-16 h-16 mb-4" />
+        <p>模板不存在</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="container mx-auto py-8 px-4 max-w-6xl">
+      <Card>
+        <CardHeader>
+          <div className="flex justify-between items-start">
+            <div>
+              <CardTitle className="text-2xl mb-2">{template.title}</CardTitle>
+              <p className="text-gray-600">{template.description}</p>
+            </div>
+            <div className="flex gap-2">
+              <Badge variant={template.isFree ? "default" : "secondary"}>
+                {template.isFree ? "免费" : "会员专享"}
+              </Badge>
+              <Badge variant="outline">{template.category}</Badge>
+            </div>
+          </div>
+        </CardHeader>
+        
+        <CardContent>
+          <div className="mb-4 flex justify-between items-center">
+            <div className="text-sm text-gray-600">
+              <span>下载次数: {template.downloadCount}</span>
+            </div>
+            <div className="flex gap-2">
+              <Button
+                variant="outline"
+                onClick={onClose}
+              >
+                关闭
+              </Button>
+              <Button
+                onClick={handleDownload}
+                disabled={!template.isFree && !user?.membership}
+              >
+                <Download className="w-4 h-4 mr-2" />
+                {template.isFree ? '免费下载' : '会员下载'}
+              </Button>
+            </div>
+          </div>
+          
+          <div className="border rounded-lg overflow-hidden">
+            {renderPreview()}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+};
+
+export default FilePreview;

+ 299 - 0
src/client/home/pages/TemplateSquare.tsx

@@ -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;

+ 2 - 1
src/client/home/routes.tsx

@@ -12,6 +12,7 @@ import WordPreview from './pages/WordPreview';
 import PricingPage from './pages/PricingPage';
 import ProfilePage from './pages/ProfilePage';
 import RechargePage from './pages/RechargePage';
+import TemplateSquare from './pages/TemplateSquare';
 
 export const router = createBrowserRouter([
   {
@@ -32,7 +33,7 @@ export const router = createBrowserRouter([
   },
   {
     path: '/templates',
-    element: <HomePage />
+    element: <TemplateSquare />
   },
   {
     path: '/pricing',

+ 6 - 0
src/server/api.ts

@@ -7,6 +7,8 @@ import rolesRoute from './api/roles/index'
 import fileRoute from './api/files/index'
 import membershipPlanRoute from './api/membership-plans/index'
 import paymentRoute from './api/payments/index'
+import templateRoute from './api/templates/index'
+import publicTemplateRoute from './api/public-templates/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -107,6 +109,8 @@ const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 const fileRoutes = api.route('/api/v1/files', fileRoute)
 const membershipPlanRoutes = api.route('/api/v1/membership-plans', membershipPlanRoute)
 const paymentRoutes = api.route('/api/v1/payments', paymentRoute)
+const templateRoutes = api.route('/api/v1/templates', templateRoute)
+const publicTemplateRoutes = api.route('/api/v1/public/templates', publicTemplateRoute)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -114,6 +118,8 @@ export type RoleRoutes = typeof roleRoutes
 export type FileRoutes = typeof fileRoutes
 export type MembershipPlanRoutes = typeof membershipPlanRoutes
 export type PaymentRoutes = typeof paymentRoutes
+export type TemplateRoutes = typeof templateRoutes
+export type PublicTemplateRoutes = typeof publicTemplateRoutes
 
 app.route('/', api)
 export default app

+ 103 - 0
src/server/api/public-templates/[id]/download.ts

@@ -0,0 +1,103 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { AppDataSource } from '@/server/data-source';
+import { TemplateService } from '@/server/modules/templates/template.service';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+import { AuthContext } from '@/server/types/context';
+
+// 路径参数Schema
+const DownloadParams = z.object({
+  id: z.string().openapi({
+    param: { name: 'id', in: 'path' },
+    example: '1',
+    description: '模板ID'
+  })
+});
+
+// 下载响应Schema
+const DownloadResponse = z.object({
+  downloadUrl: z.string().openapi({
+    description: '下载URL',
+    example: 'https://example.com/download/template.docx'
+  }),
+  fileName: z.string().openapi({
+    description: '文件名',
+    example: '销售合同模板.docx'
+  })
+});
+
+// 路由定义
+const routeDef = createRoute({
+  method: 'post',
+  path: '/{id}/download',
+  middleware: [authMiddleware],
+  request: {
+    params: DownloadParams
+  },
+  responses: {
+    200: {
+      description: '成功获取下载链接',
+      content: { 'application/json': { schema: DownloadResponse } }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    403: {
+      description: '没有下载权限',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    404: {
+      description: '模板不存在',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono<AuthContext>().openapi(routeDef, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    const templateId = parseInt(id);
+    const user = c.get('user');
+
+    if (!user) {
+      return c.json({ code: 401, message: '请先登录' }, 401);
+    }
+
+    const service = new TemplateService(AppDataSource);
+    
+    // 检查模板是否存在
+    const template = await service.getById(templateId, ['file']);
+    if (!template) {
+      return c.json({ code: 404, message: '模板不存在' }, 404);
+    }
+
+    // 检查下载权限
+    const canDownload = await service.checkDownloadPermission(templateId, user.id);
+    if (!canDownload) {
+      return c.json({ code: 403, message: '需要购买会员才能下载该模板' }, 403);
+    }
+
+    // 增加下载次数
+    await service.incrementDownloadCount(templateId);
+
+    // 获取下载URL
+    const downloadUrl = template.file.fullUrl;
+    const fileName = template.file.name;
+
+    return c.json({
+      downloadUrl,
+      fileName
+    }, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '下载模板失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 93 - 0
src/server/api/public-templates/[id]/preview.ts

@@ -0,0 +1,93 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { AppDataSource } from '@/server/data-source';
+import { TemplateService } from '@/server/modules/templates/template.service';
+
+// 路径参数Schema
+const PreviewParams = z.object({
+  id: z.string().openapi({
+    param: { name: 'id', in: 'path' },
+    example: '1',
+    description: '模板ID'
+  })
+});
+
+// 预览响应Schema
+const PreviewResponse = z.object({
+  previewUrl: z.string().openapi({
+    description: '预览URL',
+    example: 'https://example.com/preview/template.docx'
+  }),
+  template: z.object({
+    id: z.number().openapi({ description: '模板ID', example: 1 }),
+    title: z.string().openapi({ description: '模板标题', example: '销售合同模板' }),
+    description: z.string().nullable().openapi({ description: '模板描述' }),
+    category: z.string().openapi({ description: '分类', example: '合同' }),
+    isFree: z.number().openapi({ description: '是否免费', example: 0 }),
+    downloadCount: z.number().openapi({ description: '下载次数', example: 100 })
+  })
+});
+
+// 路由定义
+const routeDef = createRoute({
+  method: 'get',
+  path: '/{id}/preview',
+  request: {
+    params: PreviewParams
+  },
+  responses: {
+    200: {
+      description: '成功获取预览信息',
+      content: { 'application/json': { schema: PreviewResponse } }
+    },
+    404: {
+      description: '模板不存在',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono().openapi(routeDef, async (c) => {
+  try {
+    const { id } = c.req.valid('param');
+    const templateId = parseInt(id);
+
+    const service = new TemplateService(AppDataSource);
+    
+    // 检查模板是否存在
+    const template = await service.getById(templateId, ['file']);
+    if (!template) {
+      return c.json({ code: 404, message: '模板不存在' }, 404);
+    }
+
+    // 检查模板是否可用
+    if (template.isDisabled || template.isDeleted) {
+      return c.json({ code: 404, message: '模板不可用' }, 404);
+    }
+
+    // 构建预览URL(这里可以集成Office Online或其他预览服务)
+    const previewUrl = template.previewUrl || template.file.fullUrl;
+
+    return c.json({
+      previewUrl,
+      template: {
+        id: template.id,
+        title: template.title,
+        description: template.description,
+        category: template.category,
+        isFree: template.isFree,
+        downloadCount: template.downloadCount
+      }
+    }, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取预览信息失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 38 - 0
src/server/api/public-templates/categories.ts

@@ -0,0 +1,38 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { AppDataSource } from '@/server/data-source';
+import { TemplateService } from '@/server/modules/templates/template.service';
+
+// 路由定义
+const categoriesRoute = createRoute({
+  method: 'get',
+  path: '/categories',
+  responses: {
+    200: {
+      description: '成功获取分类列表',
+      content: { 
+        'application/json': { 
+          schema: z.array(z.string().openapi({ description: '分类名称', example: '合同' }))
+        } 
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono().openapi(categoriesRoute, async (c) => {
+  try {
+    const service = new TemplateService(AppDataSource);
+    const categories = await service.getCategories();
+    return c.json(categories, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取分类列表失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 87 - 0
src/server/api/public-templates/get.ts

@@ -0,0 +1,87 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { TemplateSchema } from '@/server/modules/templates/template.schema';
+import { AppDataSource } from '@/server/data-source';
+import { TemplateService } from '@/server/modules/templates/template.service';
+
+// 查询参数Schema
+const ListQuery = z.object({
+  page: z.coerce.number().int().positive().default(1).openapi({
+    description: '页码',
+    example: 1
+  }),
+  pageSize: z.coerce.number().int().positive().max(100).default(12).openapi({
+    description: '每页条数',
+    example: 12
+  }),
+  category: z.string().optional().openapi({
+    description: '分类筛选',
+    example: '合同'
+  }),
+  isFree: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否免费',
+    example: 1
+  })
+});
+
+// 列表响应Schema
+const ListResponse = z.object({
+  data: z.array(TemplateSchema),
+  pagination: z.object({
+    total: z.number().openapi({ example: 100, description: '总记录数' }),
+    current: z.number().openapi({ example: 1, description: '当前页码' }),
+    pageSize: z.number().openapi({ example: 12, description: '每页数量' })
+  })
+});
+
+// 路由定义
+const listRoute = createRoute({
+  method: 'get',
+  path: '/',
+  request: {
+    query: ListQuery
+  },
+  responses: {
+    200: {
+      description: '成功获取模板列表',
+      content: { 'application/json': { schema: ListResponse } }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono().openapi(listRoute, async (c) => {
+  try {
+    const query = c.req.valid('query');
+    const service = new TemplateService(AppDataSource);
+    
+    const [data, total] = await service.getAvailableTemplates(
+      query.page,
+      query.pageSize,
+      query.category,
+      query.isFree !== undefined ? Boolean(query.isFree) : undefined
+    );
+    
+    return c.json({
+      data,
+      pagination: {
+        total,
+        current: query.page,
+        pageSize: query.pageSize
+      }
+    }, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取模板列表失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 15 - 0
src/server/api/public-templates/index.ts

@@ -0,0 +1,15 @@
+import { OpenAPIHono } from '@hono/zod-openapi';
+import listRoute from './get';
+import categoriesRoute from './categories';
+import popularRoute from './popular';
+import downloadRoute from './[id]/download';
+import previewRoute from './[id]/preview';
+
+const app = new OpenAPIHono()
+  .route('/', listRoute)
+  .route('/', categoriesRoute)
+  .route('/', popularRoute)
+  .route('/', downloadRoute)
+  .route('/', previewRoute);
+
+export default app;

+ 54 - 0
src/server/api/public-templates/popular.ts

@@ -0,0 +1,54 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { AppDataSource } from '@/server/data-source';
+import { TemplateService } from '@/server/modules/templates/template.service';
+
+// 查询参数Schema
+const PopularQuery = z.object({
+  limit: z.coerce.number().int().positive().max(20).default(8).openapi({
+    description: '获取数量',
+    example: 8
+  })
+});
+
+// 路由定义
+const popularRoute = createRoute({
+  method: 'get',
+  path: '/popular',
+  request: {
+    query: PopularQuery
+  },
+  responses: {
+    200: {
+      description: '成功获取热门模板',
+      content: { 
+        'application/json': { 
+          schema: z.array(z.any()) // 使用TemplateSchema数组
+        } 
+      }
+    },
+    400: {
+      description: '请求参数错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: z.object({ code: z.number(), message: z.string() }) } }
+    }
+  }
+});
+
+// 路由实现
+const app = new OpenAPIHono().openapi(popularRoute, async (c) => {
+  try {
+    const query = c.req.valid('query');
+    const service = new TemplateService(AppDataSource);
+    const templates = await service.getPopularTemplates(query.limit);
+    return c.json(templates, 200);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : '获取热门模板失败';
+    return c.json({ code: 500, message }, 500);
+  }
+});
+
+export default app;

+ 17 - 0
src/server/api/templates/index.ts

@@ -0,0 +1,17 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { Template } from '@/server/modules/templates/template.entity';
+import { TemplateSchema, CreateTemplateDto, UpdateTemplateDto } from '@/server/modules/templates/template.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const templateRoutes = createCrudRoutes({
+  entity: Template,
+  createSchema: CreateTemplateDto,
+  updateSchema: UpdateTemplateDto,
+  getSchema: TemplateSchema,
+  listSchema: TemplateSchema,
+  searchFields: ['title', 'description', 'category'],
+  relations: ['file'],
+  middleware: [authMiddleware] // 需要登录才能管理模板
+});
+
+export default templateRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -8,6 +8,7 @@ import { Role } from "./modules/users/role.entity"
 import { File } from "./modules/files/file.entity"
 import { PaymentEntity } from "./modules/payments/payment.entity"
 import { MembershipPlan } from "./modules/membership/membership-plan.entity"
+import { Template } from "./modules/templates/template.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -17,7 +18,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
-    User, Role, File, PaymentEntity, MembershipPlan,
+    User, Role, File, PaymentEntity, MembershipPlan, Template,
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 54 - 0
src/server/modules/templates/template.entity.ts

@@ -0,0 +1,54 @@
+import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
+import { File } from '@/server/modules/files/file.entity';
+
+@Entity('templates')
+export class Template {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'title', type: 'varchar', length: 255 })
+  title!: string;
+
+  @Column({ name: 'description', type: 'text', nullable: true })
+  description!: string | null;
+
+  @Column({ name: 'file_id', type: 'int', unsigned: true })
+  fileId!: number;
+
+  @ManyToOne(() => File)
+  @JoinColumn({ name: 'file_id', referencedColumnName: 'id' })
+  file!: File;
+
+  @Column({ name: 'preview_url', type: 'varchar', length: 500, nullable: true })
+  previewUrl!: string | null;
+
+  @Column({ name: 'category', type: 'varchar', length: 100 })
+  category!: string;
+
+  @Column({ name: 'is_free', type: 'tinyint', default: 0 })
+  isFree!: number;
+
+  @Column({ name: 'download_count', type: 'int', unsigned: true, default: 0 })
+  downloadCount!: number;
+
+  @Column({ name: 'is_disabled', type: 'tinyint', default: 0 })
+  isDisabled!: number;
+
+  @Column({ name: 'is_deleted', type: 'tinyint', default: 0 })
+  isDeleted!: number;
+
+  @Column({ 
+    name: 'created_at', 
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP' 
+  })
+  createdAt!: Date;
+
+  @Column({ 
+    name: 'updated_at', 
+    type: 'timestamp', 
+    default: () => 'CURRENT_TIMESTAMP',
+    onUpdate: 'CURRENT_TIMESTAMP' 
+  })
+  updatedAt!: Date;
+}

+ 133 - 0
src/server/modules/templates/template.schema.ts

@@ -0,0 +1,133 @@
+import { z } from '@hono/zod-openapi';
+
+// 基础模板Schema
+export const TemplateSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '模板ID',
+    example: 1
+  }),
+  title: z.string().min(1).max(255).openapi({
+    description: '模板标题',
+    example: '销售合同模板'
+  }),
+  description: z.string().max(2000).nullable().openapi({
+    description: '模板描述',
+    example: '标准销售合同模板,适用于一般商品销售场景'
+  }),
+  fileId: z.number().int().positive().openapi({
+    description: '文件ID',
+    example: 1
+  }),
+  file: z.object({
+    id: z.number().int().positive().openapi({ description: '文件ID' }),
+    name: z.string().max(255).openapi({ description: '文件名', example: 'contract-template.docx' }),
+    fullUrl: z.string().openapi({ description: '文件完整URL', example: 'https://example.com/files/contract-template.docx' }),
+    type: z.string().nullable().openapi({ description: '文件类型', example: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }),
+    size: z.number().nullable().openapi({ description: '文件大小(字节)', example: 102400 })
+  }).nullable().optional().openapi({
+    description: '文件信息'
+  }),
+  previewUrl: z.string().url().nullable().openapi({
+    description: '预览URL',
+    example: 'https://example.com/preview/contract-template'
+  }),
+  category: z.string().max(100).openapi({
+    description: '模板分类',
+    example: '合同'
+  }),
+  isFree: z.coerce.number().int().min(0).max(1).openapi({
+    description: '是否免费(0-否 1-是)',
+    example: 0
+  }),
+  downloadCount: z.coerce.number().int().min(0).openapi({
+    description: '下载次数',
+    example: 100
+  }),
+  isDisabled: z.coerce.number().int().min(0).max(1).openapi({
+    description: '是否禁用(0-否 1-是)',
+    example: 0
+  }),
+  isDeleted: z.coerce.number().int().min(0).max(1).openapi({
+    description: '是否删除(0-否 1-是)',
+    example: 0
+  }),
+  createdAt: z.coerce.date().openapi({
+    description: '创建时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  updatedAt: z.coerce.date().openapi({
+    description: '更新时间',
+    example: '2024-01-01T00:00:00Z'
+  })
+});
+
+// 创建模板DTO
+export const CreateTemplateDto = z.object({
+  title: z.string().min(1).max(255).openapi({
+    description: '模板标题',
+    example: '销售合同模板'
+  }),
+  description: z.string().max(2000).nullable().optional().openapi({
+    description: '模板描述',
+    example: '标准销售合同模板'
+  }),
+  fileId: z.number().int().positive().openapi({
+    description: '文件ID',
+    example: 1
+  }),
+  category: z.string().max(100).openapi({
+    description: '模板分类',
+    example: '合同'
+  }),
+  isFree: z.coerce.number().int().min(0).max(1).default(0).openapi({
+    description: '是否免费',
+    example: 0
+  })
+});
+
+// 更新模板DTO
+export const UpdateTemplateDto = z.object({
+  title: z.string().min(1).max(255).optional().openapi({
+    description: '模板标题',
+    example: '更新后的模板标题'
+  }),
+  description: z.string().max(2000).nullable().optional().openapi({
+    description: '模板描述',
+    example: '更新后的描述'
+  }),
+  fileId: z.number().int().positive().optional().openapi({
+    description: '文件ID',
+    example: 2
+  }),
+  category: z.string().max(100).optional().openapi({
+    description: '模板分类',
+    example: '合同'
+  }),
+  isFree: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否免费',
+    example: 1
+  }),
+  isDisabled: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否禁用',
+    example: 0
+  })
+});
+
+// 列表响应Schema
+export const TemplateListResponse = z.object({
+  data: z.array(TemplateSchema),
+  pagination: z.object({
+    total: z.number().openapi({
+      example: 100,
+      description: '总记录数'
+    }),
+    current: z.number().openapi({
+      example: 1,
+      description: '当前页码'
+    }),
+    pageSize: z.number().openapi({
+      example: 10,
+      description: '每页数量'
+    })
+  })
+});

+ 115 - 0
src/server/modules/templates/template.service.ts

@@ -0,0 +1,115 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { Template } from './template.entity';
+
+export class TemplateService extends GenericCrudService<Template> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, Template);
+  }
+
+  /**
+   * 获取可用的模板列表(排除禁用和删除的)
+   */
+  async getAvailableTemplates(
+    page: number = 1,
+    pageSize: number = 10,
+    category?: string,
+    isFree?: boolean
+  ): Promise<[Template[], number]> {
+    const where: any = {
+      isDisabled: 0,
+      isDeleted: 0
+    };
+
+    if (category) {
+      where.category = category;
+    }
+
+    if (isFree !== undefined) {
+      where.isFree = isFree ? 1 : 0;
+    }
+
+    return this.getList(
+      page,
+      pageSize,
+      undefined,
+      ['title', 'description'],
+      where,
+      ['file']
+    );
+  }
+
+  /**
+   * 获取热门模板(按下载次数排序)
+   */
+  async getPopularTemplates(limit: number = 10): Promise<Template[]> {
+    const [templates] = await this.getList(
+      1,
+      limit,
+      undefined,
+      undefined,
+      { isDisabled: 0, isDeleted: 0 },
+      ['file'],
+      { downloadCount: 'DESC' }
+    );
+    return templates;
+  }
+
+  /**
+   * 获取模板分类列表
+   */
+  async getCategories(): Promise<string[]> {
+    const query = this.repository
+      .createQueryBuilder('template')
+      .select('DISTINCT template.category')
+      .where('template.isDisabled = :isDisabled', { isDisabled: 0 })
+      .andWhere('template.isDeleted = :isDeleted', { isDeleted: 0 })
+      .orderBy('template.category', 'ASC');
+
+    const result = await query.getRawMany();
+    return result.map(item => item.category);
+  }
+
+  /**
+   * 增加下载次数
+   */
+  async incrementDownloadCount(id: number): Promise<void> {
+    await this.repository.increment({ id }, 'downloadCount', 1);
+  }
+
+  /**
+   * 检查用户是否有下载权限
+   */
+  async checkDownloadPermission(id: number, userId: number): Promise<boolean> {
+    const template = await this.getById(id, ['file']);
+    if (!template) {
+      throw new Error('模板不存在');
+    }
+
+    // 免费模板直接允许下载
+    if (template.isFree) {
+      return true;
+    }
+
+    // 检查用户会员状态
+    const userRepository = this.dataSource.getRepository('UserEntity');
+    const user = await userRepository.findOne({
+      where: { id: userId },
+      relations: ['membership']
+    });
+
+    if (!user) {
+      throw new Error('用户不存在');
+    }
+
+    // 检查用户是否激活的会员
+    if (user.membership && user.membership.isActive) {
+      const now = new Date();
+      if (new Date(user.membership.expiresAt) > now) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+}