产品化
登录线上审核失败:
当前完成了用户的登录注册与信息修改,但是用户在登录以后可以做什么,比如用户登录以后可以上传自己的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)