第十六章 文件上传与展示:构建PDF上传流程

205 阅读5分钟

产品化

登录线上审核失败:

当前完成了用户的登录注册与信息修改,但是用户在登录以后可以做什么,比如用户登录以后可以上传自己的PDF并且预览,那么肯定不是所有用户都可以,比如会员制,那就需要支付功能,后续考虑,现在可以添加一个临时方案,如联系邮箱申请更长时间权限,用户注册3天内免费领取7天这样可以让测试账号正常使用

一、上传

1. dialog组件

安装shadcn组件

npx shadcn-ui@latest add dialog

2. 升级数据库

增加文件模型,用户与文件1对多

model User {
  id             String     @id    @default(cuid())
  name           String?
  phone          String?    @unique
  email          String?    @unique
  password       String?
  image          String?
  bio            String?
  expiredAt      DateTime?
  active         Boolean    @default(true)
  createdAt      DateTime   @default(now())
  File    File[]
}

model File {
  id             String @id @default(cuid())
  name           String
  url            String
  createdAt      DateTime @default(now())
  User      User?    @relation(fields: [userId], references: [id])
  userId         String?
}

3. 上传组件

Dashboard页面导入Dashbord组件

// src/app/(page)/(protected)/dashboard/page.tsx
import Dashboard from '@/components/Dashboard'

function DashboardPage() {
  return <Dashboard />
}

export default DashboardPage

Dashboard组件导入UploadButton上传组件

// src/components/Dashboard.tsx
import UploadButton from './pdf/UploadButton'

const Dashboard = () => {
  return (
    <main className="mx-auto max-w-7xl p-10">
      <div className="mt-8 flex flex-col items-start justify-between gap-4 border-b border-gray-200 pb-5 sm:flex-row sm:items-center sm:gap-0">
        <h1 className="mb-3 font-bold text-5xl text-gray-900">我的文件</h1>

        <UploadButton />
      </div>
    </main>
  )
}

export default Dashboard

UploadButton上传组件

// src/components/pdf/UploadButton.tsx
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { Cloud, File } from 'lucide-react'
import { trpc } from '@/app/_trpc/client'
import { useToast } from '../ui/use-toast'
import { Dialog, DialogContent, DialogTrigger } from '../ui/dialog'
import { Button } from '../ui/button'
import { Progress } from '../ui/progress'
import { ManualTRPCError } from '@/lib/utils'
import { getMessages } from '@/lib/tips'
import { uploadOSS } from '@/lib/ossClient'

