mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-07 11:10:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4ce11bcc | ||
|
|
9c650a4c26 | ||
|
|
b96bcb27ad | ||
|
|
b0e12b2027 | ||
|
|
3b68693177 | ||
|
|
fa36b63fbe | ||
|
|
68db16419d | ||
|
|
19cd10ab7b | ||
|
|
bc0055781e | ||
|
|
8c634c8115 | ||
|
|
ceb9416fab | ||
|
|
c6733814ba | ||
|
|
1b94ae75ca | ||
|
|
3e7092dbd6 | ||
|
|
61f627b125 | ||
|
|
a578d4f910 | ||
|
|
1bca61da40 | ||
|
|
660a7ce4e8 | ||
|
|
ac31ca8659 | ||
|
|
9200336b49 | ||
|
|
c243e29002 | ||
|
|
175db7b8bc | ||
|
|
581e75ee57 | ||
|
|
b625abae68 | ||
|
|
b0bfbb789c | ||
|
|
2ce277c7e5 | ||
|
|
7ef77267d3 | ||
|
|
d34ae3ef7a | ||
|
|
bfa7ed1cf9 | ||
|
|
aa5088cc97 | ||
|
|
0d0465f722 | ||
|
|
360279f765 | ||
|
|
6c73a8abc0 | ||
|
|
55a0dcb443 | ||
|
|
5b3c52bd9b | ||
|
|
a911d953cd | ||
|
|
c52b5a53cd | ||
|
|
c9a43eeeb1 | ||
|
|
69624d7a5d | ||
|
|
4681a743b4 | ||
|
|
e0b3ec74de | ||
|
|
d0b179108a | ||
|
|
10ac3c720e | ||
|
|
7a3b6b1cfb | ||
|
|
dc1d3f6ce5 | ||
|
|
5cfe6823cc | ||
|
|
6cd348433a | ||
|
|
617bb56f5f | ||
|
|
a859b4ff3e | ||
|
|
46fb8407a6 | ||
|
|
c7d18ad95e | ||
|
|
8f31e4f2cd | ||
|
|
d4446186cd | ||
|
|
8fb8af036d | ||
|
|
68492cd932 | ||
|
|
3c1afb92e1 | ||
|
|
abaacf7ee7 | ||
|
|
a22b898c0c | ||
|
|
d64a4ae81d | ||
|
|
ebd81f7cb4 | ||
|
|
f657f633b4 | ||
|
|
fb6411e923 | ||
|
|
a7e646c28b | ||
|
|
98a226f3ff | ||
|
|
49fb5a6a61 | ||
|
|
fe69841622 | ||
|
|
bb968706ad | ||
|
|
849e669147 | ||
|
|
b41e67ee0b | ||
|
|
50a0bbbab1 | ||
|
|
d712925d0f | ||
|
|
b78398680d | ||
|
|
c7f52b1003 | ||
|
|
f008fb9f60 | ||
|
|
76a39add29 | ||
|
|
eb9118d5b1 | ||
|
|
cfdf8730e8 | ||
|
|
6651071580 | ||
|
|
f30f6472f5 | ||
|
|
5ac3b3088d | ||
|
|
acc4d5acba | ||
|
|
1fb43b78e3 | ||
|
|
b27adc1f69 | ||
|
|
6f68107405 | ||
|
|
de406135a3 | ||
|
|
1822188c97 | ||
|
|
38725c588b | ||
|
|
d95b763b45 | ||
|
|
f4de2a182e | ||
|
|
93e2485dee | ||
|
|
5f7e2a3d5a | ||
|
|
fe604f3781 | ||
|
|
143b84afb4 | ||
|
|
c54bc68001 | ||
|
|
1944a69b53 | ||
|
|
f03a544108 | ||
|
|
277d619518 | ||
|
|
96c0785a7e | ||
|
|
2cc8a98734 | ||
|
|
d07b38c7d6 | ||
|
|
00e59e37ee | ||
|
|
580a24402b | ||
|
|
66adfec365 | ||
|
|
c6f45e10e0 | ||
|
|
140a1249a6 | ||
|
|
5413cf4118 | ||
|
|
9e7065fbd7 | ||
|
|
52fa8d6f3a | ||
|
|
88d19e7f87 | ||
|
|
a9a63ea164 | ||
|
|
fc13e9371c | ||
|
|
e664573215 | ||
|
|
63ed4c098c | ||
|
|
4d53f56397 | ||
|
|
057635eed6 | ||
|
|
84cf74cdfc | ||
|
|
a7eeb8470a | ||
|
|
c9500c7575 | ||
|
|
dd3e6b4aac | ||
|
|
4f55dcd929 | ||
|
|
790e09b315 | ||
|
|
ce6d5aad66 | ||
|
|
a90b0a3c83 | ||
|
|
1a5655d7ce | ||
|
|
196187d5c9 | ||
|
|
accf2bd0e2 | ||
|
|
0d8ab8413a | ||
|
|
c42c558614 | ||
|
|
c6e49a10be | ||
|
|
63c2443802 | ||
|
|
f9e6228226 | ||
|
|
937c510aa0 | ||
|
|
779733c7ba | ||
|
|
639a7d06d9 | ||
|
|
814ca72040 | ||
|
|
821386a0e4 | ||
|
|
956af624c6 | ||
|
|
2e6a29cb92 | ||
|
|
e360ebcf53 | ||
|
|
89f86fad30 | ||
|
|
6fddf5ee0c | ||
|
|
1ab3e577c1 | ||
|
|
28cd2a7ac7 | ||
|
|
2e1fe369a4 | ||
|
|
21ca083628 | ||
|
|
3cebcddc91 | ||
|
|
7ba9e6b14b | ||
|
|
cd03277fd4 | ||
|
|
2c4bd67996 | ||
|
|
06c36565d0 | ||
|
|
ba641ca222 | ||
|
|
ff2241daa8 | ||
|
|
d19b32c6d1 | ||
|
|
d15a197db9 | ||
|
|
5b71944588 | ||
|
|
627ff1476b | ||
|
|
06c694ee68 | ||
|
|
7320688827 | ||
|
|
a4a261291d | ||
|
|
4c85516bcb | ||
|
|
9f9a31719e | ||
|
|
b6b8010a60 | ||
|
|
3e441b1104 | ||
|
|
174941a37e | ||
|
|
bdd37ae747 | ||
|
|
dd7cded09e | ||
|
|
f1d5374137 | ||
|
|
364cda39ad | ||
|
|
66ce015f0e | ||
|
|
cc03e57c70 | ||
|
|
f7907a5999 | ||
|
|
5d26029166 | ||
|
|
869f01a62b | ||
|
|
d221465a73 | ||
|
|
56d96cf7a0 | ||
|
|
a8ba2a640b | ||
|
|
36f53da08a | ||
|
|
0343a7ccee | ||
|
|
42ac27126c | ||
|
|
4297f96913 | ||
|
|
691f12f7e7 | ||
|
|
c89abfbac5 | ||
|
|
764c6812bd | ||
|
|
882ce5f75d | ||
|
|
b514fa8bea | ||
|
|
3fed851b7b | ||
|
|
3f8bcb5446 | ||
|
|
c7fb0997b7 | ||
|
|
d4da065a95 | ||
|
|
65894f831a | ||
|
|
5fbdcd5182 | ||
|
|
ee5e8afe7b | ||
|
|
a6b51a08e5 | ||
|
|
0d886a686e | ||
|
|
9da23b5dc1 | ||
|
|
f6c3b33da9 | ||
|
|
83994106a0 | ||
|
|
2837354456 | ||
|
|
6ff21053cf | ||
|
|
4c1be84942 | ||
|
|
659161a76e | ||
|
|
82d1004ecf | ||
|
|
12c6e493a5 | ||
|
|
515aefcadc | ||
|
|
5d353b71e2 | ||
|
|
c9a24b92ad | ||
|
|
92b12a90d3 | ||
|
|
f84a163b77 | ||
|
|
c272fe61a8 | ||
|
|
46efb15273 | ||
|
|
b5be0608ae | ||
|
|
08e28f7725 | ||
|
|
34e9b20f2e | ||
|
|
b19d6bf6e2 | ||
|
|
7a8df716da | ||
|
|
e018e8dfe4 | ||
|
|
ca8215486c | ||
|
|
14d6ab961b | ||
|
|
e18f0db5ad | ||
|
|
fae2d8199d | ||
|
|
c92a2e1d70 |
No files matched your search
+3
-2
@@ -13,11 +13,11 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
// parser: 'babel-eslint',
|
// parser: 'babel-eslint',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 13,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
'no-console': 'warn',
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
'import/extensions': 'off',
|
'import/extensions': 'off',
|
||||||
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
|
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
|
||||||
@@ -28,5 +28,6 @@ module.exports = {
|
|||||||
'no-plusplus': 'off',
|
'no-plusplus': 'off',
|
||||||
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
|
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
|
||||||
'import/prefer-default-export': 'off',
|
'import/prefer-default-export': 'off',
|
||||||
|
'no-restricted-syntax': ['off', 'ForOfStatement'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,15 @@ name: CI Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
secrets:
|
||||||
|
GOOGLE_CLIENT_ID:
|
||||||
|
required: true
|
||||||
|
GOOGLE_CLIENT_SECRET:
|
||||||
|
required: true
|
||||||
|
GH_CLIENT_ID:
|
||||||
|
required: true
|
||||||
|
GH_CLIENT_SECRET:
|
||||||
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -40,6 +49,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||||
GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }}
|
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
|
||||||
GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}
|
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
|
||||||
run: docker-compose -f docker-compose.testing.yml run cypress
|
run: docker-compose -f docker-compose.testing.yml run cypress
|
||||||
@@ -12,6 +12,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
code_review:
|
code_review:
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,7 +25,7 @@ jobs:
|
|||||||
uses: mattzcarey/code-review-gpt@v0.1.4-alpha
|
uses: mattzcarey/code-review-gpt@v0.1.4-alpha
|
||||||
with:
|
with:
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
MODEL: 'gpt-4'
|
MODEL: 'gpt-3.5-turbo'
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -2,10 +2,10 @@
|
|||||||
FROM node:20-alpine as base
|
FROM node:20-alpine as base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
COPY app/package.json ./app/package.json
|
|
||||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
COPY packages/utils/package.json ./packages/utils/package.json
|
COPY packages/utils/package.json ./packages/utils/package.json
|
||||||
COPY packages/constants/package.json ./packages/constants/package.json
|
COPY packages/constants/package.json ./packages/constants/package.json
|
||||||
|
COPY app/package.json ./app/package.json
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Dev stage
|
# Dev stage
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon_totem.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linux Odyssey</title>
|
<title>Linux Odyssey</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+5
-1
@@ -20,11 +20,15 @@
|
|||||||
"@linux-odyssey/file-graph": "*",
|
"@linux-odyssey/file-graph": "*",
|
||||||
"@linux-odyssey/utils": "*",
|
"@linux-odyssey/utils": "*",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"dompurify": "^3.0.6",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"marked": "^10.0.0",
|
||||||
"socket.io-client": "^4.7.1",
|
"socket.io-client": "^4.7.1",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
"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"
|
||||||
@@ -34,6 +38,6 @@
|
|||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"vite": "^4.4.0"
|
"vite": "^4.4.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
|
||||||
|
<defs>
|
||||||
|
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||||
|
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
|
||||||
|
</pattern>
|
||||||
|
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,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 truncated
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 630 KiB |
@@ -41,7 +41,7 @@ const hasSocialLogins = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const success = () => {
|
const success = () => {
|
||||||
router.push({ name: 'game', params: { questId: 'helloworld' } })
|
router.push({ name: 'map' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = (err) => {
|
const error = (err) => {
|
||||||
@@ -89,8 +89,13 @@ const handleChange = () => {
|
|||||||
<div
|
<div
|
||||||
class="bg-bg border-8 border-bg-primary flex flex-1 flex-col items-center justify-center rounded-3xl p-10"
|
class="bg-bg border-8 border-bg-primary flex flex-1 flex-col items-center justify-center rounded-3xl p-10"
|
||||||
>
|
>
|
||||||
<h1 class="text-text-primary text-3xl font-black mb-2">{{ title }}</h1>
|
<div class="w-full flex items-center justify-center mb-2 gap-3">
|
||||||
<div class="text-error inline-flex justify-center items-center xl:hidden">
|
<img src="../img/icon_totem.svg" class="h-10" />
|
||||||
|
<h1 class="text-text-primary text-3xl font-black" id="title">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="text-error inline-flex justify-center items-center md:hidden">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'circle-exclamation']"
|
:icon="['fas', 'circle-exclamation']"
|
||||||
class="px-1 text-sm"
|
class="px-1 text-sm"
|
||||||
@@ -100,6 +105,7 @@ const handleChange = () => {
|
|||||||
<form @submit.prevent="handleSubmit()" class="w-full">
|
<form @submit.prevent="handleSubmit()" class="w-full">
|
||||||
<div v-if="socialLogin && hasSocialLogins">
|
<div v-if="socialLogin && hasSocialLogins">
|
||||||
<a
|
<a
|
||||||
|
id="GoogleLogin"
|
||||||
v-if="availableMethods.google"
|
v-if="availableMethods.google"
|
||||||
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
|
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
|
||||||
href="/api/v1/auth/google"
|
href="/api/v1/auth/google"
|
||||||
@@ -108,9 +114,13 @@ const handleChange = () => {
|
|||||||
:icon="['fab', 'google']"
|
:icon="['fab', 'google']"
|
||||||
class="text-text-secondary px-3 text-lg"
|
class="text-text-secondary px-3 text-lg"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium">Continue with Google</span>
|
<span class="font-medium">
|
||||||
|
<!-- Continue with Google -->
|
||||||
|
以 Google 繼續
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
id="GitHubLogin"
|
||||||
v-if="availableMethods.github"
|
v-if="availableMethods.github"
|
||||||
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
|
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
|
||||||
href="/api/v1/auth/github"
|
href="/api/v1/auth/github"
|
||||||
@@ -119,17 +129,22 @@ const handleChange = () => {
|
|||||||
:icon="['fab', 'github']"
|
:icon="['fab', 'github']"
|
||||||
class="text-text-secondary px-3 text-lg"
|
class="text-text-secondary px-3 text-lg"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium">Continue with GitHub</span>
|
<span class="font-medium">
|
||||||
|
<!-- Continue with GitHub -->
|
||||||
|
以 GitHub 繼續
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<p
|
<p
|
||||||
|
id="or"
|
||||||
class="text-text-secondary flex justify-center mt-3"
|
class="text-text-secondary flex justify-center mt-3"
|
||||||
v-if="type === 'login' || type === 'register'"
|
v-if="type === 'login' || type === 'register'"
|
||||||
>
|
>
|
||||||
or
|
<!-- or -->
|
||||||
|
或
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<input
|
<!-- <input
|
||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
ref="usernameInput"
|
ref="usernameInput"
|
||||||
@@ -139,9 +154,21 @@ const handleChange = () => {
|
|||||||
required
|
required
|
||||||
@input="handleChange()"
|
@input="handleChange()"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
|
/> -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
ref="usernameInput"
|
||||||
|
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||||
|
:placeholder="type === 'login' ? '電子郵件 / 帳號名稱' : '帳號名稱'"
|
||||||
|
v-model="username"
|
||||||
|
required
|
||||||
|
@input="handleChange()"
|
||||||
|
autocomplete="username"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="text-sm font-normal text-text-secondary"
|
class="text-sm font-normal text-text-secondary"
|
||||||
|
id="NameRules"
|
||||||
v-if="type === 'username' || type === 'register'"
|
v-if="type === 'username' || type === 'register'"
|
||||||
>
|
>
|
||||||
<p>可用小寫字母、數字、"_"和"-"</p>
|
<p>可用小寫字母、數字、"_"和"-"</p>
|
||||||
@@ -149,7 +176,7 @@ const handleChange = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6" v-if="type === 'register'">
|
<div class="mb-6" v-if="type === 'register'">
|
||||||
<input
|
<!-- <input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
ref="emailInput"
|
ref="emailInput"
|
||||||
@@ -159,10 +186,21 @@ const handleChange = () => {
|
|||||||
required
|
required
|
||||||
@input="handleChange()"
|
@input="handleChange()"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
|
/> -->
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
ref="emailInput"
|
||||||
|
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||||
|
placeholder="電子郵件"
|
||||||
|
v-model="email"
|
||||||
|
required
|
||||||
|
@input="handleChange()"
|
||||||
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6" v-if="type === 'login' || type === 'register'">
|
<div class="mb-6" v-if="type === 'login' || type === 'register'">
|
||||||
<input
|
<!-- <input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||||
@@ -170,30 +208,66 @@ const handleChange = () => {
|
|||||||
v-model="password"
|
v-model="password"
|
||||||
required
|
required
|
||||||
@input="handleChange()"
|
@input="handleChange()"
|
||||||
|
/> -->
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
|
||||||
|
placeholder="密碼"
|
||||||
|
v-model="password"
|
||||||
|
required
|
||||||
|
@input="handleChange()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-error flex justify-center" v-if="errorMessage">
|
<p
|
||||||
|
id="ErrorDisplay"
|
||||||
|
class="text-error flex justify-center"
|
||||||
|
v-if="errorMessage"
|
||||||
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
id="LogInOrSignUp"
|
||||||
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
|
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<span v-if="type === 'login'">Log In</span>
|
<span v-if="type === 'login'">
|
||||||
<span v-else>Sign Up</span>
|
<!-- Log In -->
|
||||||
|
登入
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<!-- Sign Up -->
|
||||||
|
註冊
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="text-text flex justify-center mt-3">
|
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
|
||||||
<span v-if="type === 'register'"
|
<span id="CheckRegistered" v-if="type === 'register'">
|
||||||
>Already have an account?
|
<!-- Already have an account? -->
|
||||||
<RouterLink class="text-text-primary font-bold" to="/login">
|
已經有帳號了嗎?
|
||||||
<u>Log in</u>
|
<RouterLink
|
||||||
|
id="SwitchtoLogin"
|
||||||
|
class="text-text-primary font-bold"
|
||||||
|
to="/login"
|
||||||
|
>
|
||||||
|
<u>
|
||||||
|
<!-- Log in -->
|
||||||
|
登入
|
||||||
|
</u>
|
||||||
</RouterLink></span
|
</RouterLink></span
|
||||||
>
|
>
|
||||||
<span v-else-if="type === 'login'"
|
<span id="CheckRegistered" v-else-if="type === 'login'">
|
||||||
>Don't have an account?
|
<!-- Don't have an account? -->
|
||||||
<RouterLink class="text-text-primary font-bold" to="/register">
|
還沒有帳號嗎?
|
||||||
<u>Sign up</u>
|
<RouterLink
|
||||||
|
id="SwitchtoRegister"
|
||||||
|
class="text-text-primary font-bold"
|
||||||
|
to="/register"
|
||||||
|
>
|
||||||
|
<u>
|
||||||
|
<!-- Sign up -->
|
||||||
|
註冊
|
||||||
|
</u>
|
||||||
</RouterLink></span
|
</RouterLink></span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup></script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
|
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
|
||||||
<h1 class="inline text-text-primary font-xl">Command Cheatsheets</h1>
|
<h1 id="CommandBook" class="inline text-text-primary font-xl">
|
||||||
|
<!-- Command Cheatsheets -->
|
||||||
|
指令之書
|
||||||
|
</h1>
|
||||||
<div id="cheatsheets">
|
<div id="cheatsheets">
|
||||||
<p class="text-text-secondary">--Command List--</p>
|
<p class="text-text-secondary">
|
||||||
|
<!-- --Command List-- -->
|
||||||
|
-- --指令列表-- --
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -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,18 +42,42 @@ 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 class="text-2xl text-text mb-5 w-full inline-flex justify-center">
|
|
||||||
Quest Completed!
|
<h2
|
||||||
|
id="QuestCompleted"
|
||||||
|
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||||
|
>
|
||||||
|
<!-- 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"
|
||||||
: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,34 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="h-[10%] p-3 grid grid-cols-3 gap-4 place-content-between">
|
<section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between">
|
||||||
<button id="solution" class="bg-bg-disabled rounded-lg p-2">
|
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
|
||||||
|
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'file-invoice']"
|
||||||
|
class="text-text-primary"
|
||||||
|
/>
|
||||||
|
<p class="text-text-primary inline ml-1">
|
||||||
|
<!-- Survey -->
|
||||||
|
問卷
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['far', 'circle-question']"
|
:icon="['far', 'circle-question']"
|
||||||
class="text-text-disabled"
|
class="text-text-disabled"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-disabled inline ml-1">Solution</p>
|
<p class="text-text-disabled inline ml-1">
|
||||||
|
<!-- Solution -->
|
||||||
|
解答
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
|
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'arrow-rotate-left']"
|
:icon="['fas', 'arrow-rotate-left']"
|
||||||
class="text-text-primary"
|
class="text-text-primary"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-primary inline ml-1">Reset</p>
|
<p class="text-text-primary inline ml-1">
|
||||||
|
<!-- Reset -->
|
||||||
|
重來
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button id="continue" class="bg-bg-disabled rounded-lg p-2">
|
<button
|
||||||
|
id="continue"
|
||||||
|
class="bg-bg-secondary rounded-lg p-2"
|
||||||
|
@click="continuePlay"
|
||||||
|
>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['far', 'circle-right']"
|
:icon="['far', 'circle-right']"
|
||||||
class="text-text-disabled"
|
class="text-text-primary"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-disabled inline ml-1">Continue</p>
|
<p class="text-text-primary inline ml-1">
|
||||||
|
<!-- Continue -->
|
||||||
|
繼續
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
import { createSession } from '../store/session'
|
import { createSession } from '../store/session'
|
||||||
|
import { surveyUrl } from '../config'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
console.log('resetting...')
|
console.log('resetting...')
|
||||||
await createSession()
|
await createSession()
|
||||||
}
|
}
|
||||||
|
const continuePlay = async () => {
|
||||||
|
try {
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
} catch (err) {
|
||||||
|
// useToast().error('Failed to load map')
|
||||||
|
useToast().error('無法讀取地圖')
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
<template>
|
|
||||||
<div id="box">
|
|
||||||
<canvas ref="canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
@@ -79,6 +73,12 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="box">
|
||||||
|
<canvas ref="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
#box {
|
#box {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
<template>
|
|
||||||
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
|
||||||
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
|
||||||
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
|
||||||
<FileNode :node="child" :pwd="pwd" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
node: {
|
node: {
|
||||||
@@ -27,3 +18,12 @@ function nodeClass() {
|
|||||||
return classes
|
return classes
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
||||||
|
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
||||||
|
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
||||||
|
<FileNode :node="child" :pwd="pwd" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -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, surveyUrl } 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,13 +17,49 @@ 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) {
|
||||||
toast.error('Failed to logout')
|
// toast.error('Failed to logout')
|
||||||
|
toast.error('登出失敗')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const handleMap = async () => {
|
||||||
|
try {
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
} catch (err) {
|
||||||
|
// toast.error('Failed to load map')
|
||||||
|
toast.error('無法讀取地圖')
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleLeaderBoard = async () => {
|
||||||
|
try {
|
||||||
|
router.push({ name: 'leaderboard' })
|
||||||
|
} catch (err) {
|
||||||
|
// toast.error('Failed to load map')
|
||||||
|
toast.error('無法讀取排行榜')
|
||||||
|
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>
|
||||||
@@ -34,14 +75,23 @@ const handleLogout = async () => {
|
|||||||
<button id="backward" class="flex items-center h-5 w-5">
|
<button id="backward" class="flex items-center h-5 w-5">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'arrow-left']"
|
:icon="['fas', 'arrow-left']"
|
||||||
class="text-text-tertiary h-full w-full"
|
class="text-border h-full w-full"
|
||||||
/>
|
/>
|
||||||
</button> -->
|
</button> -->
|
||||||
<p
|
<img src="../img/icon_totem.svg" class="h-5" />
|
||||||
|
<button
|
||||||
|
id="GameTitle"
|
||||||
class="text-text-primary inline-block font-bold whitespace-nowrap"
|
class="text-text-primary inline-block font-bold whitespace-nowrap"
|
||||||
v-if="sessionStore.quest"
|
style="font-size: 2vh"
|
||||||
>
|
>
|
||||||
{{ sessionStore.quest.title }}
|
Linux Odyssey
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
id="QuestTitle"
|
||||||
|
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">
|
||||||
@@ -63,6 +113,31 @@ const handleLogout = 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>
|
||||||
|
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'file-invoice']"
|
||||||
|
class="text-text-primary h-full w-full"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<button title="LeaderBoard" @click="handleLeaderBoard" class="h-5 w-5">
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'trophy']"
|
||||||
|
class="text-text-primary h-full w-full"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button title="Map" @click="handleMap" class="h-5 w-5">
|
||||||
|
<font-awesome-icon
|
||||||
|
:icon="['fas', 'map']"
|
||||||
|
class="text-text-primary h-full w-full"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
title="Bug Report"
|
title="Bug Report"
|
||||||
:href="bugReportUrl"
|
:href="bugReportUrl"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import sessionStore from '../store/session'
|
import sessionStore from '../store/session'
|
||||||
|
import MarkdownText from './MarkdownText.vue'
|
||||||
|
|
||||||
const current = ref(-1)
|
const current = ref(-1)
|
||||||
watch(
|
watch(
|
||||||
@@ -30,8 +31,11 @@ const right = () => {
|
|||||||
:icon="['far', 'lightbulb']"
|
:icon="['far', 'lightbulb']"
|
||||||
class="text-yellow-200 p-2 content-center"
|
class="text-yellow-200 p-2 content-center"
|
||||||
/>
|
/>
|
||||||
<h1 class="inline text-text font-xl p-2 m-1">Hint</h1>
|
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">
|
||||||
<div v-if="current !== -1" class="w-full flex justify-end">
|
<!-- Hint -->
|
||||||
|
提示
|
||||||
|
</h1>
|
||||||
|
<div v-if="current !== -1" class="flex w-full justify-end items-end">
|
||||||
<button
|
<button
|
||||||
class="p-2 m-1 w-1/8"
|
class="p-2 m-1 w-1/8"
|
||||||
id="Lbutton"
|
id="Lbutton"
|
||||||
@@ -60,9 +64,15 @@ const right = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="hint" class="bg-bg flex flex-wrap p-8">
|
<div id="hint" class="bg-bg flex flex-wrap p-8">
|
||||||
<p class="text-text font-xl whitespace-pre-wrap">
|
<ul>
|
||||||
{{ sessionStore.session.hints[current] }}
|
<li
|
||||||
</p>
|
v-for="hint in sessionStore.session.hints[current]"
|
||||||
|
:key="hint"
|
||||||
|
class="text-text font-xl whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
<MarkdownText :content="hint" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, defineProps } from 'vue'
|
||||||
|
import markdown2HTML from '../utils/markdown'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = computed(() => {
|
||||||
|
return markdown2HTML(props.content)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="markdown-content" v-html="html"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(ol) {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
.markdown-content :deep(code) {
|
||||||
|
background-color: #282a33;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<!-- eslint-disable no-console -->
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { use, init } from 'echarts/core'
|
||||||
|
import { GraphChart } from 'echarts/charts'
|
||||||
|
import { SVGRenderer } from 'echarts/renderers'
|
||||||
|
import {
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { DAG } from '@linux-odyssey/utils'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { NodeImage } from '../img/svg.js'
|
||||||
|
|
||||||
|
const marginX = 500
|
||||||
|
const marginY = 300
|
||||||
|
const chartContainer = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
|
const fullwidth = window.screen.width
|
||||||
|
|
||||||
|
use([
|
||||||
|
GraphChart,
|
||||||
|
SVGRenderer,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
])
|
||||||
|
|
||||||
|
async function getQuests() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/quests')
|
||||||
|
return res.data
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProgress() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/users/me')
|
||||||
|
return res.data.progress
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const genOption = (nodes, edges) => ({
|
||||||
|
renderer: 'svg',
|
||||||
|
tooltip: {},
|
||||||
|
animationDurationUpdate: 1500,
|
||||||
|
animationEasingUpdate: 'quinticInOut',
|
||||||
|
// dataZoom: [
|
||||||
|
// {
|
||||||
|
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
|
||||||
|
// orient: 'vertical', // This makes the data zoom component vertical
|
||||||
|
// start: 0, // The starting percentage of the window out of the data extent
|
||||||
|
// end: 100, // The ending percentage of the window out of the data extent
|
||||||
|
// show: true,
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'graph',
|
||||||
|
layout: 'none',
|
||||||
|
symbol: () => {
|
||||||
|
return NodeImage
|
||||||
|
},
|
||||||
|
symbolSize: [fullwidth / 13, fullwidth / 30],
|
||||||
|
roam: 'move',
|
||||||
|
zoom: 1.5,
|
||||||
|
center: ['120%', '30%'],
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: fullwidth / 90,
|
||||||
|
},
|
||||||
|
edgeSymbol: ['pin', 'arrow'],
|
||||||
|
edgeSymbolSize: [2, 15],
|
||||||
|
edgeLabel: {
|
||||||
|
fontSize: 25,
|
||||||
|
},
|
||||||
|
data: nodes,
|
||||||
|
// links: [],
|
||||||
|
links: edges,
|
||||||
|
lineStyle: {
|
||||||
|
width: 3,
|
||||||
|
color: 'gray',
|
||||||
|
opacity: 4,
|
||||||
|
curveness: 0.08,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: (params) => {
|
||||||
|
const {
|
||||||
|
data: { completed, unlocked },
|
||||||
|
} = params
|
||||||
|
if (completed) {
|
||||||
|
return '#00ff00'
|
||||||
|
}
|
||||||
|
if (unlocked) {
|
||||||
|
return '#ADADB5'
|
||||||
|
}
|
||||||
|
return '#505050'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
function getOption(quests, progress) {
|
||||||
|
const dag = new DAG(quests)
|
||||||
|
|
||||||
|
const nodes = dag.getNodes().map((node) => ({
|
||||||
|
id: node._id,
|
||||||
|
name: node.title,
|
||||||
|
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
|
||||||
|
x: marginY * node.layer * 2,
|
||||||
|
completed: progress[node._id]?.completed || false,
|
||||||
|
unlocked: node.requirements.every((req) => progress[req]?.completed),
|
||||||
|
}))
|
||||||
|
const lineAppearence = (node) => {
|
||||||
|
try {
|
||||||
|
const { unlocked } = node
|
||||||
|
return {
|
||||||
|
type: unlocked ? 'line' : 'dashed',
|
||||||
|
color: unlocked ? '#ADADB5' : '#454552',
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const edges = dag.getEdgesArray().map((edge) => ({
|
||||||
|
source: edge[0],
|
||||||
|
target: edge[1],
|
||||||
|
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
|
||||||
|
}))
|
||||||
|
return genOption(nodes, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
function initChart(option) {
|
||||||
|
chartInstance = init(chartContainer.value)
|
||||||
|
chartInstance.setOption(option)
|
||||||
|
chartInstance.on('click', (params) => {
|
||||||
|
const {
|
||||||
|
data: { id, unlocked },
|
||||||
|
} = params
|
||||||
|
|
||||||
|
if (unlocked) {
|
||||||
|
router.push({ name: 'game', params: { questId: id } })
|
||||||
|
} else {
|
||||||
|
// toast.warning('You have not completed the previous quest yet!')
|
||||||
|
toast.warning('你還沒完成前一個關卡!')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const quests = await getQuests()
|
||||||
|
const progress = await getProgress()
|
||||||
|
const option = getOption(quests, progress)
|
||||||
|
initChart(option)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative bg-black w-full h-full flex">
|
||||||
|
<img
|
||||||
|
src="../img/catelogbg.svg"
|
||||||
|
alt="bg"
|
||||||
|
class="p-2 stroke-2 scale-y-120 justify-center items-center"
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||||
|
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||||
|
>
|
||||||
|
<!-- Get through your linux journey! -->
|
||||||
|
踏上你的Linux冒險之旅吧!
|
||||||
|
</h1>
|
||||||
|
<div class="flex flex-wrap absolute w-full h-full z-1">
|
||||||
|
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import MarkdownText from './MarkdownText.vue'
|
||||||
import sessionStore from '../store/session'
|
import sessionStore from '../store/session'
|
||||||
|
|
||||||
|
const tasks = computed(() => {
|
||||||
|
return sessionStore.session.tasks.map((task) => {
|
||||||
|
const prefix = task.completed ? '✓' : '➤'
|
||||||
|
const color = task.completed ? 'text-text-primary' : 'text-text'
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
class: `${color} markdown-content`,
|
||||||
|
content: `${prefix} ${task.name}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full max-h-full">
|
<div class="h-full w-full max-h-full">
|
||||||
<h1 id="topic" class="text-text" v-if="sessionStore.quest">
|
<p id="topic" class="text-text font-black" v-if="sessionStore.quest">
|
||||||
{{ sessionStore.quest.title }}
|
{{ sessionStore.quest.title }}
|
||||||
</h1>
|
</p>
|
||||||
<hr class="border-text-tertiary border-2 my-2" />
|
<br />
|
||||||
<div>
|
<div>
|
||||||
<div v-if="sessionStore.quest" class="whitespace-pre-wrap">
|
<div v-if="sessionStore.quest" class="whitespace-pre-wrap">
|
||||||
<p class="text-text">{{ sessionStore.quest.instruction }}</p>
|
<MarkdownText
|
||||||
|
class="text-text markdown-content"
|
||||||
|
:content="sessionStore.quest.instruction"
|
||||||
|
/>
|
||||||
<br />
|
<br />
|
||||||
<p class="text-text">Tasks:</p>
|
<p id="tasks" class="text-text">
|
||||||
|
<!-- Tasks: -->
|
||||||
|
任務:
|
||||||
|
</p>
|
||||||
<ul v-if="sessionStore.session">
|
<ul v-if="sessionStore.session">
|
||||||
<li v-for="task in sessionStore.session.tasks" :key="task.id">
|
<li v-for="task in tasks" :key="task.id">
|
||||||
<p v-if="task.completed" class="text-text-primary">
|
<MarkdownText :class="task.class" :content="task.content" />
|
||||||
<span class="">✓</span>
|
|
||||||
{{ task.name }}
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-text">
|
|
||||||
<span class="">➤</span>
|
|
||||||
{{ task.name }}
|
|
||||||
</p>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
||||||
<button class="text-text h-[5%]">Terminal</button>
|
<button id="Terminal" class="text-text h-[5%]">
|
||||||
|
<!-- Terminal -->
|
||||||
|
終端機
|
||||||
|
</button>
|
||||||
<div
|
<div
|
||||||
id="terminal"
|
id="terminal"
|
||||||
ref="terminal"
|
ref="terminal"
|
||||||
|
|||||||
@@ -121,6 +121,6 @@ li.discovered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.pwd {
|
a.pwd {
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
|
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
|
||||||
|
<defs>
|
||||||
|
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||||
|
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
|
||||||
|
</pattern>
|
||||||
|
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,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 truncated
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 630 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="272" height="139" viewBox="0 0 272 139" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0.640015 25.0339C0.640015 11.2268 11.8329 0.0339355 25.64 0.0339355H246.36C260.167 0.0339355 271.36 11.2268 271.36 25.0339V113.14C271.36 126.948 260.167 138.14 246.36 138.14H25.64C11.8329 138.14 0.640015 126.948 0.640015 113.14V25.0339Z" fill="#454552"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,2 @@
|
|||||||
|
export const NodeImage =
|
||||||
|
'path://M0.640015 25.0339C0.640015 11.2268 11.8329 0.0339355 25.64 0.0339355H246.36C260.167 0.0339355 271.36 11.2268 271.36 25.0339V113.14C271.36 126.948 260.167 138.14 246.36 138.14H25.64C11.8329 138.14 0.640015 126.948 0.640015 113.14V25.0339Z'
|
||||||
+7
-1
@@ -15,6 +15,9 @@ import {
|
|||||||
faArrowRight,
|
faArrowRight,
|
||||||
faBug,
|
faBug,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
|
faMap,
|
||||||
|
faTrophy,
|
||||||
|
faFileInvoice,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {
|
import {
|
||||||
faLightbulb,
|
faLightbulb,
|
||||||
@@ -36,10 +39,13 @@ library.add(
|
|||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
faBug,
|
faBug,
|
||||||
|
faMap,
|
||||||
|
faTrophy,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faLightbulb,
|
faLightbulb,
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faGithub,
|
faGithub,
|
||||||
faGoogle
|
faGoogle,
|
||||||
|
faFileInvoice
|
||||||
)
|
)
|
||||||
@@ -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')
|
||||||
+17
-9
@@ -3,17 +3,17 @@ import GamePage from './views/GamePage.vue'
|
|||||||
import LoginPage from './views/LoginPage.vue'
|
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 LeaderboardPage from './views/LeaderboardPage.vue'
|
||||||
import { isLoggedIn } from './utils/auth'
|
import { isLoggedIn } from './utils/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: {
|
redirect: {
|
||||||
name: 'game',
|
name: 'register',
|
||||||
params: {
|
|
||||||
questId: 'helloworld',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
meta: { requiresGuest: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -40,6 +40,17 @@ const routes = [
|
|||||||
component: ChooseUsernamePage,
|
component: ChooseUsernamePage,
|
||||||
meta: { requiresGuest: true },
|
meta: { requiresGuest: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/map',
|
||||||
|
name: 'map',
|
||||||
|
component: QuestMapPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/leaderboard',
|
||||||
|
name: 'leaderboard',
|
||||||
|
component: LeaderboardPage,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -59,13 +70,10 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
})
|
})
|
||||||
} else if (to.meta.requiresGuest && loggedIn) {
|
} else if (to.meta.requiresGuest && loggedIn) {
|
||||||
next({
|
next({
|
||||||
name: 'game', // Or any default authenticated user route
|
name: 'map',
|
||||||
params: {
|
|
||||||
questId: 'helloworld',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
next() // Always call next() at the end
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function updateGraph(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateHints(hints) {
|
function updateHints(hints) {
|
||||||
store.session.hints.push(...hints)
|
store.session.hints.push(hints)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatus(status) {
|
function updateStatus(status) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
export default function markdown2HTML(markdown) {
|
||||||
|
const html = marked.parse(markdown)
|
||||||
|
return DOMPurify.sanitize(html)
|
||||||
|
}
|
||||||
@@ -13,12 +13,11 @@ class SocketTerminal {
|
|||||||
disableStdin: false, // 是否应禁用输入
|
disableStdin: false, // 是否应禁用输入
|
||||||
// cursorStyle: "underline", //光标样式
|
// cursorStyle: "underline", //光标样式
|
||||||
cursorBlink: true, // 光标闪烁
|
cursorBlink: true, // 光标闪烁
|
||||||
theme: {
|
foreground: '#ECECEC', // 字体
|
||||||
foreground: '#ECECEC', // 字体
|
background: '#000000', // 背景色
|
||||||
background: '#000000', // 背景色
|
cursor: 'help', // 设置光标
|
||||||
cursor: 'help', // 设置光标
|
fontSize: 18,
|
||||||
lineHeight: 20,
|
fontWeight: 'normal',
|
||||||
},
|
|
||||||
})
|
})
|
||||||
this.fitAddon = new FitAddon()
|
this.fitAddon = new FitAddon()
|
||||||
this.term.loadAddon(this.fitAddon)
|
this.term.loadAddon(this.fitAddon)
|
||||||
|
|||||||
@@ -8,21 +8,24 @@ import { checkUsername, chooseUsername } from '../utils/auth'
|
|||||||
async function check({ username, error }) {
|
async function check({ username, error }) {
|
||||||
if (username) {
|
if (username) {
|
||||||
if (!isValidUsername(username)) {
|
if (!isValidUsername(username)) {
|
||||||
error('Invalid username.')
|
// error('Invalid username.')
|
||||||
|
error('無效的帳號名稱')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await checkUsername(username)
|
await checkUsername(username)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
error('Too many requests. Try again in 2 minutes.')
|
// error('Too many requests. Try again in 2 minutes.')
|
||||||
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
error(err.message)
|
error(err.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
error('Something went wrong. Please try again later.')
|
// error('Something went wrong. Please try again later.')
|
||||||
|
error('出了點問題,請再試一次。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,8 @@ async function handleSubmit({ username, success, error }) {
|
|||||||
success()
|
success()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
error('Too many requests. Try again in 2 minutes.')
|
// error('Too many requests. Try again in 2 minutes.')
|
||||||
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
@@ -44,6 +48,7 @@ async function handleSubmit({ username, success, error }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen">
|
<div class="w-screen h-screen">
|
||||||
<Background class="w-full h-full" />
|
<Background class="w-full h-full" />
|
||||||
|
|||||||
+13
-11
@@ -3,7 +3,7 @@ import { computed, onMounted } from 'vue'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import sessionStore, { init } from '../store/session'
|
import sessionStore, { init } from '../store/session'
|
||||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||||
import CommandlistPart from '../components/CommandlistPart.vue'
|
// import CommandlistPart from '../components/CommandlistPart.vue'
|
||||||
import HintPart from '../components/HintPart.vue'
|
import HintPart from '../components/HintPart.vue'
|
||||||
import QuestPart from '../components/QuestPart.vue'
|
import QuestPart from '../components/QuestPart.vue'
|
||||||
import TerminalPart from '../components/TerminalPart.vue'
|
import TerminalPart from '../components/TerminalPart.vue'
|
||||||
@@ -30,13 +30,15 @@ onMounted(async () => {
|
|||||||
await init(props.questId)
|
await init(props.questId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof LoadQuestError) {
|
if (err instanceof LoadQuestError) {
|
||||||
toast.error(`Failed to load quest: ${err.questId}. Please check the URL.`)
|
// toast.error(`Failed to load quest: ${err.questId}. Please check the URL.`)
|
||||||
|
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof LoadSessionError) {
|
if (err instanceof LoadSessionError) {
|
||||||
toast.error(
|
// toast.error(
|
||||||
`Failed to create session for quest: ${err.questId}. Please re-login and try again.`
|
// `Failed to create session for quest: ${err.questId}. Please re-login and try again.`
|
||||||
)
|
// )
|
||||||
|
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -48,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
|
||||||
@@ -60,19 +62,20 @@ onMounted(async () => {
|
|||||||
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
|
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
|
||||||
<!-- Topic and Command List -->
|
<!-- Topic and Command List -->
|
||||||
<div class="bg-bg-secondary h-full w-1/3 rounded-lg">
|
<div class="bg-bg-secondary h-full w-1/3 rounded-lg">
|
||||||
<section id="quest" class="h-2/3 p-3">
|
<section id="quest" class="h-full p-3 overflow-auto">
|
||||||
<QuestPart />
|
<QuestPart />
|
||||||
</section>
|
</section>
|
||||||
<hr class="border-text-tertiary border" />
|
<!-- <hr class="border-border border" />
|
||||||
<section id="cmdlist" class="h-1/3">
|
<section id="cmdlist" class="h-1/3">
|
||||||
<CommandlistPart />
|
<CommandlistPart />
|
||||||
</section>
|
</section> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- Terminal and Hint -->
|
<!-- Terminal and Hint -->
|
||||||
<div class="bg-bg h-full w-1/3 rounded-lg">
|
<div class="bg-bg h-full w-1/3 rounded-lg">
|
||||||
<section id="terminal" class="h-3/5">
|
<section id="terminal" class="h-3/5 overflow-hidden">
|
||||||
<TerminalPart />
|
<TerminalPart />
|
||||||
</section>
|
</section>
|
||||||
|
<hr class="border-border border" />
|
||||||
<section id="hint" class="h-2/5">
|
<section id="hint" class="h-2/5">
|
||||||
<HintPart />
|
<HintPart />
|
||||||
</section>
|
</section>
|
||||||
@@ -91,4 +94,3 @@ onMounted(async () => {
|
|||||||
<CompleteModal v-if="completed" />
|
<CompleteModal v-if="completed" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script></script>
|
|
||||||
@@ -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>
|
||||||
@@ -15,22 +15,27 @@ const handleLogin = async ({ username, password, success, error }) => {
|
|||||||
if (isSuccess) success()
|
if (isSuccess) success()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
error('Too many requests. Try again in 2 minutes.')
|
// error('Too many requests. Try again in 2 minutes.')
|
||||||
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof UnauthorizedError) {
|
if (err instanceof UnauthorizedError) {
|
||||||
error('Wrong username or password.')
|
// error('Wrong username or password.')
|
||||||
|
error('錯誤的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
error('Invalid username or password.')
|
// error('Invalid username or password.')
|
||||||
|
error('無效的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
error('Something went wrong.')
|
// error('Something went wrong.')
|
||||||
|
error('出了點問題。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen">
|
<div class="w-screen h-screen">
|
||||||
<Background class="w-full h-full" />
|
<Background class="w-full h-full" />
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import QuestMap from '../components/QuestMap.vue'
|
||||||
|
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-[6vh] w-full">
|
||||||
|
<GameHeaderPart title="關卡地圖" />
|
||||||
|
<!-- <p
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Quest Map
|
||||||
|
Unix編年史
|
||||||
|
</p> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
|
||||||
|
<QuestMap />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -17,11 +17,13 @@ function handleRegister({ username, email, password, success, error }) {
|
|||||||
.then(success)
|
.then(success)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
error('Too many requests. Try again in 2 minutes.')
|
// error('Too many requests. Try again in 2 minutes.')
|
||||||
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof UnauthorizedError) {
|
if (err instanceof UnauthorizedError) {
|
||||||
error('Invalid username or password.')
|
// error('Invalid username or password.')
|
||||||
|
error('無效的帳號名稱或密碼。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof ValidationError) {
|
if (err instanceof ValidationError) {
|
||||||
@@ -29,23 +31,27 @@ function handleRegister({ username, email, password, success, error }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
error('Something went wrong.')
|
// error('Something went wrong.')
|
||||||
|
error('出了點問題。')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function check({ username, email, password, error }) {
|
async function check({ username, email, password, error }) {
|
||||||
if (username && !isValidUsername(username)) {
|
if (username && !isValidUsername(username)) {
|
||||||
error('Invalid username.')
|
// error('Invalid username.')
|
||||||
|
error('無效的帳號名稱')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (email && !validator.isEmail(email)) {
|
if (email && !validator.isEmail(email)) {
|
||||||
error('Invalid email.')
|
// error('Invalid email.')
|
||||||
|
error('無效的電子郵件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
|
||||||
error(
|
// error(
|
||||||
'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
|
// 'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
|
||||||
)
|
// )
|
||||||
|
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -56,14 +62,17 @@ async function check({ username, email, password, error }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof TooManyRequestsError) {
|
if (err instanceof TooManyRequestsError) {
|
||||||
error('Too many requests. Try again in 2 minutes.')
|
// error('Too many requests. Try again in 2 minutes.')
|
||||||
|
error('太多請求,兩分鐘後再試一次。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
error('Something went wrong.')
|
// error('Something went wrong.')
|
||||||
|
error('出了點問題。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-screen h-screen">
|
<div class="w-screen h-screen">
|
||||||
<Background class="w-full h-full" />
|
<Background class="w-full h-full" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default {
|
|||||||
DEFAULT: '#FFFFFF',
|
DEFAULT: '#FFFFFF',
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
DEFAULT: '#526B4E',
|
DEFAULT: '#4A4B63',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
DEFAULT: '#FF0000',
|
DEFAULT: '#FF0000',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
- players
|
- players
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./logs:/app/logs
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
- players
|
- players
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://localhost:5173
|
- BASE_URL=http://localhost:5173
|
||||||
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
|
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
|
||||||
|
|||||||
+5
-12
@@ -33,6 +33,7 @@ services:
|
|||||||
- ./server/swagger.yaml:/app/server/swagger.yaml
|
- ./server/swagger.yaml:/app/server/swagger.yaml
|
||||||
- ./packages:/app/packages
|
- ./packages:/app/packages
|
||||||
- ./quests:/app/quests
|
- ./quests:/app/quests
|
||||||
|
- ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=http://localhost:5173
|
- BASE_URL=http://localhost:5173
|
||||||
- MONGO_URL=mongodb://db:27017/odyssey-test
|
- MONGO_URL=mongodb://db:27017/odyssey-test
|
||||||
@@ -41,7 +42,6 @@ services:
|
|||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
||||||
- SURVEY_URL=${SURVEY_URL}
|
- SURVEY_URL=${SURVEY_URL}
|
||||||
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
||||||
- HOST_PWD=${PWD}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
- TESTING=true
|
- TESTING=true
|
||||||
- TESTING_USERNAME=alex
|
- TESTING_USERNAME=alex
|
||||||
- TESTING_PASSWORD=Alex1234
|
- TESTING_PASSWORD=Alex1234
|
||||||
|
- HOST_PWD=${PWD}
|
||||||
|
- MOUNT_QUEST=${MOUNT_QUEST}
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
build:
|
build:
|
||||||
@@ -63,8 +65,8 @@ services:
|
|||||||
- '3001:3001'
|
- '3001:3001'
|
||||||
environment:
|
environment:
|
||||||
- MONGO_URL=mongodb://db:27017/odyssey-test
|
- MONGO_URL=mongodb://db:27017/odyssey-test
|
||||||
- USERNAME=user
|
- ADMIN_USERNAME=alex
|
||||||
- PASSWORD=user
|
- ADMIN_PASSWORD=Alex1234
|
||||||
volumes:
|
volumes:
|
||||||
- ./packages/analytics:/app/packages/analytics
|
- ./packages/analytics:/app/packages/analytics
|
||||||
- ./packages/models:/app/packages/models
|
- ./packages/models:/app/packages/models
|
||||||
@@ -78,15 +80,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data/db
|
- ./data:/data/db
|
||||||
|
|
||||||
swagger:
|
|
||||||
image: swaggerapi/swagger-editor:latest
|
|
||||||
ports:
|
|
||||||
- 8080:8080
|
|
||||||
volumes:
|
|
||||||
- ./server/swagger.yaml:/tmp/swagger.yaml
|
|
||||||
environment:
|
|
||||||
- SWAGGER_FILE=/tmp/swagger.yaml
|
|
||||||
|
|
||||||
base:
|
base:
|
||||||
image: linuxodyssey/quest-base
|
image: linuxodyssey/quest-base
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -29,4 +29,38 @@ down() {
|
|||||||
docker compose -f docker-compose.testing.yml down
|
docker compose -f docker-compose.testing.yml down
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dump() {
|
||||||
|
# Define service name and dump directory name
|
||||||
|
SERVICE_NAME="db"
|
||||||
|
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||||
|
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
||||||
|
|
||||||
|
# Fetch credentials from Docker environment variables
|
||||||
|
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
|
||||||
|
echo mongo uri: $MONGO_URL
|
||||||
|
|
||||||
|
# Dump MongoDB data using credentials
|
||||||
|
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
|
||||||
|
|
||||||
|
# Copy the dump from the container to the host
|
||||||
|
# Note: You will need to know the exact container name for this step
|
||||||
|
docker cp ${CONTAINER_ID}:/tmp/$DUMP_NAME . &&
|
||||||
|
|
||||||
|
echo "MongoDB data dumped successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
# Define service name and dump directory name
|
||||||
|
SERVICE_NAME="db"
|
||||||
|
DUMP_NAME=$1
|
||||||
|
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
||||||
|
|
||||||
|
# Copy the dump from the host to the container
|
||||||
|
docker cp $DUMP_NAME ${CONTAINER_ID}:/tmp &&
|
||||||
|
|
||||||
|
# Dump MongoDB data using credentials
|
||||||
|
docker compose exec -T $SERVICE_NAME sh -c "mongorestore --gzip --archive=/tmp/$DUMP_NAME" &&
|
||||||
|
|
||||||
|
echo "MongoDB data dumped successfully."
|
||||||
|
}
|
||||||
$@
|
$@
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"@linux-odyssey/models": "*",
|
"@linux-odyssey/models": "*",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"express-handlebars": "^7.1.2"
|
"express-handlebars": "^7.1.2",
|
||||||
|
"mongoose": "^8.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
File renamed without changes.
@@ -1,9 +1,24 @@
|
|||||||
|
/* eslint-disable object-shorthand */
|
||||||
|
import Pagination from '../models/pagination.js'
|
||||||
import { errorCommands } from '../models/commands.js'
|
import { errorCommands } from '../models/commands.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export async function commandListController(req, res) {
|
export async function commandListController(req, res) {
|
||||||
const payload = {
|
const { nextKey, order } = req.query
|
||||||
errorCommands: await errorCommands(),
|
const itemsPerPage = 100
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||||
|
const payload = await errorCommands(pagination)
|
||||||
|
const newNextKey = payload[payload.length - 1]?._id
|
||||||
|
|
||||||
|
res.render('commands', {
|
||||||
|
errorCommands: payload,
|
||||||
|
nextKey: newNextKey,
|
||||||
|
order: pagination.getOrder(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send('Error fetching data')
|
||||||
}
|
}
|
||||||
res.render('commands', payload)
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
import { userCount } from '../models/users.js'
|
import { userCount } from '../models/users.js'
|
||||||
import { sessionCount, sessionCompleted } from '../models/sessions.js'
|
import { questList } from '../models/quests.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export async function homeController(req, res) {
|
export async function homeController(req, res) {
|
||||||
|
const quests = await questList()
|
||||||
|
const [totalQuests, totalCompleted] = quests.reduce(
|
||||||
|
([total, completed], quest) => [
|
||||||
|
total + quest.started,
|
||||||
|
completed + quest.completed,
|
||||||
|
],
|
||||||
|
[0, 0]
|
||||||
|
)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: 'Linux Odyssey Admin',
|
title: 'Linux Odyssey Admin',
|
||||||
userCount: await userCount(),
|
userCount: await userCount(),
|
||||||
sessionCount: await sessionCount(),
|
totalQuests,
|
||||||
sessionCompleted: await sessionCompleted(),
|
totalCompleted,
|
||||||
}
|
}
|
||||||
payload.completedRate =
|
payload.completedRate =
|
||||||
Math.round((payload.sessionCompleted / payload.sessionCount) * 10000) / 100
|
Math.round((payload.totalCompleted / payload.totalQuests) * 10000) / 100
|
||||||
res.render('home', payload)
|
res.render('home', payload)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { questList } from '../models/quests.js'
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export async function questListController(req, res) {
|
||||||
|
const quests = await questList()
|
||||||
|
quests.forEach((quest) => {
|
||||||
|
quest.failed = quest.started - quest.completed
|
||||||
|
})
|
||||||
|
res.render('quests', { quests })
|
||||||
|
}
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
|
import Pagination from '../models/pagination.js'
|
||||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
import { sessionList, sessionDetail } from '../models/sessions.js'
|
||||||
|
|
||||||
export async function sessionListController(req, res) {
|
export async function sessionListController(req, res) {
|
||||||
const sessions = await sessionList()
|
const { nextKey, order } = req.query
|
||||||
res.render('sessions', { sessions })
|
const itemsPerPage = 50
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||||
|
const sessions = await sessionList(pagination)
|
||||||
|
const newNextKey = sessions[sessions.length - 1]?._id
|
||||||
|
|
||||||
|
res.render('sessions', {
|
||||||
|
sessions,
|
||||||
|
nextKey: newNextKey,
|
||||||
|
order: pagination.getOrder(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
res.status(500).send('Error fetching session data')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sessionDetailController(req, res) {
|
export async function sessionDetailController(req, res) {
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
import Pagination from '../models/pagination.js'
|
||||||
import { userList } from '../models/users.js'
|
import { userList } from '../models/users.js'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
|
||||||
export async function userListController(req, res) {
|
export async function userListController(req, res) {
|
||||||
res.render('users', { users: await userList() })
|
const { nextKey, order } = req.query
|
||||||
|
const itemsPerPage = 50
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagination = new Pagination(order, itemsPerPage, nextKey)
|
||||||
|
const users = await userList(pagination)
|
||||||
|
const newNextKey = users[users.length - 1]?._id
|
||||||
|
|
||||||
|
res.render('users', {
|
||||||
|
users,
|
||||||
|
nextKey: newNextKey,
|
||||||
|
order: pagination.getOrder(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send('Error fetching user data')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,44 @@
|
|||||||
import { Command } from '@linux-odyssey/models'
|
import { Command } from '@linux-odyssey/models'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export async function errorCommands() {
|
export async function errorCommands(pagination) {
|
||||||
const commands = await Command.find({
|
const commands = await Command.aggregate([
|
||||||
$and: [
|
pagination.match('_id'),
|
||||||
{ error: { $exists: true } },
|
{
|
||||||
{ error: { $ne: null } },
|
$match: {
|
||||||
{ error: { $ne: '' } },
|
$and: [
|
||||||
],
|
{ error: { $exists: true } },
|
||||||
})
|
{ error: { $ne: null } },
|
||||||
.populate({
|
{ error: { $ne: '' } },
|
||||||
path: 'session',
|
],
|
||||||
populate: { path: 'user' },
|
},
|
||||||
})
|
},
|
||||||
.exec()
|
{
|
||||||
return commands.map(({ command, error, createdAt, session }) => {
|
$lookup: {
|
||||||
|
from: 'sessions', // 替換成 'session' 對應的集合名稱
|
||||||
|
localField: 'session', // 替換成 'session' 對應的字段名稱
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'session',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$session' },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'users', // 替換成 'user' 對應的集合名稱
|
||||||
|
localField: 'session.user', // 替換成 'user' 對應的字段名稱
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'session.user',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: '$session.user' },
|
||||||
|
pagination.sort('_id'),
|
||||||
|
pagination.limit(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return commands.map(({ _id, command, error, createdAt, session }) => {
|
||||||
const { user, quest } = session
|
const { user, quest } = session
|
||||||
return {
|
return {
|
||||||
|
_id,
|
||||||
command: command.slice(0, 20),
|
command: command.slice(0, 20),
|
||||||
error: error.slice(0, 50),
|
error: error.slice(0, 50),
|
||||||
createdAt: createdAt?.toLocaleString(),
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* eslint-disable lines-between-class-members */
|
||||||
|
/* eslint-disable class-methods-use-this */
|
||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
export const DESC = 'desc'
|
||||||
|
export const ASC = 'asc'
|
||||||
|
|
||||||
|
class Asc {
|
||||||
|
getOperator() {
|
||||||
|
return '$gt'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return ASC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Desc {
|
||||||
|
getOperator() {
|
||||||
|
return '$lt'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder() {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return DESC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Pagination {
|
||||||
|
nextKey = null
|
||||||
|
itemsPerPage = 50
|
||||||
|
order = null
|
||||||
|
|
||||||
|
constructor(order, itemsPerPage = 50, nextKey = null) {
|
||||||
|
this.itemsPerPage = itemsPerPage
|
||||||
|
if (!order || order === DESC) {
|
||||||
|
this.order = new Desc()
|
||||||
|
} else if (order === ASC) {
|
||||||
|
this.order = new Asc()
|
||||||
|
} else {
|
||||||
|
throw new Error('order must be asc or desc')
|
||||||
|
}
|
||||||
|
if (nextKey) {
|
||||||
|
this.nextKey = new mongoose.Types.ObjectId(nextKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match(field) {
|
||||||
|
const stage = this.nextKey
|
||||||
|
? { [field]: { [this.order.getOperator()]: this.nextKey } }
|
||||||
|
: {}
|
||||||
|
return { $match: stage }
|
||||||
|
}
|
||||||
|
|
||||||
|
sort(field) {
|
||||||
|
return { $sort: { [field]: this.order.getOrder() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
limit() {
|
||||||
|
return { $limit: this.itemsPerPage }
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder() {
|
||||||
|
return this.order.getName()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* eslint-disable object-shorthand */
|
||||||
|
/* eslint-disable func-names */
|
||||||
|
import { UserProfile } from '@linux-odyssey/models'
|
||||||
|
|
||||||
|
export function questList() {
|
||||||
|
return UserProfile.aggregate([
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
progress: {
|
||||||
|
$objectToArray: '$progress',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$unwind: '$progress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$progress.v.quest',
|
||||||
|
started: {
|
||||||
|
$sum: 1,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
$sum: {
|
||||||
|
$cond: {
|
||||||
|
if: { $eq: ['$progress.v.completed', true] },
|
||||||
|
then: 1,
|
||||||
|
else: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
_id: 1,
|
||||||
|
started: 1,
|
||||||
|
completed: 1,
|
||||||
|
completedRate: {
|
||||||
|
$cond: {
|
||||||
|
if: { $eq: ['$started', 0] },
|
||||||
|
then: 0,
|
||||||
|
else: {
|
||||||
|
$round: [
|
||||||
|
{ $multiply: [{ $divide: ['$completed', '$started'] }, 100] },
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$sort: {
|
||||||
|
completed: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -1,28 +1,5 @@
|
|||||||
import { Command, Session } from '@linux-odyssey/models'
|
import { Command, Session } from '@linux-odyssey/models'
|
||||||
|
|
||||||
export function sessionCount() {
|
|
||||||
return Session.find().count()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionCompleted() {
|
|
||||||
return Session.find({ status: 'finished' }).count()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function averageTimeUsage() {
|
|
||||||
return Session.aggregate([
|
|
||||||
{
|
|
||||||
$group: {
|
|
||||||
_id: '$quest',
|
|
||||||
average: {
|
|
||||||
$avg: {
|
|
||||||
$subtract: ['$finishedAt', '$createdAt'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(time) {
|
function formatTime(time) {
|
||||||
const seconds = Math.floor(time / 1000)
|
const seconds = Math.floor(time / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -30,8 +7,9 @@ function formatTime(time) {
|
|||||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sessionList() {
|
export async function sessionList(pagination) {
|
||||||
const sessions = await Session.aggregate([
|
const sessions = await Session.aggregate([
|
||||||
|
pagination.match('_id'),
|
||||||
{
|
{
|
||||||
$lookup: {
|
$lookup: {
|
||||||
from: 'users',
|
from: 'users',
|
||||||
@@ -49,7 +27,10 @@ export async function sessionList() {
|
|||||||
as: 'commands',
|
as: 'commands',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
pagination.sort('_id'),
|
||||||
|
pagination.limit(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return sessions.map(
|
return sessions.map(
|
||||||
({
|
({
|
||||||
_id,
|
_id,
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export function userCount() {
|
|||||||
return User.find().count()
|
return User.find().count()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userList() {
|
export async function userList(pagination) {
|
||||||
const users = await Session.aggregate([
|
const users = await Session.aggregate([
|
||||||
|
pagination.match('user'),
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: '$user',
|
_id: '$user',
|
||||||
@@ -29,11 +30,14 @@ export async function userList() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ $unwind: '$userData' },
|
{ $unwind: '$userData' },
|
||||||
{ $sort: { 'userData.createdAt': 1 } },
|
pagination.sort('userData._id'),
|
||||||
|
pagination.limit(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return users.map(({ userData, count, lastActivityAt }) => {
|
return users.map(({ userData, count, lastActivityAt }) => {
|
||||||
const { username, email, createdAt } = userData
|
const { _id, username, email, createdAt } = userData
|
||||||
return {
|
return {
|
||||||
|
_id,
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
createdAt: createdAt?.toLocaleString(),
|
createdAt: createdAt?.toLocaleString(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
|
|
||||||
import { homeController } from './controllers/index.js'
|
import { homeController } from './controllers/index.js'
|
||||||
import { userListController } from './controllers/users.js'
|
import { userListController } from './controllers/users.js'
|
||||||
|
import { questListController } from './controllers/quests.js'
|
||||||
import { commandListController } from './controllers/commands.js'
|
import { commandListController } from './controllers/commands.js'
|
||||||
import {
|
import {
|
||||||
sessionDetailController,
|
sessionDetailController,
|
||||||
@@ -12,6 +13,7 @@ const router = Router()
|
|||||||
|
|
||||||
router.get('/', homeController)
|
router.get('/', homeController)
|
||||||
router.get('/users', userListController)
|
router.get('/users', userListController)
|
||||||
|
router.get('/quests', questListController)
|
||||||
router.get('/commands', commandListController)
|
router.get('/commands', commandListController)
|
||||||
router.get('/sessions', sessionListController)
|
router.get('/sessions', sessionListController)
|
||||||
router.get('/sessions/:id', sessionDetailController)
|
router.get('/sessions/:id', sessionDetailController)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import Pagination, { ASC, DESC } from '../src/models/pagination.js'
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('should failed when not provide order', () => {
|
||||||
|
expect(new Pagination('').getOrder()).toBe(DESC)
|
||||||
|
expect(() => new Pagination('Hahaha')).toThrow()
|
||||||
|
})
|
||||||
|
it('should return correct limit', () => {
|
||||||
|
const pagination = new Pagination(DESC, 10)
|
||||||
|
|
||||||
|
expect(pagination.limit()).toMatchObject({ $limit: 10 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('desc should return correct order', async () => {
|
||||||
|
const pagination = new Pagination(DESC, 10)
|
||||||
|
|
||||||
|
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: -1 } })
|
||||||
|
expect(pagination.sort('time')).toMatchObject({ $sort: { time: -1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asc should return correct order', async () => {
|
||||||
|
const pagination = new Pagination(ASC, 10)
|
||||||
|
|
||||||
|
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: 1 } })
|
||||||
|
expect(pagination.sort('time')).toMatchObject({ $sort: { time: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct match stage for empty nextKey', async () => {
|
||||||
|
const pagination = new Pagination(DESC, 10)
|
||||||
|
|
||||||
|
expect(pagination.match('_id')).toMatchObject({ $match: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct match stage for empty nextKey', async () => {
|
||||||
|
const nextKeyStr = '656c7fea28a4c087bfcefe12'
|
||||||
|
const pagination = new Pagination(DESC, 10, nextKeyStr)
|
||||||
|
const nextKey = new mongoose.Types.ObjectId(nextKeyStr)
|
||||||
|
|
||||||
|
expect(pagination.match('_id')).toMatchObject({
|
||||||
|
$match: {
|
||||||
|
_id: {
|
||||||
|
$lt: nextKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<h1>Commands</h1>
|
<h1>Commands</h1>
|
||||||
<div>
|
<div>
|
||||||
<h2>Error commands</h2>
|
<h2>Error commands</h2>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/commands?order=asc'>Ascending</a>
|
||||||
|
<a href='/commands?order=desc'>Descending</a>
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Command</th>
|
<th>Command</th>
|
||||||
@@ -21,4 +25,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/commands?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<h1>Welcome to {{title}}</h1>
|
<h1>Welcome to {{title}}</h1>
|
||||||
<div>
|
<div>
|
||||||
<h2>Users</h2>
|
<h2>Overview</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Registered Users</th>
|
<th>Registered Users</th>
|
||||||
<td>{{userCount}}</td>
|
<td>{{userCount}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Session Count</th>
|
<th>Total Started Quests</th>
|
||||||
<td>{{sessionCount}}</td>
|
<td>{{totalQuests}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Completed Session</th>
|
<th>Completed Quests</th>
|
||||||
<td>{{sessionCompleted}}</td>
|
<td>{{totalCompleted}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Completed Rate</th>
|
<th>Completed Rate</th>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href='/'>Home</a></li>
|
<li><a href='/'>Home</a></li>
|
||||||
<li><a href='/users'>Users</a></li>
|
<li><a href='/users'>Users</a></li>
|
||||||
|
<li><a href='/quests'>Quests</a></li>
|
||||||
<li><a href='/commands'>Commands</a></li>
|
<li><a href='/commands'>Commands</a></li>
|
||||||
<li><a href='/sessions'>Sessions</a></li>
|
<li><a href='/sessions'>Sessions</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<h1>Quests</h1>
|
||||||
|
<div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Id</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Failed</th>
|
||||||
|
<th>Completed Rate</th>
|
||||||
|
</tr>
|
||||||
|
{{#each quests}}
|
||||||
|
<tr>
|
||||||
|
<td>{{_id}}</td>
|
||||||
|
<td>{{started}}</td>
|
||||||
|
<td>{{completed}}</td>
|
||||||
|
<td>{{failed}}</td>
|
||||||
|
<td>{{completedRate}} %</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</table>
|
||||||
@@ -36,20 +36,20 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Command</th>
|
<th>Stage</th>
|
||||||
<th>PWD</th>
|
<th>PWD</th>
|
||||||
|
<th>Input</th>
|
||||||
<th>Output</th>
|
<th>Output</th>
|
||||||
<th>Error</th>
|
<th>Error</th>
|
||||||
<th>Stage</th>
|
<th>Input Time</th>
|
||||||
<th>Created At</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
{{#each commands}}
|
{{#each commands}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{command}}</td>
|
<td>{{stage}}</td>
|
||||||
<td>{{pwd}}</td>
|
<td>{{pwd}}</td>
|
||||||
|
<td>{{command}}</td>
|
||||||
<td>{{output}}</td>
|
<td>{{output}}</td>
|
||||||
<td>{{error}}</td>
|
<td>{{error}}</td>
|
||||||
<td>{{stage}}</td>
|
|
||||||
<td>{{createdAt}}</td>
|
<td>{{createdAt}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
<h1>Sessions</h1>
|
<h1>Sessions</h1>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/sessions?order=asc'>Ascending</a>
|
||||||
|
<a href='/sessions?order=desc'>Descending</a>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Quest</th>
|
<th>Quest</th>
|
||||||
<th></th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Created At</th>
|
<th>Created At</th>
|
||||||
<th>Finished At</th>
|
<th>Finished At</th>
|
||||||
<th>Used Time</th>
|
<th>Used Time</th>
|
||||||
<th>Command Count</th>
|
<th>Command Count</th>
|
||||||
|
<th>Session</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{#each sessions}}
|
{{#each sessions}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{user}}</td>
|
<td>{{user}}</td>
|
||||||
<td>{{quest}}</td>
|
<td>{{quest}}</td>
|
||||||
<td><a href='/sessions/{{_id}}'>Detail</a></td>
|
|
||||||
<td>{{status}}</td>
|
<td>{{status}}</td>
|
||||||
<td>{{createdAt}}</td>
|
<td>{{createdAt}}</td>
|
||||||
<td>{{finishedAt}}</td>
|
<td>{{finishedAt}}</td>
|
||||||
<td>{{usedTime}}</td>
|
<td>{{usedTime}}</td>
|
||||||
<td>{{commandCount}}</td>
|
<td>{{commandCount}}</td>
|
||||||
|
<td><a href='/sessions/{{_id}}'>Detail</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
||||||
|
</div>
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
<h1>Users List</h1>
|
<h1>Users List</h1>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/users?order=asc'>Ascending</a>
|
||||||
|
<a href='/users?order=desc'>Descending</a>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Email</th>
|
|
||||||
<th>Registration Method</th>
|
|
||||||
<th>Registration Time</th>
|
|
||||||
<th>Sessions Count</th>
|
|
||||||
<th>Last Activity</th>
|
<th>Last Activity</th>
|
||||||
|
<th>Sessions Count</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Registration Time</th>
|
||||||
|
<th>Registration Method</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{#each users}}
|
{{#each users}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{username}}</td>
|
<td href='/users/{{_id}}'>{{username}}</td>
|
||||||
<td>{{email}}</td>
|
|
||||||
<td>{{method}}</td>
|
|
||||||
<td>{{createdAt}}</td>
|
|
||||||
<td>{{sessionCount}}</td>
|
|
||||||
<td>{{lastActivityAt}}</td>
|
<td>{{lastActivityAt}}</td>
|
||||||
|
<td>{{sessionCount}}</td>
|
||||||
|
<td>{{email}}</td>
|
||||||
|
<td>{{createdAt}}</td>
|
||||||
|
<td>{{method}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='pagination'>
|
||||||
|
<a href='/users?nextKey={{nextKey}}&order={{order}}'>Next</a>
|
||||||
|
</div>
|
||||||
+43
-33
@@ -3,9 +3,26 @@ const fs = require('fs').promises
|
|||||||
const { exit } = require('process')
|
const { exit } = require('process')
|
||||||
const minimist = require('minimist')
|
const minimist = require('minimist')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const { colorize, printResponses } = require('./print.js')
|
const { colorize, printResponses, printHints } = require('./print.js')
|
||||||
const discoverFiles = require('./discover.js')
|
const discoverFiles = require('./discover.js')
|
||||||
|
|
||||||
|
const {
|
||||||
|
API_ENDPOINT,
|
||||||
|
TOKEN,
|
||||||
|
PWD,
|
||||||
|
CMD_NAME,
|
||||||
|
CMD_OUTPUT_FILE,
|
||||||
|
CMD_ERROR_FILE,
|
||||||
|
CMD_EXIT_CODE,
|
||||||
|
} = process.env
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: `${API_ENDPOINT}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TOKEN}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
async function readOrNone(file) {
|
async function readOrNone(file) {
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(file, 'utf8')
|
return await fs.readFile(file, 'utf8')
|
||||||
@@ -29,24 +46,21 @@ async function handleCommand(command) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function handleResponse({ responses, hints, end }) {
|
||||||
const {
|
if (responses) {
|
||||||
API_ENDPOINT,
|
await printResponses(responses, 90)
|
||||||
TOKEN,
|
}
|
||||||
PWD,
|
if (hints) {
|
||||||
CMD_NAME,
|
await printHints(hints, 90)
|
||||||
CMD_OUTPUT_FILE,
|
}
|
||||||
CMD_ERROR_FILE,
|
if (end) {
|
||||||
CMD_EXIT_CODE,
|
console.log(colorize('Quest completed!', 'blue'))
|
||||||
} = process.env
|
}
|
||||||
if (!CMD_NAME) exit(0)
|
await api.post('/commands/completed')
|
||||||
|
}
|
||||||
|
|
||||||
const api = axios.create({
|
async function main() {
|
||||||
baseURL: `${API_ENDPOINT}/api/v1`,
|
if (!CMD_NAME) exit(0)
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${TOKEN}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = await handleCommand(CMD_NAME)
|
const params = await handleCommand(CMD_NAME)
|
||||||
|
|
||||||
@@ -62,20 +76,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/commands', payload)
|
const res = await api.post('/commands', payload)
|
||||||
if (!res.data) return
|
if (!res.data || !res.data.responses) return
|
||||||
const { responses, hints, end } = res.data
|
await handleResponse(res.data)
|
||||||
if (responses) {
|
|
||||||
await printResponses(responses, 60)
|
|
||||||
await api.post('/commands/completed')
|
|
||||||
}
|
|
||||||
if (hints) {
|
|
||||||
for (const hint of hints) {
|
|
||||||
console.log(colorize(hint, 'yellow'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (end) {
|
|
||||||
console.log(colorize('Quest completed!', 'blue'))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
console.error(err.response.data)
|
console.error(err.response.data)
|
||||||
@@ -85,4 +87,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().then(exit)
|
main()
|
||||||
|
.then(exit)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
||||||
|
)
|
||||||
|
console.error(err)
|
||||||
|
exit(1)
|
||||||
|
})
|
||||||
@@ -34,12 +34,16 @@ async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
|||||||
|
|
||||||
async function discoverFiles(argv) {
|
async function discoverFiles(argv) {
|
||||||
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
||||||
const result = await Promise.all(
|
try {
|
||||||
targetPath
|
const result = await Promise.all(
|
||||||
.map((p) => path.resolve(process.cwd(), p))
|
targetPath
|
||||||
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
.map((p) => path.resolve(process.cwd(), p))
|
||||||
)
|
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
||||||
return { discover: result.flat() }
|
)
|
||||||
|
return { discover: result.flat() }
|
||||||
|
} catch (err) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = discoverFiles
|
module.exports = discoverFiles
|
||||||
@@ -6,6 +6,7 @@ const RETURN_SYMBOL = '\u21B5'
|
|||||||
|
|
||||||
// ANSI color map
|
// ANSI color map
|
||||||
const ANSI_COLORS = {
|
const ANSI_COLORS = {
|
||||||
|
default: 0,
|
||||||
black: 30,
|
black: 30,
|
||||||
red: 31,
|
red: 31,
|
||||||
green: 32,
|
green: 32,
|
||||||
@@ -16,10 +17,13 @@ const ANSI_COLORS = {
|
|||||||
white: 37,
|
white: 37,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function colorCode(color) {
|
||||||
|
return `\x1b[${ANSI_COLORS[color]}m`
|
||||||
|
}
|
||||||
|
|
||||||
function colorize(content, color) {
|
function colorize(content, color) {
|
||||||
const colorCode = ANSI_COLORS[color]
|
if (!color) return content
|
||||||
if (!colorCode) return content
|
return `${colorCode(color)}${content}${colorCode('default')}`
|
||||||
return `\x1b[${colorCode}m${content}\x1b[0m`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
process.stdin.setRawMode(true)
|
||||||
@@ -36,8 +40,25 @@ function waitForEnter() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLine(content, color) {
|
||||||
|
let result = colorCode(color)
|
||||||
|
let codeBlock = false
|
||||||
|
for (const c of content) {
|
||||||
|
switch (c) {
|
||||||
|
case '`':
|
||||||
|
result += colorCode(codeBlock ? color : 'green')
|
||||||
|
codeBlock = !codeBlock
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
result += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result + colorCode('default') + RETURN_SYMBOL
|
||||||
|
}
|
||||||
|
|
||||||
function printLine(content, delay, color) {
|
function printLine(content, delay, color) {
|
||||||
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
|
const printContent = formatLine(content, color)
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let i = 0
|
let i = 0
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
@@ -52,6 +73,7 @@ function printLine(content, delay, color) {
|
|||||||
|
|
||||||
waitForEnter().then(() => {
|
waitForEnter().then(() => {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
|
if (i >= printContent.length) return
|
||||||
process.stdout.write(printContent.slice(i))
|
process.stdout.write(printContent.slice(i))
|
||||||
process.stdout.write('\n')
|
process.stdout.write('\n')
|
||||||
resolve()
|
resolve()
|
||||||
@@ -61,25 +83,33 @@ function printLine(content, delay, color) {
|
|||||||
|
|
||||||
async function printResponses(responses, delay = 100) {
|
async function printResponses(responses, delay = 100) {
|
||||||
console.log()
|
console.log()
|
||||||
console.log(colorize('-------------', 'white'))
|
console.log(colorize('==============================', 'green'))
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
const { type, content, speaker, color } = response
|
const { type, content, speaker, color } = response
|
||||||
if (type === 'narrative') {
|
if (type === 'narrative') {
|
||||||
for (const line of content) {
|
for (const line of content) {
|
||||||
await printLine(line, delay, color || 'white')
|
await printLine(line, delay, color || 'cyan')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === 'dialogue') {
|
if (type === 'dialogue') {
|
||||||
console.log(`${speaker}:`)
|
console.log(colorize(`${speaker}:`, 'green'))
|
||||||
for (const line of content) {
|
for (const line of content) {
|
||||||
await printLine(` ${line}`, delay, color || 'green')
|
await printLine(` ${line}`, delay, color || 'cyan')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(colorize('------------------------------', 'green'))
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printHints(hints, delay = 100) {
|
||||||
|
for (const hint of hints) {
|
||||||
|
await printLine(hint, delay, 'yellow')
|
||||||
}
|
}
|
||||||
rl.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
printResponses,
|
printResponses,
|
||||||
|
printHints,
|
||||||
colorize,
|
colorize,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
import conditionSchema from './condition.js'
|
||||||
|
import responseSchema from './response.js'
|
||||||
|
import hintSchema from './hint.js'
|
||||||
|
|
||||||
|
const commandResponderSchema = new Schema({
|
||||||
|
condition: {
|
||||||
|
type: conditionSchema,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
type: [responseSchema],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
commandResponderSchema.add(hintSchema)
|
||||||
|
|
||||||
|
export default commandResponderSchema
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
const fileConditionSchema = new Schema({
|
||||||
|
path: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
type: String,
|
||||||
|
exists: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditionSchema = new Schema({
|
||||||
|
command: [String],
|
||||||
|
output: [String],
|
||||||
|
error: [String],
|
||||||
|
pwd: [String],
|
||||||
|
files: [fileConditionSchema],
|
||||||
|
})
|
||||||
|
|
||||||
|
conditionSchema.add({
|
||||||
|
or: [conditionSchema],
|
||||||
|
not: conditionSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default conditionSchema
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
import commandResponderSchema from './commandResponder.js'
|
||||||
|
|
||||||
|
const exceptionSchema = new Schema({})
|
||||||
|
|
||||||
|
exceptionSchema.add(commandResponderSchema)
|
||||||
|
|
||||||
|
export default exceptionSchema
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
import exceptionSchema from './exception.js'
|
||||||
|
import requirementSchema from './requirement.js'
|
||||||
|
|
||||||
|
const globalExceptionSchema = new Schema({})
|
||||||
|
|
||||||
|
globalExceptionSchema.add(exceptionSchema)
|
||||||
|
globalExceptionSchema.add(requirementSchema)
|
||||||
|
|
||||||
|
export default globalExceptionSchema
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
const hintSchema = new Schema({
|
||||||
|
hints: {
|
||||||
|
type: [String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default hintSchema
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
"license": "GPL",
|
"license": "GPL",
|
||||||
"exports": "./index.js",
|
"exports": "./index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mongoose": "^7.6.0"
|
"mongoose": "^8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,72 +1,6 @@
|
|||||||
import { model, Schema } from 'mongoose'
|
import { model, Schema } from 'mongoose'
|
||||||
|
import stageSchema from './stage.js'
|
||||||
const responseSchema = new Schema({
|
import globalExceptionSchema from './globalException.js'
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
content: [String],
|
|
||||||
speaker: String,
|
|
||||||
color: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileConditionSchema = new Schema({
|
|
||||||
path: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
type: String,
|
|
||||||
exists: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const conditionSchema = new Schema({
|
|
||||||
command: [String],
|
|
||||||
output: [String],
|
|
||||||
error: [String],
|
|
||||||
pwd: [String],
|
|
||||||
files: [fileConditionSchema],
|
|
||||||
})
|
|
||||||
|
|
||||||
conditionSchema.add({
|
|
||||||
or: [conditionSchema],
|
|
||||||
not: conditionSchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
const stageSchema = new Schema({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
requirements: [
|
|
||||||
{
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
repeatable: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
condition: {
|
|
||||||
type: conditionSchema,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
type: [responseSchema],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
hints: {
|
|
||||||
type: [String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Quest = model(
|
const Quest = model(
|
||||||
'Quest',
|
'Quest',
|
||||||
@@ -100,6 +34,10 @@ const Quest = model(
|
|||||||
type: [stageSchema],
|
type: [stageSchema],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
exceptions: {
|
||||||
|
type: [globalExceptionSchema],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
const requirementSchema = new Schema({
|
||||||
|
requirements: [
|
||||||
|
{
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default requirementSchema
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
const responseSchema = new Schema({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ['narrative', 'dialogue'],
|
||||||
|
default: 'dialogue',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: [String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
speaker: { type: String, default: 'Ada' },
|
||||||
|
color: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default responseSchema
|
||||||
@@ -35,10 +35,12 @@ const Session = model(
|
|||||||
user: {
|
user: {
|
||||||
type: Schema.ObjectId,
|
type: Schema.ObjectId,
|
||||||
ref: 'User',
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
quest: {
|
quest: {
|
||||||
type: String,
|
type: String,
|
||||||
ref: 'Quest',
|
ref: 'Quest',
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
containerId: String,
|
containerId: String,
|
||||||
status: {
|
status: {
|
||||||
@@ -54,7 +56,7 @@ const Session = model(
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
tasks: [taskSchema],
|
tasks: [taskSchema],
|
||||||
hints: [String],
|
hints: [[String]],
|
||||||
graph: {
|
graph: {
|
||||||
type: nodeSchema,
|
type: nodeSchema,
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
import commandResponderSchema from './commandResponder.js'
|
||||||
|
import stageExceptionSchema from './stageException.js'
|
||||||
|
import requirementSchema from './requirement.js'
|
||||||
|
|
||||||
|
const stageSchema = new Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
exceptions: {
|
||||||
|
type: [stageExceptionSchema],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
stageSchema.add(commandResponderSchema)
|
||||||
|
stageSchema.add(requirementSchema)
|
||||||
|
|
||||||
|
export default stageSchema
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Schema } from 'mongoose'
|
||||||
|
import exceptionSchema from './exception.js'
|
||||||
|
|
||||||
|
const stageExceptionSchema = new Schema({
|
||||||
|
catchAll: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
stageExceptionSchema.add(exceptionSchema)
|
||||||
|
|
||||||
|
export default stageExceptionSchema
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export default class DAG {
|
||||||
|
#nodes = new Map()
|
||||||
|
|
||||||
|
#edges = new Set()
|
||||||
|
|
||||||
|
#layers = []
|
||||||
|
|
||||||
|
constructor(nodes) {
|
||||||
|
this.#nodes = new Map(nodes.map((node) => [node._id, node]))
|
||||||
|
this.#edges = new Set()
|
||||||
|
this.#nodes.forEach((_, id) => {
|
||||||
|
this.#setLayer(id)
|
||||||
|
this.#addEdges(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(id) {
|
||||||
|
return this.#nodes.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodes() {
|
||||||
|
return Array.from(this.#nodes.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdges() {
|
||||||
|
return this.#edges
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdgesArray() {
|
||||||
|
return Array.from(this.#edges.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayers() {
|
||||||
|
return this.#layers
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayer(id) {
|
||||||
|
const node = this.getNode(id)
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`Node ${id} not found`)
|
||||||
|
}
|
||||||
|
return this.#layers[node.layer - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
#setLayer(id) {
|
||||||
|
const node = this.getNode(id)
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`Node ${id} not found`)
|
||||||
|
}
|
||||||
|
if (node.layer !== undefined) {
|
||||||
|
return node.layer
|
||||||
|
}
|
||||||
|
const previousLayer = node.requirements.map((rId) => this.#setLayer(rId))
|
||||||
|
node.layer = Math.max(...previousLayer, 0) + 1
|
||||||
|
if (this.#layers.length < node.layer) {
|
||||||
|
this.#layers.push(1)
|
||||||
|
} else {
|
||||||
|
this.#layers[node.layer - 1] += 1
|
||||||
|
}
|
||||||
|
node.index = this.#layers[node.layer - 1]
|
||||||
|
return node.layer
|
||||||
|
}
|
||||||
|
|
||||||
|
#addEdges(id) {
|
||||||
|
const node = this.getNode(id)
|
||||||
|
if (!node) {
|
||||||
|
throw new Error(`Node ${id} not found`)
|
||||||
|
}
|
||||||
|
node.requirements.forEach((rId) => {
|
||||||
|
this.#edges.add([rId, id])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import DAG from './dag.js'
|
||||||
|
|
||||||
|
describe('dag layers', () => {
|
||||||
|
it('two layer', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getNode('helloworld').layer).toBe(1)
|
||||||
|
expect(dag.getNode('spell').layer).toBe(2)
|
||||||
|
})
|
||||||
|
it('branch', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'discover',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getNode('helloworld').layer).toBe(1)
|
||||||
|
expect(dag.getNode('spell').layer).toBe(2)
|
||||||
|
expect(dag.getNode('discover').layer).toBe(2)
|
||||||
|
})
|
||||||
|
it('merge', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'A',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'B',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'C',
|
||||||
|
requirements: ['A', 'B'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getNode('A').layer).toBe(1)
|
||||||
|
expect(dag.getNode('B').layer).toBe(1)
|
||||||
|
expect(dag.getNode('C').layer).toBe(2)
|
||||||
|
})
|
||||||
|
it('skip', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'A',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'B',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'C',
|
||||||
|
requirements: ['A'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'D',
|
||||||
|
requirements: ['B', 'C'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getNode('A').layer).toBe(1)
|
||||||
|
expect(dag.getNode('B').layer).toBe(1)
|
||||||
|
expect(dag.getNode('C').layer).toBe(2)
|
||||||
|
expect(dag.getNode('D').layer).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dag edges', () => {
|
||||||
|
it('one edge', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getEdges()).toEqual(new Set([['helloworld', 'spell']]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('branch edges', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'discover',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getEdges()).toEqual(
|
||||||
|
new Set([
|
||||||
|
['helloworld', 'discover'],
|
||||||
|
['helloworld', 'spell'],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dag layers', () => {
|
||||||
|
it('two layer', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getLayers()).toEqual([1, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('branch layers', () => {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
_id: 'helloworld',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'spell',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'discover',
|
||||||
|
requirements: ['helloworld'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const dag = new DAG(data)
|
||||||
|
expect(dag.getLayers()).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { isValidEmail, isValidUsername } from './auth.js'
|
export { isValidEmail, isValidUsername } from './auth.js'
|
||||||
export { buildFileCheckCmd } from './commands.js'
|
export { buildFileCheckCmd } from './commands.js'
|
||||||
|
export { default as DAG } from './dag.js'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
echo start
|
||||||
|
echo example
|
||||||
|
echo How are you
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
title: 輸出文字
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,你將會學到:
|
||||||
|
1. 如何使用 `echo` 指令
|
||||||
|
|
||||||
|
準備好就輸入 `echo start` 開始吧!
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- get-started
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 輸入 `echo start` 來開始教程
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^start$
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你剛才使用的指令叫做 `echo`。
|
||||||
|
- '`echo` 指令可以將後方接續的文字輸出到終端機上。'
|
||||||
|
- 例如你輸入 `echo Hello`,終端機就會顯示 `Hello`。
|
||||||
|
- 你可以試著輸入 `echo` 並在後面加上一些文字,然後按下 Enter 鍵,就可以看到終端機上顯示了你剛才輸入的文字。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `echo` 並在後面加上一些文字
|
||||||
|
- ➤ 例如:`echo example`
|
||||||
|
|
||||||
|
- id: echo
|
||||||
|
task: 在終端機上顯示顯示任意文字
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo .*
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你應該可以看到終端機上顯示了你在 `echo` 指令後面的文字。
|
||||||
|
- 接下來,請你試著在終端機上顯示 `How are you?`。
|
||||||
|
hints:
|
||||||
|
- ➤ 利用 `echo` 指令來在終端機上顯示 `How are you?`
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
task: 在終端機上顯示 `How are you?`
|
||||||
|
requirements:
|
||||||
|
- echo
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^[Hh]ow are you\??$
|
||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^[Hh]ow are you\??$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 在 `How are you?` 前面必須加上 `echo`
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^echo$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 在 `echo` 後面還需要加上一些文字
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你成功的在終端機上顯示了 `How are you?`。
|
||||||
|
- 相信你已經了解如何使用 `echo` 指令來顯示文字了。
|
||||||
|
- 之後如果要在終端機顯示文字,記得可以使用 `echo` 指令。
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
- repeatable: true
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ' '
|
||||||
|
hints:
|
||||||
|
- '!! 請勿使用全形空格 !!'
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
title: 輸出文字
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,你將會學到:
|
||||||
|
1. 如何使用 `echo` 指令
|
||||||
|
2. `echo` 指令在遊戲中的用途
|
||||||
|
|
||||||
|
準備好就輸入 `echo start` 開始吧!
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- get-started
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: warning-01
|
||||||
|
repeatable: true
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ' '
|
||||||
|
hints:
|
||||||
|
- '!! 請勿使用全形空格 !!'
|
||||||
|
|
||||||
|
- id: start
|
||||||
|
task: 輸入 `echo start` 來開始教程
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^start$
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你剛才使用的指令叫做 `echo`。
|
||||||
|
- '`echo` 指令可以將後方接續的文字輸出到終端機上。'
|
||||||
|
- 例如你輸入 `echo Hello`,終端機就會顯示 `Hello`。
|
||||||
|
- 你可以試著輸入 `echo` 並在後面加上一些文字,然後按下 Enter 鍵,就可以看到終端機上顯示了你剛才輸入的文字。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `echo` 並在後面加上一些文字
|
||||||
|
- ➤ 例如:`echo example`
|
||||||
|
|
||||||
|
- id: echo
|
||||||
|
task: 在終端機上顯示顯示任意文字
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo .*
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你應該可以看到終端機上顯示了你在 `echo` 指令後面的文字。
|
||||||
|
- 接下來,請你試著在終端機上顯示 `How are you?`。
|
||||||
|
hints:
|
||||||
|
- ➤ 利用 `echo` 指令來在終端機上顯示 `How are you?`
|
||||||
|
|
||||||
|
- id: how-are-you
|
||||||
|
task: 在終端機上顯示 `How are you?`
|
||||||
|
requirements:
|
||||||
|
- echo
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^[Hh]ow are you\??$
|
||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^[Hh]ow are you\??$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 在 `How are you?` 前面必須加上 `echo`
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^echo$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 在 `echo` 後面還需要加上一些文字
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你成功的在終端機上顯示了 `How are you?`。
|
||||||
|
- 相信你已經了解 `echo` 指令的用法了。
|
||||||
|
- 接下來,要像你介紹 `echo` 在遊戲中的特殊功能,那就是詠唱咒語。
|
||||||
|
- 你可以通過輸入 `echo [咒語]` 來詠唱咒語,並產生對應的特殊效果。
|
||||||
|
- 但是要注意,詠唱咒語只有在 Linux Odyssey 中才會有特殊效果。
|
||||||
|
- 在真實的 Linux 環境中,詠唱咒語並不會有任何特殊效果。
|
||||||
|
- 現在,有一段咒語叫做 `HELP`,請你試著使用 `echo` 詠唱這段咒語。
|
||||||
|
hints:
|
||||||
|
- ➤ 試著使用 `echo` 來詠唱 `HELP` 咒語
|
||||||
|
- ➤ 注意:咒語要使用大寫
|
||||||
|
|
||||||
|
- id: help
|
||||||
|
task: 詠唱 `HELP` 咒語
|
||||||
|
requirements:
|
||||||
|
- how-are-you
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^HELP$
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你詠唱了 `HELP` 咒語,一位精靈出現了。
|
||||||
|
- type: dialogue
|
||||||
|
speaker: Unix 精靈
|
||||||
|
content:
|
||||||
|
- 你好,我是 Unix 精靈。
|
||||||
|
- 既然你使用了 `HELP` 咒語呼喚我,就讓我來教你一個咒語吧。
|
||||||
|
- 讓我想想... 好了,我想到了一個咒語,叫做 `WATER`。
|
||||||
|
- 這是施展水魔法的咒語。詠唱這個咒語可以施展水魔法,產生少量的水。
|
||||||
|
- 試著使用 `echo` 詠唱這個咒語吧。
|
||||||
|
hints:
|
||||||
|
- ➤ 詠唱 `WATER` 咒語
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
task: 詠唱 `WATER` 咒語
|
||||||
|
requirements:
|
||||||
|
- help
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- ^WATER$
|
||||||
|
responses:
|
||||||
|
- content:
|
||||||
|
- 你施展了水魔法,一個水球出現在你的手中。
|
||||||
|
- type: dialogue
|
||||||
|
speaker: Unix 精靈
|
||||||
|
content:
|
||||||
|
- 很好,你已經學會了水魔法。
|
||||||
|
- content:
|
||||||
|
- 真厲害,你已經了解如何使用 `echo` 指令,並且學會了詠唱咒語的方法。
|
||||||
|
- 最後複習一下,你可以通過輸入 `echo [文字]` 來在終端機上顯示文字。
|
||||||
|
- 你也可以通過輸入 `echo [咒語]` 來詠唱咒語,並產生對應的特殊效果。
|
||||||
|
- 不要忘了,詠唱咒語只有在 Linux Odyssey 中才會有特殊效果。
|
||||||
|
- 在真實的 Linux 環境中,咒語只會被視為一串文字顯示出來而已。
|
||||||
|
- 以上就是 `echo` 指令的用法,請繼續努力,完成接下來的關卡吧!
|
||||||
|
hints: |
|
||||||
|
複習:
|
||||||
|
通過輸入 `echo [文字]` 來在終端機上顯示文字
|
||||||
|
通過輸入 `echo [咒語]` 來詠唱咒語
|
||||||
|
咒語只有在 Linux Odyssey 中才會有特殊效果
|
||||||
File renamed without changes.
@@ -0,0 +1,12 @@
|
|||||||
|
echo start
|
||||||
|
cat hello.txt
|
||||||
|
cat world.txt
|
||||||
|
echo continue
|
||||||
|
cp hello.txt hello2.txt
|
||||||
|
cat hello2.txt
|
||||||
|
cp world.txt world2.txt
|
||||||
|
cat world2.txt
|
||||||
|
cp -r notes notes2
|
||||||
|
ls notes2
|
||||||
|
cp hello.txt world.txt
|
||||||
|
cat world.txt
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
title: 複製檔案
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,你將會學到:
|
||||||
|
1. 如何用 `cp` 指令複製檔案
|
||||||
|
|
||||||
|
準備好就輸入 `echo start` 開始吧!
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- create
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 輸入 `echo start` 來開始
|
||||||
|
condition:
|
||||||
|
output:
|
||||||
|
- start
|
||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^echo$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你忘記輸入 `start` 了嗎?
|
||||||
|
- 請輸入 `echo start` 來開始
|
||||||
|
- catchAll: true
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 請輸入 `echo start` 來開始
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 今天我們要學習如何複製檔案。
|
||||||
|
- 現在資料夾裡有一個 `hello.txt` 跟一個 `world.txt`,請使用 `ls` 與 `cat` 來查看它們的內容。
|
||||||
|
- 看完之後,使用 `echo continue` 來繼續吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `ls` 來查看資料夾內的檔案
|
||||||
|
- ➤ 使用 `cat hello.txt` 以及 `cat world.txt` 來查看檔案內容
|
||||||
|
- ➤ 如果終端機卡在某個指令無法輸入,請按下 `Ctrl + C` 來取消該指令
|
||||||
|
|
||||||
|
- id: cat-hello
|
||||||
|
task: 查看 `hello.txt` 的內容
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello.txt$
|
||||||
|
output:
|
||||||
|
- Hello
|
||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^cat$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你忘記輸入 `cat` 了嗎?
|
||||||
|
- 請輸入 `cat hello.txt` 來查看 `hello.txt` 的內容
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你忘記輸入 `.txt` 了嗎?
|
||||||
|
- 請輸入 `cat hello.txt` 來查看 `hello.txt` 的內容
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功查看到 `hello.txt` 的內容了!
|
||||||
|
|
||||||
|
- id: cat-world
|
||||||
|
task: 查看 `world.txt` 的內容
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat world.txt$
|
||||||
|
output:
|
||||||
|
- World
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功查看到 `world.txt` 的內容了!
|
||||||
|
|
||||||
|
- id: cat-continue
|
||||||
|
task: 使用 `echo continue` 來繼續
|
||||||
|
requirements:
|
||||||
|
- cat-hello
|
||||||
|
- cat-world
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- echo
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 接下來我們要來學習複製檔案。
|
||||||
|
- '`cp` 這個指令,是 copy 的縮寫,就是複製的意思。'
|
||||||
|
- 我們可以用 `cp [來源檔案] [目的檔案]` 的方式來複製檔案。
|
||||||
|
- 請你使用 `cp` 來複製 `hello.txt` 到 `hello2.txt`。
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cp hello.txt hello2.txt` 來複製 `hello.txt` 到 `hello2.txt`
|
||||||
|
|
||||||
|
- id: cp-hello
|
||||||
|
task: 複製 `hello.txt` 到 `hello2.txt`
|
||||||
|
requirements:
|
||||||
|
- cat-continue
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cp hello.txt hello2.txt$
|
||||||
|
files:
|
||||||
|
- path: hello2.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功複製 `hello.txt` 到 `hello2.txt` 了!
|
||||||
|
- 我們打開來確認一下 `hello2.txt` 的內容吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cat hello2.txt` 來查看 `hello2.txt` 的內容
|
||||||
|
|
||||||
|
- id: cat-hello2
|
||||||
|
requirements:
|
||||||
|
- cp-hello
|
||||||
|
task: 查看 `hello2.txt` 的內容
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello2.txt$
|
||||||
|
output:
|
||||||
|
- Hello
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功查看到 `hello2.txt` 的內容了!
|
||||||
|
- 正如我們所見,`hello2.txt` 的內容跟 `hello.txt` 一模一樣。
|
||||||
|
- 那我們再來試試看複製 `world.txt` 到 `world2.txt` 吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cp world.txt world2.txt` 來複製 `world.txt` 到 `world2.txt`
|
||||||
|
|
||||||
|
- id: cp-world
|
||||||
|
requirements:
|
||||||
|
- cat-hello2
|
||||||
|
task: 複製 `world.txt` 到 `world2.txt`
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cp world.txt world2.txt$
|
||||||
|
files:
|
||||||
|
- path: world2.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功複製 `world.txt` 到 `world2.txt` 了!
|
||||||
|
- 我們打開來確認一下 `world2.txt` 的內容吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cat world2.txt` 來查看 `world2.txt` 的內容
|
||||||
|
|
||||||
|
- id: cat-world2
|
||||||
|
requirements:
|
||||||
|
- cp-world
|
||||||
|
task: 查看 `world2.txt` 的內容
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat world2.txt$
|
||||||
|
output:
|
||||||
|
- World
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功查看到 `world2.txt` 的內容了!
|
||||||
|
- '`world2.txt` 的內容跟 `world.txt` 一模一樣。'
|
||||||
|
- 接下來我們要來學習如何複製資料夾。
|
||||||
|
- 現在目錄下有一個 `notes` 資料夾,裡面有一個 `note.txt` 的檔案。
|
||||||
|
- 我們要能整個資料夾包含裡面的檔案一起複製到 `notes2`。
|
||||||
|
- 跟複製單一檔案不一樣的是,複製資料夾時,我們要加上 `-r` 的選項(recursive)代表將資料夾中的所有檔案都一起複製。
|
||||||
|
- 在使用指令時,如果想要調整指令的行為,可以在指令後面加上選項。
|
||||||
|
- 選項的格式通常是 `-` 加上一個字母,例如 `-r`。
|
||||||
|
- 選項擺放的位置沒有特別的限制,通常會跟在指令的後面,置於檔案名稱的前面。
|
||||||
|
- 請你使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`。
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
|
||||||
|
- ➤ 記得要加上 `-r` 的選項來複製所有檔案
|
||||||
|
|
||||||
|
- id: cp-notes
|
||||||
|
requirements:
|
||||||
|
- cat-world2
|
||||||
|
task: 複製 `notes` 資料夾到 `notes2`
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cp
|
||||||
|
files:
|
||||||
|
- path: notes2
|
||||||
|
type: directory
|
||||||
|
- path: notes2/note.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功複製 `notes` 資料夾到 `notes2` 了!
|
||||||
|
- 我們打開來確認一下 `notes2` 資料夾的內容吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `ls notes2` 來查看 `notes2` 資料夾的內容
|
||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
error:
|
||||||
|
- 'cp: omitting directory'
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你忘記加上 `-r` 的參數了嗎?
|
||||||
|
- 請輸入 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
|
||||||
|
|
||||||
|
- id: ls-notes2
|
||||||
|
requirements:
|
||||||
|
- cp-notes
|
||||||
|
task: 查看 `notes2` 資料夾的內容
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls notes2$
|
||||||
|
output:
|
||||||
|
- note.txt
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功查看到 `notes2` 資料夾的內容了!
|
||||||
|
- 我們可以看到,整個 `notes` 資料夾包含裡面的檔案,都被複製到 `notes2` 資料夾了。
|
||||||
|
- 那麼最後我們要來試試看,如果複製一個檔案到已經存在的檔案會發生什麼事。
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cp hello.txt world.txt` 來複製 `hello.txt` 到 `world.txt`
|
||||||
|
|
||||||
|
- id: cp-overwrite
|
||||||
|
requirements:
|
||||||
|
- ls-notes2
|
||||||
|
task: 複製 `hello.txt` 到 `world.txt`
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cp hello.txt world.txt$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你成功複製 `hello.txt` 到 `world.txt` 了!
|
||||||
|
- 我們打開來確認一下 `world.txt` 的內容吧!
|
||||||
|
hints:
|
||||||
|
- ➤ 使用 `cat world.txt`
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
requirements:
|
||||||
|
- cp-overwrite
|
||||||
|
task: 檢查 `world.txt` 的內容
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat world.txt$
|
||||||
|
output:
|
||||||
|
- Hello
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '`world.txt` 的內容變成 `Hello` 了!'
|
||||||
|
- 這是因為,當我們複製檔案時,如果目的檔案已經存在,那麼目的檔案的內容就會被覆蓋掉。
|
||||||
|
- 所以在複製檔案的時候,要特別注意目的檔案是否已經存在,否則可能會不小心把資料弄丟。
|
||||||
|
- 以上就是複製檔案的基本操作!
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Note
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
World
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
echo start
|
||||||
|
touch hello.txt
|
||||||
|
ls
|
||||||
|
cat hello.txt
|
||||||
|
touch world.txt
|
||||||
|
ls
|
||||||
|
mkdir notes
|
||||||
|
ls
|
||||||
|
cd notes
|
||||||
|
touch note.txt
|
||||||
|
ls
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
title: 建立檔案與資料夾
|
||||||
|
instruction: |
|
||||||
|
在這個關卡中,你將會學到:
|
||||||
|
1. 如何使用 `touch` 建立檔案
|
||||||
|
2. 如何使用 `mkdir` 建立資料夾
|
||||||
|
|
||||||
|
準備好就輸入 `echo start` 開始吧!
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
- read
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- id: start
|
||||||
|
task: 輸入 `echo start` 來開始
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^echo start
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 這次要學習的指令是 `touch` 指令。
|
||||||
|
- '`touch` 指令可以建立一個檔案。'
|
||||||
|
- 接下來,請你試著在終端機上建立一個檔案,檔名為 `hello.txt`。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `touch hello.txt` 來建立一個檔案
|
||||||
|
|
||||||
|
- id: touch
|
||||||
|
task: 使用 `touch` 指令來建立一個檔案
|
||||||
|
requirements:
|
||||||
|
- start
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: hello.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你應該可以看到終端機上沒有任何回應。
|
||||||
|
- 這表示 `touch` 指令已經成功建立了一個檔案。
|
||||||
|
- 接下來,請你用之前學過的 `ls` 指令來查看目前所在資料夾中有哪些檔案。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `ls` 指令來查看目前所在資料夾中有哪些檔案
|
||||||
|
|
||||||
|
- id: ls-touch
|
||||||
|
task: 使用 `ls` 指令來查看新建立的檔案
|
||||||
|
requirements:
|
||||||
|
- touch
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
output:
|
||||||
|
- hello.txt
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你應該可以看到終端機上顯示了 `hello.txt` 這個檔案。
|
||||||
|
- 我們來看看 `hello.txt` 檔案的內容吧!
|
||||||
|
- 我們可以使用之前學過的 `cat` 指令來查看檔案內容。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `cat hello.txt` 來查看 `hello.txt` 檔案的內容
|
||||||
|
|
||||||
|
- id: cat
|
||||||
|
task: 使用 `cat` 指令來查看 `hello.txt` 檔案的內容
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^cat hello.txt$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 咦?竟然什麼都沒有!
|
||||||
|
- 沒錯,使用 `touch` 指令建立的檔案是空的。
|
||||||
|
- 我們在未來會學習如何編輯檔案的內容。
|
||||||
|
- 再來試試建立其他的檔案吧!
|
||||||
|
- 請你試著在終端機上建立另一個檔案,檔名為 `world.txt`。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `touch world.txt` 來建立一個檔案
|
||||||
|
|
||||||
|
- id: touch-2
|
||||||
|
task: 使用 `touch` 指令來建立另一個檔案 `world.txt`
|
||||||
|
requirements:
|
||||||
|
- ls-touch
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: world.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 非常好,你已經成功建立了第二個檔案。
|
||||||
|
- 接下來,請你用之前學過的 `ls` 指令來查看剛剛建立的兩個檔案。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `ls` 指令來查看目前所在資料夾中有哪些檔案
|
||||||
|
|
||||||
|
- id: ls-2
|
||||||
|
task: 使用 `ls` 指令來查看剛剛建立的兩個檔案
|
||||||
|
requirements:
|
||||||
|
- touch-2
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
output:
|
||||||
|
- hello.txt
|
||||||
|
- world.txt
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你應該可以看到終端機上顯示了 `hello.txt` 和 `world.txt` 這兩個檔案。
|
||||||
|
- 接下來,我們來試試看建立資料夾吧!
|
||||||
|
- '`mkdir` 指令可以建立一個空的資料夾。'
|
||||||
|
- 使用方式是 `mkdir [資料夾名稱]`。
|
||||||
|
- 請你試著在終端機上建立一個資料夾,名稱為 `notes`。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `mkdir notes` 來建立一個資料夾
|
||||||
|
|
||||||
|
- id: mkdir
|
||||||
|
task: 使用 `mkdir` 指令建立 `notes` 資料夾
|
||||||
|
requirements:
|
||||||
|
- ls-2
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: notes
|
||||||
|
type: folder
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 非常好,你已經成功建立了 `notes` 資料夾。
|
||||||
|
- 接下來,請你用 `ls` 指令來查看剛剛建立的資料夾。
|
||||||
|
|
||||||
|
- id: ls-mkdir
|
||||||
|
task: 使用 `ls` 查看剛建立的資料夾
|
||||||
|
requirements:
|
||||||
|
- mkdir
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls$
|
||||||
|
output:
|
||||||
|
- notes
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你應該可以看到終端機上顯示了 `notes` 這個資料夾。
|
||||||
|
- 接下來,我們要在 `notes` 資料夾中建立一個 `note.txt` 的檔案。
|
||||||
|
- 首先,我們要先進入 `notes` 資料夾。
|
||||||
|
- 還記得之前的 `cd` 指令嗎?請你試著使用 `cd` 指令來進入 `notes` 資料夾。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `cd notes` 來進入 `notes` 資料夾
|
||||||
|
- ➤ 可以在輸入到 `cd n` 時按下 `Tab` 鍵來自動補齊資料夾名稱
|
||||||
|
|
||||||
|
- id: cd-notes
|
||||||
|
task: 移動到 `notes` 資料夾
|
||||||
|
requirements:
|
||||||
|
- ls-mkdir
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- ^/home/commander/notes$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你已經成功進入了 `notes` 資料夾。
|
||||||
|
- 接下來,請你試著在 `notes` 資料夾裡建立一個檔案,檔名為 `note.txt`。
|
||||||
|
hints:
|
||||||
|
- ➤ 輸入 `touch note.txt` 來建立一個檔案
|
||||||
|
|
||||||
|
- id: touch-note
|
||||||
|
task: 在 `notes` 中建立 `note.txt` 檔案
|
||||||
|
requirements:
|
||||||
|
- cd-notes
|
||||||
|
condition:
|
||||||
|
files:
|
||||||
|
- path: notes/note.txt
|
||||||
|
type: file
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 非常好,你已經成功建立了 `note.txt` 檔案。
|
||||||
|
- 跟之前一樣,你可以使用 `ls` 指令來查看目前所在資料夾中有哪些檔案。
|
||||||
|
|
||||||
|
- id: END
|
||||||
|
task: 使用 `ls` 指令來查看目前所在資料夾中有哪些檔案
|
||||||
|
requirements:
|
||||||
|
- touch-note
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ^ls
|
||||||
|
output:
|
||||||
|
- note.txt
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你應該可以看到終端機上顯示了 `note.txt` 這個檔案。
|
||||||
|
- 恭喜你完成了這個關卡!
|
||||||
|
- 在下一個關卡中,你將學習如何複製檔案與資料夾。
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
title: Discover the World!
|
|
||||||
instruction: |
|
|
||||||
試著探索這個世界吧!
|
|
||||||
|
|
||||||
requirements:
|
|
||||||
- spell
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
exceptions:
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- '[\uff21-\uff3a\uff41-\uff5a]'
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! 請使用半形英文字母 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ' '
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! 請使用半形空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
or:
|
||||||
|
- command:
|
||||||
|
- '`'
|
||||||
|
- command:
|
||||||
|
- \'
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! 不需要輸入上下引號 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^echo\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `echo` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^cd\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `cd` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^ls\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `ls` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^cat\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `cat` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^touch\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `touch` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
not:
|
||||||
|
command:
|
||||||
|
- ^rmdir$
|
||||||
|
command:
|
||||||
|
- ^rm\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `rm` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^mv\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `mv` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^mkdir\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `mkdir` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^rmdir\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `rmdir` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^cp\S$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `cp` 後面要加空格 !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- ^[Ii1]s$
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '!! `ls` 的首字母是小寫的 `L` !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- 嗨
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '嗨~'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- folderl
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '`folder1` 的 `1` 是數字 `1`,不是小寫的 `L` !!'
|
||||||
|
|
||||||
|
- condition:
|
||||||
|
command:
|
||||||
|
- filel
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- '`file1` 的 `1` 是數字 `1`,不是小寫的 `L` !!'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
FROM linuxodyssey/quest-base
|
||||||
|
|
||||||
|
COPY home ${HOME}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
echo start
|
||||||
|
ls
|
||||||
|
cd folder1
|
||||||
|
ls
|
||||||
|
cd ..
|
||||||
|
cd folder2
|
||||||
|
ls
|
||||||
|
cd ..
|
||||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user