Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-sheep-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"enspire": minor
---

Added club file uploading portal
189 changes: 189 additions & 0 deletions app/components/custom/club-file-upload.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script setup lang="ts">
import type { FileCollection } from '@prisma/client'
import type { AllClubs } from '~~/types/api/user/all_clubs'
import Toaster from '@/components/ui/toast/Toaster.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import { toTypedSchema } from '@vee-validate/zod'
import dayjs from 'dayjs'
import { v4 as uuidv4 } from 'uuid'
import { useForm } from 'vee-validate'
import * as z from 'zod'

const props = defineProps({
club: {
type: String,
required: true,
},
collection: {
type: String,
required: true,
},
filetypes: {
type: Array<string>,
required: true,
},
title: {
type: String,
required: true,
},
})

definePageMeta({
middleware: ['auth'],
})

function fileTypesPrompt(fileTypes: string[]) {
if (fileTypes.length === 0 || fileTypes.includes('*')) {
return '无文件类型限制'
}
else {
return `上传类型为 ${fileTypes.join(', ').toUpperCase()} 的文件`
}
}
function fileTypesAcceptAttr(fileTypes: string[]) {
if (fileTypes.length === 0 || fileTypes.includes('*')) {
return '*'
}
else {
return fileTypes.map(type => `.${type}`).join(',')
}
}

// Still seems to be buggy
// const formSchema = toTypedSchema(z.object({
// file: z.custom(v => v, 'File missing'),
// }))

// 滚一边去
function readFileAsDataURL(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}

const form = useForm({})
const inputKey = ref(uuidv4())
const submitting = ref(false)
const onSubmit = form.handleSubmit(async (values) => {
submitting.value = true
await $fetch('/api/files/newRecord', {
method: 'POST',
body: {
clubId: Number.parseInt(props.club),
collectionId: props.collection,
fileContent: await readFileAsDataURL(values.file),
rawName: values.file.name,
},
})
form.resetForm()
inputKey.value = uuidv4()
await updateClub()
submitting.value = false
})

const msg = ref('')
const currentClubData = ref(null)
const clubUpdating = ref(false)
async function updateClub() {
if (!props.club) {
msg.value = '请先选择一个社团'
currentClubData.value = undefined
return
}
clubUpdating.value = true
const data = await $fetch('/api/files/clubRecords', {
method: 'POST',
body: {
cludId: Number.parseInt(props.club),
collection: props.collection,
},
})
if (data && data.length !== 0) {
msg.value = `最后提交于 ${dayjs(data[0].createdAt).fromNow()}`
currentClubData.value = data[0]
}
else {
msg.value = '尚未提交'
currentClubData.value = undefined
}
clubUpdating.value = false
}

const downloadLink = ref('')
const downloadFilename = ref('')
const dlink: Ref<HTMLElement | null> = ref(null)
const downloading = ref(false)
async function download() {
if (currentClubData.value) {
downloading.value = true
const data = await $fetch('/api/files/download', {
method: 'POST',
body: {
fileId: currentClubData.value.fileId,
},
})
// const blob = new Blob([new Uint8Array(Array.from(atob(data), c => c.charCodeAt(0)))])
// window.open(URL.createObjectURL(blob))
// window.open(data)
downloadLink.value = data.url
downloadFilename.value = data.name
dlink.value.click()
downloading.value = false
}
downloadLink.value = ''
downloadFilename.value = ''
}

watch(
() => props.club,
async () => {
await updateClub()
},
)

await updateClub()
</script>