const UploadDropzone = () => {
  const { toast } = useToast()
  const [uploadProgress, setUploadProgress] = useState<number>(0)
  const progressRefInterval = useRef<number | null>(null)
  const startSimulatedProgress = () => {
    setUploadProgress(0)
    const interval = setInterval(() => {
      setUploadProgress((prevProgress) => {
        if (prevProgress >= 95) {
          clearInterval(interval)
          return prevProgress
        }
        return prevProgress + 5
      })
    }, 500)

    return interval as unknown as number
  }
  const clearIntervals = (process: number) => {
    if (progressRefInterval.current) {
      clearInterval(progressRefInterval.current)
      progressRefInterval.current = null
    }
    setUploadProgress(process)
  }
  useEffect(() => {
    return () => {
      if (progressRefInterval.current) {
        clearInterval(progressRefInterval.current)
        progressRefInterval.current = null
      }
    }
  }, [])

  const { mutateAsync: asyncGetStsToken } = trpc.getStsToken.useMutation()
  const { mutate: startAddPdf } = trpc.addPDF.useMutation({
    onSuccess: (data) => {
      if (data) {
        toast({
          title: getMessages('10048'),
          description: getMessages('10049'),
          variant: 'default',
        })
        clearIntervals(100)
      }
    },
    onError: (error) => {
      setUploadProgress(0)
      toast({
        title: getMessages('10048'),
        description: error.message,
        variant: 'destructive',
      })
    },
  })
  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      if (acceptedFiles.length === 0) {
        return
      }
      // 检查文件类型与大小
      const file = acceptedFiles[0]
      if (file.type !== 'application/pdf') {
        toast({
          title: getMessages('10048'),
          description: getMessages('10050'),
          variant: 'destructive',
        })
        return
      }
      if (file.size > 4 * 1024 * 1024) {
        toast({
          title: getMessages('10048'),
          description: getMessages('10051'),
          variant: 'destructive',
        })
        return
      }
      progressRefInterval.current = startSimulatedProgress()
      // 获取临时凭证
      let stsToken = null
      try {
        const res = await asyncGetStsToken()
        stsToken = res
      } catch (error) {
        const trpcError = error as ManualTRPCError
        toast({
          title: getMessages('10048'),
          description: trpcError.message,
          variant: 'destructive',
        })
        setUploadProgress(0)
        return
      }
      // 使用临时凭证上传文件
      if (!stsToken) {
        setUploadProgress(0)
        return
      }
      try {
        // 生成随机的文件名
        const randomFileName =
          Math.random().toString(36).substring(2, 15) +
          '.' +
          file.type.split('/')[1]
        const filePath = 'file/' + randomFileName
        const url = await uploadOSS(
          {
            file,
            filePath,
            ...stsToken,
          },
          async () => {
            const ret = await asyncGetStsToken()
            if (ret) {
              return ret
            }
            return null
          }
        )
        if (url) {
          startAddPdf({
            name: file.name,
            url: filePath,
          })
        }
      } catch (error) {
        setUploadProgress(0)
        toast({
          title: getMessages('10048'),
          description: getMessages('10052'),
          variant: 'destructive',
        })
      }
      console.log(acceptedFiles)
    },
    [asyncGetStsToken, startAddPdf, toast]
  )

  const { getRootProps, getInputProps, acceptedFiles } = useDropzone({ onDrop })

  return (
    <div
      {...getRootProps()}
      className="border h-64 m-4 border-dashed border-gray-300 rounded-lg"
    >
      <div className="flex items-center justify-center h-full w-full">
        <label
          htmlFor="dropzone-file"
          className="flex flex-col items-center justify-center w-full h-full rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
        >
          <div className="flex flex-col items-center justify-center pt-5 pb-6">
            <Cloud className="h-6 w-6 text-zinc-500 mb-2" />
            <p className="mb-2 text-sm text-zinc-700">
              <span className="font-semibold">点击上传</span> 或拖放
            </p>
            <p className="text-xs text-zinc-500">PDF (最大上传限制 4MB)</p>
          </div>

          {acceptedFiles && acceptedFiles[0] ? (
            <div className="max-w-xs bg-white flex items-center rounded-md overflow-hidden outline outline-[1px] outline-zinc-200 divide-x divide-zinc-200">
              <div className="px-3 py-2 h-full grid place-items-center">
                <File className="h-4 w-4 text-blue-500" />
              </div>
              <div className="px-3 py-2 h-full text-sm truncate">
                {acceptedFiles[0].name}
              </div>
            </div>
          ) : null}

          {uploadProgress !== 0 ? (
            <div className="w-full mt-4 max-w-xs mx-auto">
              <Progress
                indicatorColor={uploadProgress === 100 ? 'bg-green-500' : ''}
                value={uploadProgress}
                className="h-1 w-full bg-zinc-200"
              />
            </div>
          ) : null}

          <input {...getInputProps()} />
        </label>
      </div>
    </div>
  )
}

const UploadButton = () => {
  const [isOpen, setIsOpen] = useState<boolean>(false)

  return (
    <Dialog
      open={isOpen}
      onOpenChange={(v) => {
        if (!v) {
          setIsOpen(v)
        }
      }}
    >
      <DialogTrigger onClick={() => setIsOpen(true)} asChild>
        <Button>上传 PDF</Button>
      </DialogTrigger>

      <DialogContent>
        <UploadDropzone />
      </DialogContent>
    </Dialog>
  )
}

export default UploadButton

4. 添加方法

// src/trpc/index.ts
addPDF: privateProcedure
.input(z.object({ url: z.string(), name: z.string() }))
.mutation(async ({ ctx, input }) => {
  try {
    const { userId } = ctx
    const { url, name } = input
    const fileInfo = db.file.create({
      data: {
        url,
        name,
        userId,
      },
    })
    return fileInfo
  } catch (error) {
    console.log(error)
    handleErrorforInitiative(error)
  }
}),

4. 提示文案

'10048': '上传PDF',
'10049': 'PDF上传成功',
'10050': '上传PDF类型文件',
'10051': 'PDF大小不能超过4M',
'10052': 'PDF上传失败',

二、展示与删除

1. 第三方库

处理时间

npm i date-fns

2. 列表组件

Dashboard组件导入FileLists组件,FileLists组件定义如下:

// src/components/pdf/FileLists.tsx
'use client'

