Skip to content

Commit 7521890

Browse files
authored
feat: file upload portal (#668)
* chore(script): .env file updating script * chore(sync): i am back home * chore(sync): switching container * chore(sync): i am back home * chore(sync): today's work so far * chore(sync): adopted fix by twc * feat: file upload mostly finished (TODO: http error handling) * feat: using new sidebar * fix: moved bucket into .env * feat: changeset
1 parent 231f03e commit 7521890

File tree

18 files changed

+3037
-385
lines changed

18 files changed

+3037
-385
lines changed

.changeset/plenty-sheep-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"enspire": minor
3+
---
4+
5+
Added club file uploading portal
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<script setup lang="ts">
2+
import type { FileCollection } from '@prisma/client'
3+
import type { AllClubs } from '~~/types/api/user/all_clubs'
4+
import Toaster from '@/components/ui/toast/Toaster.vue'
5+
import { useToast } from '@/components/ui/toast/use-toast'
6+
import { toTypedSchema } from '@vee-validate/zod'
7+
import dayjs from 'dayjs'
8+
import { v4 as uuidv4 } from 'uuid'
9+
import { useForm } from 'vee-validate'
10+
import * as z from 'zod'
11+
12+
const props = defineProps({
13+
club: {
14+
type: String,
15+
required: true,
16+
},
17+
collection: {
18+
type: String,
19+
required: true,
20+
},
21+
filetypes: {
22+
type: Array<string>,
23+
required: true,
24+
},
25+
title: {
26+
type: String,
27+
required: true,
28+
},
29+
})
30+
31+
definePageMeta({
32+
middleware: ['auth'],
33+
})
34+
35+
function fileTypesPrompt(fileTypes: string[]) {
36+
if (fileTypes.length === 0 || fileTypes.includes('*')) {
37+
return '无文件类型限制'
38+
}
39+
else {
40+
return `上传类型为 ${fileTypes.join(', ').toUpperCase()} 的文件`
41+
}
42+
}
43+
function fileTypesAcceptAttr(fileTypes: string[]) {
44+
if (fileTypes.length === 0 || fileTypes.includes('*')) {
45+
return '*'
46+
}
47+
else {
48+
return fileTypes.map(type => `.${type}`).join(',')
49+
}
50+
}
51+
52+
// Still seems to be buggy
53+
// const formSchema = toTypedSchema(z.object({
54+
// file: z.custom(v => v, 'File missing'),
55+
// }))
56+
57+
// 滚一边去
58+
function readFileAsDataURL(file: File) {
59+
return new Promise((resolve, reject) => {
60+
const reader = new FileReader()
61+
reader.onload = () => resolve(reader.result)
62+
reader.onerror = reject
63+
reader.readAsDataURL(file)
64+
})
65+
}
66+
67+
const form = useForm({})
68+
const inputKey = ref(uuidv4())
69+
const submitting = ref(false)
70+
const onSubmit = form.handleSubmit(async (values) => {
71+
submitting.value = true
72+
await $fetch('/api/files/newRecord', {
73+
method: 'POST',
74+
body: {
75+
clubId: Number.parseInt(props.club),
76+
collectionId: props.collection,
77+
fileContent: await readFileAsDataURL(values.file),
78+
rawName: values.file.name,
79+
},
80+
})
81+
form.resetForm()
82+
inputKey.value = uuidv4()
83+
await updateClub()
84+
submitting.value = false
85+
})
86+
87+
const msg = ref('')
88+
const currentClubData = ref(null)
89+
const clubUpdating = ref(false)
90+
async function updateClub() {
91+
if (!props.club) {
92+
msg.value = '请先选择一个社团'
93+
currentClubData.value = undefined
94+
return
95+
}
96+
clubUpdating.value = true
97+
const data = await $fetch('/api/files/clubRecords', {
98+
method: 'POST',
99+
body: {
100+
cludId: Number.parseInt(props.club),
101+
collection: props.collection,
102+
},
103+
})
104+
if (data && data.length !== 0) {
105+
msg.value = `最后提交于 ${dayjs(data[0].createdAt).fromNow()}`
106+
currentClubData.value = data[0]
107+
}
108+
else {
109+
msg.value = '尚未提交'
110+
currentClubData.value = undefined
111+
}
112+
clubUpdating.value = false
113+
}
114+
115+
const downloadLink = ref('')
116+
const downloadFilename = ref('')
117+
const dlink: Ref<HTMLElement | null> = ref(null)
118+
const downloading = ref(false)
119+
async function download() {
120+
if (currentClubData.value) {
121+
downloading.value = true
122+
const data = await $fetch('/api/files/download', {
123+
method: 'POST',
124+
body: {
125+
fileId: currentClubData.value.fileId,
126+
},
127+
})
128+
// const blob = new Blob([new Uint8Array(Array.from(atob(data), c => c.charCodeAt(0)))])
129+
// window.open(URL.createObjectURL(blob))
130+
// window.open(data)
131+
downloadLink.value = data.url
132+
downloadFilename.value = data.name
133+
dlink.value.click()
134+
downloading.value = false
135+
}
136+
downloadLink.value = ''
137+
downloadFilename.value = ''
138+
}
139+
140+
watch(
141+
() => props.club,
142+
async () => {
143+
await updateClub()
144+
},
145+
)
146+
147+
await updateClub()
148+
</script>
149+
150+
<template>
151+
<Card class="px-4 py-4">
152+
<div class="mb-5 text-xl font-bold">
153+
{{ title }}
154+
</div>
155+
<form class="inline-block" @submit="onSubmit">
156+
<FormField v-slot="{ componentField }" name="file">
157+
<FormItem>
158+
<FormControl>
159+
<Input
160+
v-bind="componentField"
161+
:key="inputKey" class="text-foreground"
162+
type="file"
163+
:accept="fileTypesAcceptAttr(filetypes)"
164+
/>
165+
</FormControl>
166+
<FormDescription>
167+
{{ fileTypesPrompt(filetypes) }}
168+
</FormDescription>
169+
<!-- <FormMessage /> -->
170+
</FormItem>
171+
</FormField>
172+
<div class="mt-2">
173+
<Button type="submit" variant="secondary" :disabled="!form.values.file || submitting || clubUpdating">
174+
上传
175+
</Button>
176+
<Button v-if="currentClubData" :disabled="downloading" variant="outline" class="ml-2" type="button" @click="download">
177+
下载
178+
</Button>
179+
</div>
180+
</form>
181+
<div v-if="submitting || clubUpdating" class="mt-2">
182+
<Skeleton class="h-5 w-full" />
183+
</div>
184+
<div v-else class="mt-2">
185+
{{ msg }}
186+
</div>
187+
<a ref="dlink" :href="downloadLink" :download="downloadFilename" class="hidden">Download</a>
188+
</Card>
189+
</template>

app/components/custom/sidebar.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const sidebarData = ref({
7171
},
7272
...(isPresidentOrVicePresident.value
7373
? [
74+
{
75+
title: '社团文件',
76+
url: '/forms/files',
77+
},
7478
{
7579
title: '活动记录',
7680
url: '#',

app/components/ui/input/Input.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import type { HTMLAttributes } from 'vue'
3-
import { useVModel } from '@vueuse/core'
43
import { cn } from '@/lib/utils'
4+
import { useVModel } from '@vueuse/core'
55
66
const props = defineProps<{
77
defaultValue?: string | number
@@ -20,5 +20,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
2020
</script>
2121

2222
<template>
23-
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class ?? '')">
23+
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
2424
</template>
25+
26+
<style scoped>
27+
input[type="file"]::file-selector-button {
28+
color: hsl(var(--foreground));
29+
}
30+
</style>

app/components/ui/input/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { default as Input } from '@/components/ui/input/Input.vue'
1+
export { default as Input } from './Input.vue'

app/pages/forms/files.vue

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script setup lang="ts">
2+
import type { FileCollection } from '@prisma/client'
3+
import type { AllClubs } from '~~/types/api/user/all_clubs'
4+
import ClubFileUpload from '@/components/custom/club-file-upload.vue'
5+
import Toaster from '@/components/ui/toast/Toaster.vue'
6+
import { useToast } from '@/components/ui/toast/use-toast'
7+
import { toTypedSchema } from '@vee-validate/zod'
8+
import { v4 as uuidv4 } from 'uuid'
9+
import { useForm } from 'vee-validate'
10+
import * as z from 'zod'
11+
12+
// ZOD!
13+
const formSchema = toTypedSchema(z.object({
14+
file: z
15+
.instanceof(FileList)
16+
.refine(file => file?.length === 1, 'File is required.'),
17+
}))
18+
19+
definePageMeta({
20+
middleware: ['auth'],
21+
})
22+
23+
useHead({
24+
title: 'Club Files | Enspire',
25+
})
26+
27+
const { toast } = useToast()
28+
29+
const { data: collectionsData, suspense: _s1 } = useQuery<FileCollection[]>({
30+
queryKey: ['/api/files/collections'],
31+
})
32+
await _s1() // suspense要await
33+
34+
const collectionLoaded = ref(false)
35+
if (collectionsData.value) {
36+
collectionLoaded.value = true
37+
}
38+
else {
39+
toast({
40+
title: '错误',
41+
description: '获取上传通道信息出错',
42+
})
43+
}
44+
45+
const { data: clubData, suspense: _s2 } = useQuery<AllClubs>({
46+
queryKey: ['/api/user/all_clubs'],
47+
})
48+
await _s2() // suspense要await
49+
50+
const clubLoaded = ref(false)
51+
if (clubData.value) {
52+
clubLoaded.value = true
53+
}
54+
else {
55+
toast({
56+
title: '错误',
57+
description: '获取社团信息出错',
58+
})
59+
}
60+
61+
const selectedClub = ref('')
62+
</script>
63+
64+
<template>
65+
<Select v-model="selectedClub">
66+
<SelectTrigger class="mb-4 w-full lg:w-72">
67+
<SelectValue placeholder="选择一个社团" />
68+
</SelectTrigger>
69+
<SelectContent>
70+
<SelectItem v-for="club in clubData.president" :key="club.id" :value="club.id">
71+
{{ club.name.zh }}
72+
</SelectItem>
73+
</SelectContent>
74+
</Select>
75+
<div v-if="!collectionLoaded">
76+
loading
77+
</div>
78+
<div v-if="collectionLoaded" class="grid grid-cols-1 gap-4 lg:grid-cols-3">
79+
<ClubFileUpload
80+
v-for="collection in collectionsData"
81+
:key="collection.id"
82+
:club="selectedClub"
83+
:collection="collection.id"
84+
:filetypes="collection.fileTypes"
85+
:title="collection.name"
86+
/>
87+
</div>
88+
<Toaster />
89+
</template>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-- CreateEnum
2+
CREATE TYPE "FormStatus" AS ENUM ('OPEN', 'CLOSED');
3+
4+
-- CreateTable
5+
CREATE TABLE "File" (
6+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
7+
"name" TEXT NOT NULL,
8+
"fileId" TEXT NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "FileUploadRecord" (
16+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
17+
"clubId" INTEGER NOT NULL,
18+
"fileId" TEXT NOT NULL,
19+
"fileUploadId" TEXT NOT NULL,
20+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21+
22+
CONSTRAINT "FileUploadRecord_pkey" PRIMARY KEY ("id")
23+
);
24+
25+
-- CreateTable
26+
CREATE TABLE "FileUpload" (
27+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
28+
"name" TEXT NOT NULL,
29+
"status" "FormStatus" NOT NULL,
30+
"fileNaming" TEXT NOT NULL,
31+
"fileTypes" TEXT[],
32+
33+
CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id")
34+
);
35+
36+
-- CreateIndex
37+
CREATE UNIQUE INDEX "FileUploadRecord_clubId_key" ON "FileUploadRecord"("clubId");
38+
39+
-- CreateIndex
40+
CREATE UNIQUE INDEX "FileUploadRecord_fileId_key" ON "FileUploadRecord"("fileId");
41+
42+
-- CreateIndex
43+
CREATE UNIQUE INDEX "FileUploadRecord_fileUploadId_key" ON "FileUploadRecord"("fileUploadId");
44+
45+
-- AddForeignKey
46+
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
47+
48+
-- AddForeignKey
49+
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
50+
51+
-- AddForeignKey
52+
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileUploadId_fkey" FOREIGN KEY ("fileUploadId") REFERENCES "FileUpload"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- DropIndex
2+
DROP INDEX "FileUploadRecord_clubId_key";
3+
4+
-- DropIndex
5+
DROP INDEX "FileUploadRecord_fileUploadId_key";

db/migrations/migration_lock.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
2-
# It should be added in your version-control system (i.e. Git)
2+
# It should be added in your version-control system (e.g., Git)
33
provider = "postgresql"

0 commit comments

Comments
 (0)