Skip to content

Commit f139fd8

Browse files
authored
Merge pull request #10 from NCTUCSUnion/dev
Dev
2 parents 5fc7c93 + f770522 commit f139fd8

8 files changed

Lines changed: 157 additions & 24 deletions

File tree

.github/workflows/main.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ jobs:
8181
ghcr.io/nctucsunion/pastexam:frontend-${{ github.sha }}
8282
build-args: |
8383
VITE_API_BASE_URL=${{ secrets.FRONTEND_API_BASE_URL }}
84+
VITE_UMAMI_SHARE_URL=${{ secrets.FRONTEND_UMAMI_SHARE_URL }}
85+
cache-from: type=registry,ref=ghcr.io/nctucsunion/pastexam:frontend-buildcache
86+
cache-to: type=registry,ref=ghcr.io/nctucsunion/pastexam:frontend-buildcache,mode=max
8487

8588
- name: Build and push Backend Docker image
8689
uses: docker/build-push-action@v4
@@ -92,3 +95,5 @@ jobs:
9295
tags: |
9396
ghcr.io/nctucsunion/pastexam:backend
9497
ghcr.io/nctucsunion/pastexam:backend-${{ github.sha }}
98+
cache-from: type=registry,ref=ghcr.io/nctucsunion/pastexam:backend-buildcache
99+
cache-to: type=registry,ref=ghcr.io/nctucsunion/pastexam:backend-buildcache,mode=max