import { trpc } from '@/app/_trpc/client'
import { Ghost, Loader2, Plus, Trash } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import { format } from 'date-fns'
function FileLists() {
  const [currentlyDeletingFile, setCurrentlyDeletingFile] = useState<
    string | null
  >(null)

  const utils = trpc.useUtils()

  const { data: files, isLoading } = trpc.getUserFiles.useQuery()

  const { mutate: deleteFile } = trpc.deleteFile.useMutation({
    onSuccess: () => {
      utils.getUserFiles.invalidate()
    },
    onMutate({ id }) {
      setCurrentlyDeletingFile(id)
    },
    onSettled() {
      setCurrentlyDeletingFile(null)
    },
  })

  return (
    <>
      {files && files?.length !== 0 ? (
        <ul className="mt-8 grid grid-cols-1 gap-6 divide-y divide-zinc-200 md:grid-cols-2 lg:grid-cols-3">
          {files
            .sort(
              (a, b) =>
                new Date(b.createdAt).getTime() -
                new Date(a.createdAt).getTime()
            )
            .map((file) => (
              <li
                key={file.id}
                className="col-span-1 divide-y divide-gray-200 rounded-lg bg-white shadow transition hover:shadow-lg"
              >
                <Link
                  href={`/dashboard/${file.id}`}
                  className="flex flex-col gap-2"
                >
                  <div className="pt-6 px-6 flex w-full items-center justify-between space-x-6">
                    <div className="flex-1 truncate">
                      <div className="flex items-center space-x-3">
                        <h3 className="truncate text-lg font-medium text-zinc-900">
                          {file.name}
                        </h3>
                      </div>
                    </div>
                  </div>
                </Link>

                <div className="px-6 mt-4 grid grid-cols-2 place-items-center py-2 gap-6 text-xs text-zinc-500">
                  <div className="flex items-center gap-2">
                    <Plus className="h-4 w-4" />
                    {format(new Date(file.createdAt), 'yyyy-MM-dd HH:mm:ss')}
                  </div>

                  <Button
                    onClick={() => deleteFile({ id: file.id })}
                    size="sm"
                    className="w-full"
                    variant="destructive"
                  >
                    {currentlyDeletingFile === file.id ? (
                      <Loader2 className="h-4 w-4 animate-spin" />
                    ) : (
                      <Trash className="h-4 w-4" />
                    )}
                  </Button>
                </div>
              </li>
            ))}
        </ul>
      ) : isLoading ? (
        <Skeleton />
      ) : (
        <div className="mt-16 flex flex-col items-center gap-2">
          <Ghost className="h-8 w-8 text-zinc-800" />
          <h3 className="font-semibold text-xl">这里很空旷</h3>
          <p>开始上传你的第一个pdf文件吧。</p>
        </div>
      )}
    </>
  )
}

export default FileLists

3. 删除查询逻辑

// src/trpc/index.ts
getUserFiles: privateProcedure.query(async ({ ctx }) => {
  try {
    const { userId } = ctx
    return await db.file.findMany({
      where: {
        userId,
      },
    })
  } catch (error) {
    handleErrorforInitiative(error)
  }
}),
deleteFile: privateProcedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ ctx, input }) => {
    try {
      const { userId } = ctx
      const file = await db.file.findFirst({
        where: {
          id: input.id,
          userId,
        },
      })
      if (!file) throw new ManualTRPCError('NOT_FOUND', getMessages('10053'))
      // 删除文件
      await deleteObject(file.url)
      await db.file.delete({
        where: {
          id: input.id,
        },
      })
      return file
    } catch (error) {
      handleErrorforInitiative(error)
    }
  }),

4. 文案与样式

文案:

'10053': '文件不存在',

样式:

global.css重新定义destructive的颜色

--destructive: 0 86% 97%; /* custom value */
--destructive-foreground: 0 74% 42%; /* custom value */

三、预览页前置

1. 创建页面

创建一个预览页面,拿到对应pdf的url,给相应的组件

// src/app/(page)/(protected)/dashboard/[fileid]/page.tsx
import { db } from '@/db'
import { notFound } from 'next/navigation'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
import PdfRenderer from '@/components/pdf/PdfRenderer'
import { getSignedUrl } from '@/lib/ossServer'
interface PageProps {
  params: {
    fileid: string
  }
}
const Page = async ({ params }: PageProps) => {
  const { fileid } = params
  const session = await getServerSession(authOptions)
  let file = null
  if (session?.user) {
    file = await db.file.findFirst({
      where: {
        id: fileid,
        userId: session.user.id,
      },
    })
  }
  if (!file) notFound()
  const url = await getSignedUrl(file.url)

  return (
    <div className="flex-1 justify-between flex flex-col h-[calc(100vh-3.5rem)]">
      <div className="mx-auto w-full max-w-8xl">
        <div className="px-4 py-6 sm:px-6">
          <PdfRenderer url={url} />
        </div>
      </div>
    </div>
  )
}

export default Page

2. 创建组件

// src/components/pdf/PdfRenderer.tsx
interface PdfRendererProps {
  url: string
}

const PdfRenderer = ({ url }: PdfRendererProps) => {
  return <div>{url}</div>
}

export default PdfRenderer

3. 上传成功的跳转

// src/components/pdf/UploadButton.tsx
import { useRouter } from 'next/navigation'
const router = useRouter()
setTimeout(() => {
  router.push('/dashboard/' + data.id)
}, 1000)