mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
Merge branch 'main' into quest
This commit is contained in:
18 files changed
+315
-29
No files matched your search
@@ -1,5 +1,8 @@
|
|||||||
# Linux Odyssey
|
# Linux Odyssey
|
||||||
|
|
||||||
|
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
||||||
|
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- NodeJS
|
- NodeJS
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.4",
|
"vue-router": "^4.2.4",
|
||||||
"vue-toastification": "^2.0.0-rc.5",
|
"vue-toastification": "^2.0.0-rc.5",
|
||||||
|
"vue3-lottie": "^3.2.0",
|
||||||
"xterm": "^5.2.1",
|
"xterm": "^5.2.1",
|
||||||
"xterm-addon-attach": "^0.8.0",
|
"xterm-addon-attach": "^0.8.0",
|
||||||
"xterm-addon-fit": "^0.7.0"
|
"xterm-addon-fit": "^0.7.0"
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { surveyUrl } from '../config'
|
import { Vue3Lottie } from 'vue3-lottie'
|
||||||
|
// import { surveyUrl } from '../config'
|
||||||
|
import CompleteAnimation from '../img/complete-animation.json'
|
||||||
|
|
||||||
const showModal = ref(true) // Controls whether the modal is visible or not
|
const showModal = ref(true) // Controls whether the modal is visible or not
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
}
|
}
|
||||||
|
const defineComponent = () => ({
|
||||||
|
components: {
|
||||||
|
Vue3Lottie,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
CompleteAnimation,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const backtoMap = async () => {
|
||||||
|
try {
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
} catch (err) {
|
||||||
|
// useToast().error('Failed to load map')
|
||||||
|
toast.error('無法讀取地圖')
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate completing a quest to show the modal
|
// Simulate completing a quest to show the modal
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -18,6 +42,7 @@ setTimeout(() => {
|
|||||||
<div v-if="showModal" class="modal">
|
<div v-if="showModal" class="modal">
|
||||||
<div class="modal-content bg-bg-secondary rounded-lg">
|
<div class="modal-content bg-bg-secondary rounded-lg">
|
||||||
<span class="close text-text" @click="closeModal">×</span>
|
<span class="close text-text" @click="closeModal">×</span>
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
id="QuestCompleted"
|
id="QuestCompleted"
|
||||||
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||||
@@ -25,16 +50,34 @@ setTimeout(() => {
|
|||||||
<!-- Quest Completed! -->
|
<!-- Quest Completed! -->
|
||||||
關卡完成!
|
關卡完成!
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-base text-text">
|
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
|
||||||
|
<Vue3Lottie
|
||||||
|
:animationData="CompleteAnimation"
|
||||||
|
:height="200"
|
||||||
|
:width="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-base text-text text-center">恭喜你完成這一關!</p>
|
||||||
|
<p class="text-base text-text text-center">
|
||||||
|
接下來回到地圖,迎接新的挑戰吧!
|
||||||
|
</p>
|
||||||
|
<!-- <p class="text-base text-text">
|
||||||
您已成功完成挑戰,真的非常感謝您的參與!<br />
|
您已成功完成挑戰,真的非常感謝您的參與!<br />
|
||||||
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
|
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
|
||||||
</p>
|
</p> -->
|
||||||
<a
|
<!-- <a
|
||||||
id="SurveyButton"
|
id="SurveyButton"
|
||||||
:href="surveyUrl"
|
:href="surveyUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
|
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
|
||||||
>填寫問卷</a
|
>填寫問卷</a
|
||||||
|
> -->
|
||||||
|
<a
|
||||||
|
id="BacktoMap"
|
||||||
|
target="_blank"
|
||||||
|
@click="backtoMap"
|
||||||
|
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
|
||||||
|
>回到地圖</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { defineProps, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { logout } from '../utils/auth'
|
import { logout } from '../utils/auth'
|
||||||
import { bugReportUrl } from '../config'
|
import { bugReportUrl } from '../config'
|
||||||
import sessionStore, { reset } from '../store/session'
|
import { reset } from '../store/session'
|
||||||
|
import userProfileStore, {
|
||||||
|
loadUserProfile,
|
||||||
|
resetUserProfile,
|
||||||
|
} from '../store/userProfile'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -12,6 +17,7 @@ const toast = useToast()
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await logout()
|
await logout()
|
||||||
|
resetUserProfile()
|
||||||
reset()
|
reset()
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -29,6 +35,22 @@ const handleMap = async () => {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadUserProfile()
|
||||||
|
} catch (err) {
|
||||||
|
// toast.error('Failed to load user profile')
|
||||||
|
toast.error('無法讀取使用者資料')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,13 +69,19 @@ const handleMap = async () => {
|
|||||||
class="text-border h-full w-full"
|
class="text-border h-full w-full"
|
||||||
/>
|
/>
|
||||||
</button> -->
|
</button> -->
|
||||||
<p
|
<button
|
||||||
id="HeaderText"
|
id="HeaderText"
|
||||||
class="text-text-primary inline-block font-bold whitespace-nowrap"
|
class="text-text-primary inline-block font-bold whitespace-nowrap"
|
||||||
style="font-size: 2vh"
|
style="font-size: 2vh"
|
||||||
v-if="sessionStore.quest"
|
|
||||||
>
|
>
|
||||||
{{ sessionStore.quest.title }}
|
Linux Odyssey
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
id="HeaderText"
|
||||||
|
class="text-text inline-block font-bold whitespace-nowrap"
|
||||||
|
style="font-size: 2vh"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full w-full flex gap-3 items-center justify-end">
|
<div class="h-full w-full flex gap-3 items-center justify-end">
|
||||||
@@ -75,6 +103,13 @@ const handleMap = async () => {
|
|||||||
class="text-text-primary h-full w-full"
|
class="text-text-primary h-full w-full"
|
||||||
/>
|
/>
|
||||||
</button> -->
|
</button> -->
|
||||||
|
<p
|
||||||
|
id="UsernameText"
|
||||||
|
class="text-text inline-block whitespace-nowrap"
|
||||||
|
style="font-size: 2vh"
|
||||||
|
>
|
||||||
|
{{ userProfileStore.username }}
|
||||||
|
</p>
|
||||||
<button title="Map" @click="handleMap" class="h-5 w-5">
|
<button title="Map" @click="handleMap" class="h-5 w-5">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'map']"
|
:icon="['fas', 'map']"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- eslint-disable no-console -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -34,6 +35,7 @@ async function getQuests() {
|
|||||||
return res.data
|
return res.data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,9 +95,15 @@ const genOption = (nodes, edges) => ({
|
|||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: (params) => {
|
color: (params) => {
|
||||||
const {
|
const {
|
||||||
data: { completed },
|
data: { completed, unlocked },
|
||||||
} = params
|
} = params
|
||||||
return completed ? '#ADADB5' : '#454552'
|
if (completed) {
|
||||||
|
return '#00ff00'
|
||||||
|
}
|
||||||
|
if (unlocked) {
|
||||||
|
return '#ADADB5'
|
||||||
|
}
|
||||||
|
return '454552'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":"5.5.9","fr":60,"ip":0,"op":90,"w":640,"h":640,"nm":"Lottie_petard","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 1","parent":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[320,320,0],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[358.25,238.75],[313.75,278],[456.813,424.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":3},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.47,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[318,312,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.47,"y":1},"o":{"x":0.167,"y":0.987},"t":6.5,"s":[328,316,0],"to":[0,0,0],"ti":[0,0,0]},{"t":45,"s":[318,312,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[272.458,139.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[207.458,186.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[274.458,236.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[224.458,267.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[326.458,202.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[314.458,272.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 4","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[319.25,320.25,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,104.369,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-27.614,0],[0,-27.614],[27.568,-1.605],[0,27.614]],"o":[[27.614,0],[0,27.614],[-26.718,1.555],[0,-27.614]],"v":[[-9,-26.75],[41,23.25],[-11.439,70.948],[-59,23.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.949020026712,0.886274988511,0.019608000213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9,0.840928201114,0.008999999364,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8,-36.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[89.096,20.801],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60Line truncated
|
||||||
@@ -5,6 +5,8 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
|||||||
import './library'
|
import './library'
|
||||||
/* import Vue Toastification */
|
/* import Vue Toastification */
|
||||||
import Toast, { POSITION } from 'vue-toastification'
|
import Toast, { POSITION } from 'vue-toastification'
|
||||||
|
/* import Vue Lottie */
|
||||||
|
import Vue3Lottie from 'vue3-lottie'
|
||||||
import 'vue-toastification/dist/index.css'
|
import 'vue-toastification/dist/index.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -22,4 +24,6 @@ const options = {
|
|||||||
|
|
||||||
app.use(Toast, options)
|
app.use(Toast, options)
|
||||||
|
|
||||||
|
app.use(Vue3Lottie, { name: 'Vue3Lottie' })
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
@@ -4,6 +4,7 @@ import LoginPage from './views/LoginPage.vue'
|
|||||||
import RegisterPage from './views/RegisterPage.vue'
|
import RegisterPage from './views/RegisterPage.vue'
|
||||||
import ChooseUsernamePage from './views/ChooseUsernamePage.vue'
|
import ChooseUsernamePage from './views/ChooseUsernamePage.vue'
|
||||||
import QuestMapPage from './views/QuestMapPage.vue'
|
import QuestMapPage from './views/QuestMapPage.vue'
|
||||||
|
import LeaderboardPage from './views/LeaderboardPage.vue'
|
||||||
import { isLoggedIn } from './utils/auth'
|
import { isLoggedIn } from './utils/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -45,6 +46,11 @@ const routes = [
|
|||||||
component: QuestMapPage,
|
component: QuestMapPage,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/leaderboard',
|
||||||
|
name: 'leaderboard',
|
||||||
|
component: LeaderboardPage,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
const store = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
progress: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function loadUserProfile() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/users/me')
|
||||||
|
const { username, email, progress } = res.data
|
||||||
|
store.username = username
|
||||||
|
store.email = email
|
||||||
|
store.progress = progress
|
||||||
|
return store
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
throw new Error(`Failed to load user profile: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUserProfile() {
|
||||||
|
store.username = ''
|
||||||
|
store.email = ''
|
||||||
|
store.progress = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default store
|
||||||
@@ -50,7 +50,7 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<!-- game header -->
|
<!-- game header -->
|
||||||
<div class="h-[6vh] w-full">
|
<div class="h-[6vh] w-full">
|
||||||
<GameHeaderPart />
|
<GameHeaderPart :title="sessionStore.quest?.title" />
|
||||||
</div>
|
</div>
|
||||||
<!-- current status indicator -->
|
<!-- current status indicator -->
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||||
|
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
const leaderboard = ref([])
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
async function getLeaderboard() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/leaderboard')
|
||||||
|
leaderboard.value = res.data
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
toast.error('讀取排行榜失敗')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getLeaderboard()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-screen h-screen bg-bg">
|
||||||
|
<DynamicBackground class="w-full h-full" />
|
||||||
|
<div
|
||||||
|
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
|
||||||
|
>
|
||||||
|
<div class="h-[6vh] w-full">
|
||||||
|
<GameHeaderPart title="排行榜" />
|
||||||
|
</div>
|
||||||
|
<div class="w-fit h-[94vh] m-3">
|
||||||
|
<div
|
||||||
|
class="h-full bg-bg border-8 border-bg-primary flex flex-col items-center rounded-3xl p-10"
|
||||||
|
>
|
||||||
|
<h1 class="text-text-primary text-3xl mb-5">排行榜</h1>
|
||||||
|
<div class="max-h-300 overflow-auto">
|
||||||
|
<table class="w-full text-sm text-left text-text">
|
||||||
|
<thead
|
||||||
|
class="text-xs text-text-secondary uppercase bg-bg-secondary"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3">使用者名稱</th>
|
||||||
|
<th scope="col" class="px-6 py-3">分數</th>
|
||||||
|
<th scope="col" class="px-6 py-3">完成關卡</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="user in leaderboard"
|
||||||
|
:key="user.username"
|
||||||
|
class="border-b bg-bg"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
class="px-6 py-4 font-medium text-text-primary whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ user.username }}
|
||||||
|
</th>
|
||||||
|
<td class="px-6 py-4">{{ user.score }}</td>
|
||||||
|
<td class="px-6 py-4 text-text-secondary">
|
||||||
|
<span
|
||||||
|
class="mr-2"
|
||||||
|
v-for="quest in user.completedQuests"
|
||||||
|
:key="quest"
|
||||||
|
>
|
||||||
|
{{ quest }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,14 +5,14 @@ import GameHeaderPart from '../components/GameHeaderPart.vue'
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-[6vh] w-full">
|
<div class="h-[6vh] w-full">
|
||||||
<GameHeaderPart />
|
<GameHeaderPart title="關卡地圖" />
|
||||||
<p
|
<!-- <p
|
||||||
class="text-text-primary absolute z-1 font-bold inline-block tracking-widest"
|
class="text-text-primary absolute z-1 font-bold inline-block tracking-widest"
|
||||||
style="left: 1.2%; top: 1.5%; height: 100%; font-size: 2vh"
|
style="left: 1.2%; top: 1.5%; height: 100%; font-size: 2vh"
|
||||||
>
|
>
|
||||||
<!-- Quest Map -->
|
Quest Map
|
||||||
Unix編年史
|
Unix編年史
|
||||||
</p>
|
</p> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
|
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
|
||||||
<QuestMap />
|
<QuestMap />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { asyncHandler } from '../../middleware/error.js'
|
||||||
|
import { leaderboard } from '../../models/userManager.js'
|
||||||
|
|
||||||
|
export default asyncHandler(async (req, res) => {
|
||||||
|
const users = await leaderboard()
|
||||||
|
const response = users.map((user) => ({
|
||||||
|
username: user.user.username,
|
||||||
|
completedQuests: user.completedQuests,
|
||||||
|
score: user.score * 1000,
|
||||||
|
}))
|
||||||
|
res.json(response)
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import authRouter from './authRouter.js'
|
|||||||
import authRequired from '../../middleware/authRequired.js'
|
import authRequired from '../../middleware/authRequired.js'
|
||||||
import userRouter from './userRouter.js'
|
import userRouter from './userRouter.js'
|
||||||
import config from '../../config.js'
|
import config from '../../config.js'
|
||||||
|
import leaderboardController from '../controllers/leaderboardController.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -48,4 +49,6 @@ router.get('/bug-report', (req, res) => {
|
|||||||
res.redirect(config.bugReportUrl)
|
res.redirect(config.bugReportUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/leaderboard', leaderboardController)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -22,3 +22,46 @@ export async function createUser(username, email, { password, socialLogin }) {
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function leaderboard() {
|
||||||
|
return UserProfile.aggregate([
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
user: true, // Include the user field in the projection
|
||||||
|
completedQuests: {
|
||||||
|
$map: {
|
||||||
|
input: {
|
||||||
|
$filter: {
|
||||||
|
input: { $objectToArray: '$progress' }, // Convert the progress map to an array
|
||||||
|
as: 'prog',
|
||||||
|
cond: { $eq: ['$$prog.v.completed', true] }, // Filter for completed quests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
as: 'completedProg',
|
||||||
|
in: '$$completedProg.k', // Extract the quest IDs
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
score: { $size: '$completedQuests' }, // Calculate the size of the completedQuests array
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
score: { $gt: 0 }, // Filter out users who haven't completed any quests
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { score: -1 } },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users',
|
||||||
|
localField: 'user',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'user',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$user' },
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ describe('example helloworld app', () => {
|
|||||||
cy.PrepareForGame()
|
cy.PrepareForGame()
|
||||||
})
|
})
|
||||||
it('Check Header', () => {
|
it('Check Header', () => {
|
||||||
cy.get('#HeaderText').should('contain', title).and('be.visible')
|
cy.get('#HeaderText').should('contain', 'Linux Odyssey').and('be.visible')
|
||||||
cy.findByRole('link', { name: 'Bug Report' }).should('be.visible')
|
cy.findByRole('link', { name: 'Bug Report' }).should('be.visible')
|
||||||
cy.findByRole('button', { name: 'Sign Out' }).should('be.visible')
|
cy.findByRole('button', { name: 'Sign Out' }).should('be.visible')
|
||||||
})
|
})
|
||||||
@@ -123,8 +123,8 @@ describe('example helloworld app', () => {
|
|||||||
it('Typing in Terminal', () => {
|
it('Typing in Terminal', () => {
|
||||||
cy.typeInCommand('12345{enter}')
|
cy.typeInCommand('12345{enter}')
|
||||||
cy.get('@Terminaltextbox')
|
cy.get('@Terminaltextbox')
|
||||||
.should('contain', 'zsh: command not found: 12345')
|
.should('contain', '12345')
|
||||||
.and('contain', '12345')
|
.and('contain', 'zsh: command not found: 12345', { timeout: 50000 })
|
||||||
})
|
})
|
||||||
it('Complete the Game(relating UI)', () => {
|
it('Complete the Game(relating UI)', () => {
|
||||||
// Stage1
|
// Stage1
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ Cypress.Commands.add('LoginWithPassword', (username, password) => {
|
|||||||
})
|
})
|
||||||
Cypress.Commands.add('PrepareForGame', () => {
|
Cypress.Commands.add('PrepareForGame', () => {
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
|
// make sure the login page is loaded
|
||||||
|
cy.url().should('satisfy', (elements) => {
|
||||||
|
const text = elements
|
||||||
|
return text.includes('login') || text.includes('register')
|
||||||
|
})
|
||||||
cy.LoginWithPassword(
|
cy.LoginWithPassword(
|
||||||
Cypress.env('defaultAccount'),
|
Cypress.env('defaultAccount'),
|
||||||
Cypress.env('defaultPassword')
|
Cypress.env('defaultPassword')
|
||||||
)
|
)
|
||||||
|
// make sure the map page is loaded
|
||||||
cy.url().should('include', '/map')
|
cy.url().should('include', '/map')
|
||||||
cy.visit('/game/get-started')
|
cy.visit('/game/get-started')
|
||||||
|
cy.url().should('include', '/game/get-started')
|
||||||
cy.get('.xterm-screen', { timeout: 10000 })
|
cy.get('.xterm-screen', { timeout: 10000 })
|
||||||
.as('Terminaltextbox')
|
.as('Terminaltextbox')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
@@ -24,10 +31,6 @@ Cypress.Commands.add('PrepareForGame', () => {
|
|||||||
'contain',
|
'contain',
|
||||||
'commander:~ $'
|
'commander:~ $'
|
||||||
)
|
)
|
||||||
cy.get('@Terminaltextbox', { timeout: 150000 }).should(
|
|
||||||
'contain',
|
|
||||||
'commander:~ $'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
Cypress.Commands.add('typeInCommand', (command) => {
|
Cypress.Commands.add('typeInCommand', (command) => {
|
||||||
cy.get('.xterm-screen', { timeout: 150000 }).type(command)
|
cy.get('.xterm-screen', { timeout: 150000 }).type(command)
|
||||||
|
|||||||
@@ -1500,12 +1500,7 @@ astral-regex@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
|
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
|
||||||
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
|
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
|
||||||
|
|
||||||
async@^3.2.0:
|
async@^3.2.0, async@^3.2.3:
|
||||||
version "3.2.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66"
|
|
||||||
integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==
|
|
||||||
|
|
||||||
async@^3.2.3:
|
|
||||||
version "3.2.5"
|
version "3.2.5"
|
||||||
resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66"
|
resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66"
|
||||||
integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==
|
integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==
|
||||||
@@ -4556,6 +4551,11 @@ locate-path@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^5.0.0"
|
p-locate "^5.0.0"
|
||||||
|
|
||||||
|
lodash-es@^4.17.21:
|
||||||
|
version "4.17.21"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||||
|
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||||
|
|
||||||
lodash.includes@^4.3.0:
|
lodash.includes@^4.3.0:
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||||
@@ -4636,6 +4636,11 @@ long-timeout@0.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514"
|
resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514"
|
||||||
integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==
|
integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==
|
||||||
|
|
||||||
|
lottie-web@5.12.2:
|
||||||
|
version "5.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5"
|
||||||
|
integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==
|
||||||
|
|
||||||
lru-cache@^5.1.1:
|
lru-cache@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||||
@@ -6620,6 +6625,14 @@ vue-toastification@^2.0.0-rc.5:
|
|||||||
resolved "https://registry.yarnpkg.com/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz#92798604d806ae473cfb76ed776fae294280f8f8"
|
resolved "https://registry.yarnpkg.com/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz#92798604d806ae473cfb76ed776fae294280f8f8"
|
||||||
integrity sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==
|
integrity sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==
|
||||||
|
|
||||||
|
vue3-lottie@^3.2.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue3-lottie/-/vue3-lottie-3.2.0.tgz#7f95831d3483dc304c680a9b2cac6ed098070a2a"
|
||||||
|
integrity sha512-PtNXbv7iD4XGmK8CXd71aLWmILIR6P6VzLMDw6ZzmDAeuJZuJ0MdOVnn50LOARgq+cOFJsn/2z+7tZh0F9T9qw==
|
||||||
|
dependencies:
|
||||||
|
lodash-es "^4.17.21"
|
||||||
|
lottie-web "5.12.2"
|
||||||
|
|
||||||
vue@^3.3.4:
|
vue@^3.3.4:
|
||||||
version "3.3.4"
|
version "3.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.3.4.tgz#8ed945d3873667df1d0fcf3b2463ada028f88bd6"
|
resolved "https://registry.yarnpkg.com/vue/-/vue-3.3.4.tgz#8ed945d3873667df1d0fcf3b2463ada028f88bd6"
|
||||||
|
|||||||
Reference in new issue
Block a user