backend/app/api/services/courses.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,15 @@ async def get_course_archives(
6363

6464
return archives
6565

66-
@router.get("/{course_id}/archives/{archive_id}/url")
67-
async def get_archive_url(
66+
@router.get("/{course_id}/archives/{archive_id}/preview")
67+
async def get_archive_preview_url(
6868
course_id: int,
6969
archive_id: int,
70-
is_download: bool = False,
7170
current_user: User = Depends(get_current_user),
7271
db: AsyncSession = Depends(get_session),
7372
):
7473
"""
75-
Get presigned URLs for downloading and previewing an archive
74+
Get presigned URL for previewing an archive (30 minutes expiry)
7675
"""
7776
query = select(Archive).where(
7877
Archive.course_id == course_id,
@@ -88,14 +87,41 @@ async def get_archive_url(
8887
detail="Archive not found"
8988
)
9089

91-
if is_download:
92-
archive.download_count += 1
93-
await db.commit()
94-
await db.refresh(archive)
95-
9690
return {
97-
"download_url": presigned_get_url(archive.object_name, expires=timedelta(hours=1)),
98-
"preview_url": presigned_get_url(archive.object_name, expires=timedelta(minutes=30))
91+
"url": presigned_get_url(archive.object_name, expires=timedelta(minutes=30))
92+
}
93+
94+
@router.get("/{course_id}/archives/{archive_id}/download")
95+
async def get_archive_download_url(
96+
course_id: int,
97+
archive_id: int,
98+
current_user: User = Depends(get_current_user),
99+
db: AsyncSession = Depends(get_session),
100+
):
101+
"""
102+
Get presigned URL for downloading an archive (1 hour expiry)
103+
This endpoint increments the download count
104+
"""
105+
query = select(Archive).where(
106+
Archive.course_id == course_id,
107+
Archive.id == archive_id,
108+
Archive.deleted_at.is_(None)
109+
)
110+
result = await db.execute(query)
111+
archive = result.scalar_one_or_none()
112+
113+
if not archive:
114+
raise HTTPException(
115+
status_code=status.HTTP_404_NOT_FOUND,
116+
detail="Archive not found"
117+
)
118+
119+
archive.download_count += 1
120+
await db.commit()
121+
await db.refresh(archive)
122+
123+
return {
124+
"url": presigned_get_url(archive.object_name, expires=timedelta(hours=1))
99125
}
100126

101127
@router.patch("/{course_id}/archives/{archive_id}")

frontend/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
VITE_API_BASE_URL=
1+
VITE_API_BASE_URL=
2+
VITE_UMAMI_SHARE_URL=

frontend/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ WORKDIR /app
44
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
55

66
ARG VITE_API_BASE_URL
7+
ARG VITE_UMAMI_SHARE_URL
78
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
9+
ENV VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL}
810

911
COPY package.json pnpm-lock.yaml ./
1012

@@ -13,6 +15,7 @@ RUN pnpm install --frozen-lockfile
1315
COPY . .
1416

1517
RUN echo "VITE_API_BASE_URL=${VITE_API_BASE_URL}" > .env
18+
RUN echo "VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL}" >> .env
1619
RUN pnpm run build
1720

1821
FROM nginx:alpine

frontend/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848

4949
<!-- Canonical URL -->
5050
<link rel="canonical" href="https://pastexam.nctucsunion.me/" />
51+
<script
52+
defer
53+
src="https://cloud.umami.is/script.js"
54+
data-website-id="8089a20a-ba6a-4bf5-b629-8a82c03c134e"
55+
></script>
5156
<script>
5257
function updateFavicon(isDark) {
5358
const theme = isDark ? 'dark' : 'bright'

frontend/src/api/services/archives.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ export const archiveService = {
99
})
1010
},
1111

12-
getArchiveUrl(courseId, archiveId, isDownload = false) {
13-
return api.get(`/courses/${courseId}/archives/${archiveId}/url`, {
14-
params: { is_download: isDownload },
15-
})
12+
getArchivePreviewUrl(courseId, archiveId) {
13+
return api.get(`/courses/${courseId}/archives/${archiveId}/preview`)
14+
},
15+
16+
getArchiveDownloadUrl(courseId, archiveId) {
17+
return api.get(`/courses/${courseId}/archives/${archiveId}/download`)
1618
},
1719

1820
deleteArchive(courseId, archiveId) {

frontend/src/views/Admin.vue

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<TabList>
66
<Tab value="0">課程管理</Tab>
77
<Tab value="1">使用者管理</Tab>
8+
<Tab value="2">網站分析</Tab>
89
</TabList>
910
<TabPanels>
1011
<TabPanel value="0">
@@ -185,6 +186,53 @@
185186
</DataTable>
186187
</div>
187188
</TabPanel>
189+
190+
<TabPanel value="2">
191+
<div class="p-2 md:p-4 h-full">
192+
<div class="flex flex-column gap-3 h-full">
193+
<div class="flex justify-content-end align-items-center mb-2">
194+
<Button
195+
v-if="umamiShareUrl"
196+
label="在新視窗開啟"
197+
icon="pi pi-external-link"
198+
severity="secondary"
199+
size="small"
200+
@click="openUmamiInNewTab"
201+
/>
202+
</div>
203+
204+
<!-- Loading state -->
205+
<div
206+
v-if="umamiLoading"
207+
class="flex-1 flex align-items-center justify-content-center"
208+
>
209+
<ProgressSpinner strokeWidth="4" />
210+
</div>
211+
212+
<!-- Error state: No URL configured -->
213+
<div
214+
v-else-if="!umamiShareUrl"
215+
class="flex-1 flex flex-column align-items-center justify-content-center gap-4"
216+
>
217+
<i class="pi pi-exclamation-circle text-6xl text-red-500" />
218+
<div class="text-xl">無法載入網站分析</div>
219+
<div class="text-sm text-500">請聯絡管理員設定 Umami</div>
220+
</div>
221+
222+
<!-- Success state: Show iframe -->
223+
<div v-else class="umami-iframe-container">
224+
<iframe
225+
:src="umamiShareUrl"
226+
frameborder="0"
227+
class="umami-iframe"
228+
title="Umami Analytics"
229+
@load="handleUmamiLoaded"
230+
@error="handleUmamiError"
231+
></iframe>
232+
</div>
233+
</div>
234+
</div>
235+
</TabPanel>
188236
</TabPanels>
189237
</Tabs>
190238

@@ -383,6 +431,29 @@ const userFormErrors = ref({})
383431
384432
const currentUserId = computed(() => getCurrentUser()?.id)
385433
434+
const umamiShareUrl = ref(import.meta.env.VITE_UMAMI_SHARE_URL || '')
435+
const umamiLoading = ref(false)
436+
437+
const openUmamiInNewTab = () => {
438+
if (umamiShareUrl.value) {
439+
window.open(umamiShareUrl.value, '_blank')
440+
}
441+
}
442+
443+
const handleUmamiLoaded = () => {
444+
umamiLoading.value = false
445+
}
446+
447+
const handleUmamiError = () => {
448+
umamiLoading.value = false
449+
toast.add({
450+
severity: 'error',
451+
summary: '錯誤',
452+
detail: '無法載入 Umami 分析頁面',
453+
life: 3000,
454+
})
455+
}
456+
386457
const categoryOptions = [
387458
{ name: '大一課程', value: 'freshman' },
388459
{ name: '大二課程', value: 'sophomore' },
@@ -873,8 +944,29 @@ onMounted(() => {
873944
flex-wrap: wrap;
874945
}
875946
947+
.umami-iframe-container {
948+
width: 100%;
949+
height: calc(100vh - var(--navbar-height) - 200px);
950+
min-height: 600px;
951+
border: 1px solid var(--border-color);
952+
border-radius: 8px;
953+
overflow: hidden;
954+
background: var(--bg-primary);
955+
}
956+
957+
.umami-iframe {
958+
width: 100%;
959+
height: 100%;
960+
border: none;
961+
}
962+
876963
/* Mobile responsive adjustments */
877964
@media (max-width: 768px) {
965+
.umami-iframe-container {
966+
height: calc(100vh - var(--navbar-height) - 200px);
967+
min-height: 400px;
968+
}
969+
878970
:deep(.p-dialog .p-dialog-content) {
879971
font-size: 0.875rem;
880972
}

frontend/src/views/Archive.vue

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -813,9 +813,9 @@ const downloadingId = ref(null)
813813
async function downloadArchive(archive) {
814814
try {
815815
downloadingId.value = archive.id
816-
const { data } = await archiveService.getArchiveUrl(selectedCourse.value, archive.id, true)
816+
const { data } = await archiveService.getArchiveDownloadUrl(selectedCourse.value, archive.id)
817817
818-
const response = await fetch(data.download_url)
818+
const response = await fetch(data.url)
819819
const blob = await response.blob()
820820
const url = window.URL.createObjectURL(blob)
821821
const link = document.createElement('a')
@@ -860,11 +860,11 @@ async function previewArchive(archive) {
860860
previewError.value = false
861861
showPreview.value = true
862862
863-
const { data } = await archiveService.getArchiveUrl(selectedCourse.value, archive.id)
863+
const { data } = await archiveService.getArchivePreviewUrl(selectedCourse.value, archive.id)
864864
865865
selectedArchive.value = {
866866
...archive,
867-
previewUrl: data.preview_url,
867+
previewUrl: data.url,
868868
}
869869
} catch (error) {
870870
console.error('Preview error:', error)
@@ -1173,13 +1173,12 @@ async function handlePreviewDownload(onComplete) {
11731173
if (!selectedArchive.value) return
11741174
11751175
try {
1176-
const { data } = await archiveService.getArchiveUrl(
1176+
const { data } = await archiveService.getArchiveDownloadUrl(
11771177
selectedCourse.value,
1178-
selectedArchive.value.id,
1179-
true
1178+
selectedArchive.value.id
11801179
)
11811180
1182-
const response = await fetch(data.download_url)
1181+
const response = await fetch(data.url)
11831182
const blob = await response.blob()
11841183
const url = window.URL.createObjectURL(blob)
11851184
const link = document.createElement('a')

0 commit comments

Comments
 (0)