<template>
<Card class="px-4 py-4">
<div class="mb-5 text-xl font-bold">
{{ title }}
</div>
<form class="inline-block" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="file">
<FormItem>
<FormControl>
<Input
v-bind="componentField"
:key="inputKey" class="text-foreground"
type="file"
:accept="fileTypesAcceptAttr(filetypes)"
/>
</FormControl>
<FormDescription>
{{ fileTypesPrompt(filetypes) }}
</FormDescription>
<!-- <FormMessage /> -->
</FormItem>
</FormField>
<div class="mt-2">
<Button type="submit" variant="secondary" :disabled="!form.values.file || submitting || clubUpdating">
上传
</Button>
<Button v-if="currentClubData" :disabled="downloading" variant="outline" class="ml-2" type="button" @click="download">
下载
</Button>
</div>
</form>
<div v-if="submitting || clubUpdating" class="mt-2">
<Skeleton class="h-5 w-full" />
</div>
<div v-else class="mt-2">
{{ msg }}
</div>
<a ref="dlink" :href="downloadLink" :download="downloadFilename" class="hidden">Download</a>
</Card>
</template>
4 changes: 4 additions & 0 deletions app/components/custom/sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const sidebarData = ref({
},
...(isPresidentOrVicePresident.value
? [
{
title: '社团文件',
url: '/forms/files',
},
{
title: '活动记录',
url: '#',
Expand Down
10 changes: 8 additions & 2 deletions app/components/ui/input/Input.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core'

const props = defineProps<{
defaultValue?: string | number
Expand All @@ -20,5 +20,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
</script>

<template>
<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 ?? '')">
<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)">
</template>

<style scoped>
input[type="file"]::file-selector-button {
color: hsl(var(--foreground));
}
</style>
2 changes: 1 addition & 1 deletion app/components/ui/input/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default as Input } from '@/components/ui/input/Input.vue'
export { default as Input } from './Input.vue'
89 changes: 89 additions & 0 deletions app/pages/forms/files.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { FileCollection } from '@prisma/client'
import type { AllClubs } from '~~/types/api/user/all_clubs'
import ClubFileUpload from '@/components/custom/club-file-upload.vue'
import Toaster from '@/components/ui/toast/Toaster.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import { toTypedSchema } from '@vee-validate/zod'
import { v4 as uuidv4 } from 'uuid'
import { useForm } from 'vee-validate'
import * as z from 'zod'

// ZOD!
const formSchema = toTypedSchema(z.object({
file: z
.instanceof(FileList)
.refine(file => file?.length === 1, 'File is required.'),
}))

definePageMeta({
middleware: ['auth'],
})

useHead({
title: 'Club Files | Enspire',
})

const { toast } = useToast()

const { data: collectionsData, suspense: _s1 } = useQuery<FileCollection[]>({
queryKey: ['/api/files/collections'],
})
await _s1() // suspense要await

const collectionLoaded = ref(false)
if (collectionsData.value) {
collectionLoaded.value = true
}
else {
toast({
title: '错误',
description: '获取上传通道信息出错',
})
}

const { data: clubData, suspense: _s2 } = useQuery<AllClubs>({
queryKey: ['/api/user/all_clubs'],
})
await _s2() // suspense要await

const clubLoaded = ref(false)
if (clubData.value) {
clubLoaded.value = true
}
else {
toast({
title: '错误',
description: '获取社团信息出错',
})
}

const selectedClub = ref('')
</script>

<template>
<Select v-model="selectedClub">
<SelectTrigger class="mb-4 w-full lg:w-72">
<SelectValue placeholder="选择一个社团" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="club in clubData.president" :key="club.id" :value="club.id">
{{ club.name.zh }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="!collectionLoaded">
loading
</div>
<div v-if="collectionLoaded" class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ClubFileUpload
v-for="collection in collectionsData"
:key="collection.id"
:club="selectedClub"
:collection="collection.id"
:filetypes="collection.fileTypes"
:title="collection.name"
/>
</div>
<Toaster />
</template>
52 changes: 52 additions & 0 deletions db/migrations/20241226133450_file_upload/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- CreateEnum
CREATE TYPE "FormStatus" AS ENUM ('OPEN', 'CLOSED');

-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "FileUploadRecord" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"clubId" INTEGER NOT NULL,
"fileId" TEXT NOT NULL,
"fileUploadId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "FileUploadRecord_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "FileUpload" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"status" "FormStatus" NOT NULL,
"fileNaming" TEXT NOT NULL,
"fileTypes" TEXT[],

CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_clubId_key" ON "FileUploadRecord"("clubId");

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_fileId_key" ON "FileUploadRecord"("fileId");

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_fileUploadId_key" ON "FileUploadRecord"("fileUploadId");

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileUploadId_fkey" FOREIGN KEY ("fileUploadId") REFERENCES "FileUpload"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
5 changes: 5 additions & 0 deletions db/migrations/20241228072009_fix_relation_files/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "FileUploadRecord_clubId_key";

-- DropIndex
DROP INDEX "FileUploadRecord_fileUploadId_key";
2 changes: 1 addition & 1 deletion db/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
Loading
Loading