mirror of
https://github.com/bs-community/blessing-skin-server.git
synced 2024-12-09 06:00:31 +08:00
rewrite user dashboard with React
This commit is contained in:
parent
36a516d584
commit
1d87171808
@ -100,8 +100,8 @@ class UserController extends Controller
|
||||
'players' => $this->calculatePercentageUsed($user->players->count(), option('score_per_player')),
|
||||
'storage' => $this->calculatePercentageUsed($this->getStorageUsed($user), option('score_per_storage')),
|
||||
],
|
||||
'signAfterZero' => option('sign_after_zero'),
|
||||
'signGapTime' => option('sign_gap_time'),
|
||||
'signAfterZero' => (bool) option('sign_after_zero'),
|
||||
'signGapTime' => (int) option('sign_gap_time'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -45,8 +45,10 @@
|
||||
"@types/echarts": "^4.4.2",
|
||||
"@types/jest": "^24.0.25",
|
||||
"@types/jquery": "^3.3.29",
|
||||
"@types/js-yaml": "^3.12.2",
|
||||
"@types/react": "^16.9.17",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
"@types/tween.js": "^17.2.0",
|
||||
"@types/webpack": "^4.41.2",
|
||||
"@typescript-eslint/eslint-plugin": "^2.8.0",
|
||||
"@typescript-eslint/parser": "^2.8.0",
|
||||
@ -64,6 +66,7 @@
|
||||
"file-loader": "^5.0.2",
|
||||
"jest": "^24.9.0",
|
||||
"jest-extended": "^0.11.2",
|
||||
"js-yaml": "^3.13.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
|
24
resources/assets/src/scripts/hooks/useTween.ts
Normal file
24
resources/assets/src/scripts/hooks/useTween.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import TWEEN from '@tweenjs/tween.js'
|
||||
|
||||
export default function useTween<T = any>(
|
||||
initialValue: T,
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const [value, setValue] = useState<T>(initialValue)
|
||||
const ref = useRef<T>(value)
|
||||
const [dest, setDest] = useState<T>(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
TWEEN.update()
|
||||
setValue(ref.current)
|
||||
}
|
||||
|
||||
const tween = new TWEEN.Tween(ref)
|
||||
tween.to({ current: dest }, 1000).start()
|
||||
animate()
|
||||
}, [dest])
|
||||
|
||||
return [value, setDest]
|
||||
}
|
@ -10,7 +10,7 @@ export default [
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
component: () => import('../views/user/Dashboard.vue'),
|
||||
react: () => import('../views/user/Dashboard'),
|
||||
el: '#usage-box',
|
||||
},
|
||||
{
|
||||
|
27
resources/assets/src/styles/_breakpoints.scss
Normal file
27
resources/assets/src/styles/_breakpoints.scss
Normal file
@ -0,0 +1,27 @@
|
||||
@use 'sass:map';
|
||||
|
||||
$breakpoints: (
|
||||
xs: 0,
|
||||
sm: 576px,
|
||||
md: 768px,
|
||||
lg: 992px,
|
||||
xl: 1200px,
|
||||
);
|
||||
|
||||
@mixin less-than($breakpoint) {
|
||||
@media (max-width: map-get($breakpoints, $breakpoint)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin between($down, $up) {
|
||||
@media (max-width: map-get($breakpoints, $down)) and (min-width: map-get($breakpoints, $up)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin greater-than($breakpoint) {
|
||||
@media (min-width: map-get($breakpoints, $breakpoint)) {
|
||||
@content;
|
||||
}
|
||||
}
|
@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<email-verification />
|
||||
<div class="card card-primary card-outline">
|
||||
<div class="card-header">
|
||||
<h3 v-t="'user.used.title'" class="card-title" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-1" />
|
||||
<div class="col-md-6">
|
||||
<div class="info-box bg-teal">
|
||||
<span class="info-box-icon">
|
||||
<i class="fas fa-gamepad" />
|
||||
</span>
|
||||
<div class="info-box-content">
|
||||
<span class="info-box-text">{{ $t('user.used.players') }}</span>
|
||||
<span class="info-box-number">
|
||||
<strong>{{ playersUsed }}</strong> / {{ playersTotal }}
|
||||
</span>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: playersPercentage + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box bg-maroon">
|
||||
<span class="info-box-icon">
|
||||
<i class="fas fa-hdd" />
|
||||
</span>
|
||||
<div class="info-box-content">
|
||||
<span class="info-box-text">{{ $t('user.used.storage') }}</span>
|
||||
<span class="info-box-number">
|
||||
<template v-if="storageUsed > 1024">
|
||||
<strong>{{ ~~(storageUsed / 1024) }}</strong> / {{ ~~(storageTotal / 1024) }} MB
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ storageUsed }}</strong> / {{ storageTotal }} KB
|
||||
</template>
|
||||
</span>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: storagePercentage + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="text-center score-title">
|
||||
<strong v-t="'user.cur-score'" />
|
||||
</p>
|
||||
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
|
||||
{{ animatedScore }}
|
||||
</p>
|
||||
<p v-t="'user.score-notice'" class="text-center score-notice" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button
|
||||
v-if="canSign"
|
||||
class="btn bg-gradient-primary pl-5 pr-5"
|
||||
:disabled="signing"
|
||||
@click="sign"
|
||||
>
|
||||
<i class="far fa-calendar-check" aria-hidden="true" /> {{ $t('user.sign') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn bg-gradient-primary pl-4 pr-4"
|
||||
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
|
||||
disabled
|
||||
>
|
||||
<i class="far fa-calendar-check" aria-hidden="true" />
|
||||
{{ remainingTimeText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Tween from '@tweenjs/tween.js'
|
||||
import EmailVerification from '../../components/EmailVerification.vue'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { toast } from '../../scripts/notify'
|
||||
|
||||
const ONE_DAY = 24 * 3600 * 1000
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
EmailVerification,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
data: () => ({
|
||||
score: 0,
|
||||
tweenedScore: 0,
|
||||
lastSignAt: new Date(),
|
||||
signAfterZero: false,
|
||||
signGap: 0,
|
||||
signing: false,
|
||||
playersUsed: 0,
|
||||
playersTotal: 1,
|
||||
storageUsed: 0,
|
||||
storageTotal: 1,
|
||||
tween: null,
|
||||
}),
|
||||
computed: {
|
||||
playersPercentage() {
|
||||
return this.playersUsed / this.playersTotal * 100
|
||||
},
|
||||
storagePercentage() {
|
||||
return this.storageUsed / this.storageTotal * 100
|
||||
},
|
||||
signRemainingTime() {
|
||||
if (this.signAfterZero) {
|
||||
const today = new Date().setHours(0, 0, 0, 0)
|
||||
const tomorrow = today + ONE_DAY
|
||||
return this.lastSignAt.valueOf() < today ? 0 : tomorrow - Date.now()
|
||||
}
|
||||
return this.lastSignAt.valueOf() + this.signGap - Date.now()
|
||||
},
|
||||
remainingTimeText() {
|
||||
const time = this.signRemainingTime / 1000 / 60
|
||||
if (time < 60) {
|
||||
return this.$t(
|
||||
'user.sign-remain-time',
|
||||
{ time: ~~time, unit: this.$t('user.time-unit-min') },
|
||||
)
|
||||
}
|
||||
return this.$t(
|
||||
'user.sign-remain-time',
|
||||
{ time: ~~(time / 60), unit: this.$t('user.time-unit-hour') },
|
||||
)
|
||||
},
|
||||
canSign() {
|
||||
return this.signRemainingTime <= 0
|
||||
},
|
||||
animatedScore() {
|
||||
return this.tweenedScore.toFixed(0)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
score(newValue) {
|
||||
this.tween.to({ tweenedScore: newValue }, 1000).start()
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.tween = new Tween.Tween(this.$data)
|
||||
},
|
||||
beforeMount() {
|
||||
this.fetchScoreInfo()
|
||||
},
|
||||
mounted() {
|
||||
function animate() {
|
||||
requestAnimationFrame(animate)
|
||||
Tween.update()
|
||||
}
|
||||
animate()
|
||||
},
|
||||
methods: {
|
||||
async fetchScoreInfo() {
|
||||
const { data } = await this.$http.get('/user/score-info')
|
||||
this.lastSignAt = new Date(data.user.lastSignAt)
|
||||
this.signAfterZero = data.signAfterZero
|
||||
this.signGap = data.signGapTime * 3600 * 1000
|
||||
this.playersUsed = data.stats.players.used
|
||||
this.playersTotal = data.stats.players.total
|
||||
this.storageUsed = data.stats.storage.used
|
||||
this.storageTotal = data.stats.storage.total
|
||||
this.score = data.user.score
|
||||
},
|
||||
async sign() {
|
||||
this.signing = true
|
||||
const {
|
||||
code, message, data,
|
||||
} = await this.$http.post('/user/sign')
|
||||
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
this.score = data.score
|
||||
this.lastSignAt = new Date()
|
||||
this.storageUsed = data.storage.used
|
||||
this.storageTotal = data.storage.total
|
||||
} else {
|
||||
toast.warning(message)
|
||||
}
|
||||
this.signing = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.score-title
|
||||
margin-top 5px
|
||||
|
||||
@media (max-width 768px)
|
||||
margin-top 12px
|
||||
|
||||
#score
|
||||
font-family Minecraft
|
||||
font-size 50px
|
||||
text-align center
|
||||
margin-top 20px
|
||||
cursor help
|
||||
|
||||
.score-notice
|
||||
font-size smaller
|
||||
margin-top 20px
|
||||
</style>
|
33
resources/assets/src/views/user/Dashboard/InfoBox.tsx
Normal file
33
resources/assets/src/views/user/Dashboard/InfoBox.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
used: number
|
||||
total: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
const InfoBox: React.FC<Props> = props => {
|
||||
const percentage = (props.used / props.total) * 100
|
||||
|
||||
return (
|
||||
<div className={`info-box bg-${props.color}`}>
|
||||
<span className="info-box-icon">
|
||||
<i className={`fas fa-${props.icon}`}></i>
|
||||
</span>
|
||||
<div className="info-box-content">
|
||||
<span className="info-box-text">{props.name}</span>
|
||||
<span className="info-box-number">
|
||||
<strong>{props.used}</strong> / {props.total} {props.unit}
|
||||
</span>
|
||||
<div className="progress">
|
||||
<div className="progress-bar" style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(InfoBox)
|
38
resources/assets/src/views/user/Dashboard/SignButton.tsx
Normal file
38
resources/assets/src/views/user/Dashboard/SignButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { trans } from '../../../scripts/i18n'
|
||||
import * as scoreUtils from './scoreUtils'
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean
|
||||
lastSign: Date
|
||||
canSignAfterZero: boolean
|
||||
signGap: number
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
const SignButton: React.FC<Props> = props => {
|
||||
const { lastSign, signGap, canSignAfterZero } = props
|
||||
const remainingTime = useMemo(
|
||||
() => scoreUtils.remainingTime(lastSign, signGap, canSignAfterZero),
|
||||
[lastSign, signGap, canSignAfterZero],
|
||||
)
|
||||
const remainingTimeText = useMemo(
|
||||
() => scoreUtils.remainingTimeText(remainingTime),
|
||||
[remainingTime],
|
||||
)
|
||||
const canSign = remainingTime <= 0
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn bg-gradient-primary pl-4 pr-4"
|
||||
role="button"
|
||||
disabled={!canSign || props.isLoading}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<i className="far fa-calendar-check" aria-hidden="true" />
|
||||
{canSign ? trans('user.sign') : remainingTimeText}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SignButton)
|
134
resources/assets/src/views/user/Dashboard/index.tsx
Normal file
134
resources/assets/src/views/user/Dashboard/index.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import { trans } from '../../../scripts/i18n'
|
||||
import * as fetch from '../../../scripts/net'
|
||||
import { toast } from '../../../scripts/notify'
|
||||
import useTween from '../../../scripts/hooks/useTween'
|
||||
import InfoBox from './InfoBox'
|
||||
import SignButton from './SignButton'
|
||||
import scoreStyle from './score.scss'
|
||||
|
||||
type ScoreInfo = {
|
||||
signAfterZero: boolean
|
||||
signGapTime: number
|
||||
stats: { players: Stat; storage: Stat }
|
||||
user: { score: number; lastSignAt: string }
|
||||
}
|
||||
|
||||
type Stat = {
|
||||
used: number
|
||||
total: number
|
||||
}
|
||||
|
||||
type SignReturn = {
|
||||
score: number
|
||||
storage: Stat
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [players, setPlayers] = useState<Stat>({ used: 0, total: 1 })
|
||||
const [storage, setStorage] = useState<Stat>({ used: 0, total: 1 })
|
||||
const [score, setScore] = useTween(0)
|
||||
const [lastSign, setLastSign] = useState(new Date())
|
||||
const [canSignAfterZero, setCanSignAfterZero] = useState(false)
|
||||
const [signGap, setSignGap] = useState(24)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInfo = async () => {
|
||||
setLoading(true)
|
||||
const { data } = await fetch.get<fetch.ResponseBody<ScoreInfo>>(
|
||||
'/user/score-info',
|
||||
)
|
||||
setPlayers(data.stats.players)
|
||||
setStorage(data.stats.storage)
|
||||
setScore(data.user.score)
|
||||
setLastSign(new Date(data.user.lastSignAt))
|
||||
setCanSignAfterZero(data.signAfterZero)
|
||||
setSignGap(data.signGapTime)
|
||||
setLoading(false)
|
||||
}
|
||||
fetchInfo()
|
||||
}, [])
|
||||
|
||||
const handleSign = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const { code, message, data } = await fetch.post<
|
||||
fetch.ResponseBody<SignReturn>
|
||||
>('/user/sign')
|
||||
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setLastSign(new Date())
|
||||
setScore(data.score)
|
||||
setStorage(data.storage)
|
||||
} else {
|
||||
toast.warning(message)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="card card-primary card-outline">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{trans('user.used.title')}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-1"></div>
|
||||
<div className="col-md-6">
|
||||
<InfoBox
|
||||
color="teal"
|
||||
icon="gamepad"
|
||||
name={trans('user.used.players')}
|
||||
used={players.used}
|
||||
total={players.total}
|
||||
unit=""
|
||||
/>
|
||||
{storage.used > 1024 ? (
|
||||
<InfoBox
|
||||
color="maroon"
|
||||
icon="hdd"
|
||||
name={trans('user.used.storage')}
|
||||
used={~~(storage.used / 1024)}
|
||||
total={~~(storage.total / 1024)}
|
||||
unit="MB"
|
||||
/>
|
||||
) : (
|
||||
<InfoBox
|
||||
color="maroon"
|
||||
icon="hdd"
|
||||
name={trans('user.used.storage')}
|
||||
used={storage.used}
|
||||
total={storage.total}
|
||||
unit="KB"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-4 text-center">
|
||||
<p className={scoreStyle.title}>{trans('user.cur-score')}</p>
|
||||
<p
|
||||
className={scoreStyle.number}
|
||||
data-toggle="modal"
|
||||
data-target="#modal-score-instruction"
|
||||
>
|
||||
{~~score}
|
||||
</p>
|
||||
<p className={scoreStyle.notice}>{trans('user.score-notice')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<SignButton
|
||||
isLoading={loading}
|
||||
lastSign={lastSign}
|
||||
canSignAfterZero={canSignAfterZero}
|
||||
signGap={signGap}
|
||||
onClick={handleSign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(Dashboard)
|
23
resources/assets/src/views/user/Dashboard/score.scss
Normal file
23
resources/assets/src/views/user/Dashboard/score.scss
Normal file
@ -0,0 +1,23 @@
|
||||
@use 'sass:map';
|
||||
@use '../../../styles/breakpoints';
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
margin-top: 5px;
|
||||
|
||||
@include breakpoints.less-than('md') {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: 'Minecraft';
|
||||
font-size: 50px;
|
||||
margin-top: 20px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.notice {
|
||||
font-size: smaller;
|
||||
margin-top: 20px;
|
||||
}
|
34
resources/assets/src/views/user/Dashboard/scoreUtils.ts
Normal file
34
resources/assets/src/views/user/Dashboard/scoreUtils.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { trans } from '../../../scripts/i18n'
|
||||
|
||||
const ONE_MINUTE = 60 * 1000
|
||||
const ONE_HOUR = 60 * ONE_MINUTE
|
||||
const ONE_DAY = 24 * ONE_HOUR
|
||||
|
||||
export function remainingTime(
|
||||
lastSign: Date,
|
||||
signGap: number,
|
||||
canSignAfterZero: boolean,
|
||||
): number {
|
||||
if (canSignAfterZero) {
|
||||
const today = new Date().setHours(0, 0, 0, 0)
|
||||
const tomorrow = today + ONE_DAY
|
||||
const rest = tomorrow - Date.now()
|
||||
|
||||
return lastSign.valueOf() < today ? 0 : rest
|
||||
}
|
||||
|
||||
return lastSign.valueOf() + signGap * ONE_HOUR - Date.now()
|
||||
}
|
||||
|
||||
export function remainingTimeText(remainingTime: number): string {
|
||||
const time = remainingTime / ONE_MINUTE
|
||||
return time < 60
|
||||
? trans('user.sign-remain-time', {
|
||||
time: ~~time,
|
||||
unit: trans('user.time-unit-min'),
|
||||
})
|
||||
: trans('user.sign-remain-time', {
|
||||
time: ~~(time / 60),
|
||||
unit: trans('user.time-unit-hour'),
|
||||
})
|
||||
}
|
@ -1,14 +1,16 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import * as fs from 'fs'
|
||||
import 'jest-extended'
|
||||
import '@testing-library/jest-dom'
|
||||
import Vue from 'vue'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
window.blessing = {
|
||||
base_url: '',
|
||||
site_name: 'Blessing Skin',
|
||||
version: '4.0.0',
|
||||
extra: {},
|
||||
i18n: {},
|
||||
i18n: yaml.load(fs.readFileSync('resources/lang/en/front-end.yml', 'utf8')),
|
||||
}
|
||||
|
||||
class Headers extends Map {
|
||||
|
@ -1,180 +0,0 @@
|
||||
/* eslint-disable no-mixed-operators */
|
||||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../../utils'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import Dashboard from '@/views/user/Dashboard.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
jest.mock('@tweenjs/tween.js', () => ({
|
||||
Tween: class <T> {
|
||||
data: T
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
to(data: Partial<T>, _: number) {
|
||||
Object.assign(this.data, data)
|
||||
return this
|
||||
}
|
||||
|
||||
start() {}
|
||||
},
|
||||
update() {},
|
||||
}))
|
||||
|
||||
window.blessing.extra = { unverified: false }
|
||||
|
||||
function scoreInfo(data = {}) {
|
||||
return {
|
||||
data: {
|
||||
user: { score: 835, lastSignAt: '2018-08-07 16:06:49' },
|
||||
stats: {
|
||||
players: {
|
||||
used: 3, total: 15, percentage: 20,
|
||||
},
|
||||
storage: {
|
||||
used: 5, total: 20, percentage: 25,
|
||||
},
|
||||
},
|
||||
signAfterZero: false,
|
||||
signGapTime: '24',
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('fetch score info', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
|
||||
mount(Dashboard)
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith('/user/score-info')
|
||||
})
|
||||
|
||||
test('players usage', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
|
||||
const wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('3 / 15')
|
||||
})
|
||||
|
||||
test('storage usage', async () => {
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce(scoreInfo())
|
||||
.mockResolvedValueOnce(scoreInfo({
|
||||
stats: {
|
||||
players: {
|
||||
used: 3, total: 15, percentage: 20,
|
||||
},
|
||||
storage: {
|
||||
used: 2048, total: 4096, percentage: 50,
|
||||
},
|
||||
},
|
||||
}))
|
||||
let wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('5 / 20 KB')
|
||||
|
||||
wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('2 / 4 MB')
|
||||
})
|
||||
|
||||
test('display score', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
|
||||
const wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#score').text()).toContain('835')
|
||||
})
|
||||
|
||||
test('button `sign` state', async () => {
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce(scoreInfo({ signAfterZero: true }))
|
||||
.mockResolvedValueOnce(scoreInfo({
|
||||
signAfterZero: true,
|
||||
user: { lastSignAt: Date.now() },
|
||||
}))
|
||||
.mockResolvedValueOnce(scoreInfo({ user: { lastSignAt: Date.now() - 25 * 3600 * 1000 } }))
|
||||
.mockResolvedValueOnce(scoreInfo({ user: { lastSignAt: Date.now() } }))
|
||||
|
||||
let wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeNil()
|
||||
|
||||
wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
|
||||
|
||||
wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeNil()
|
||||
|
||||
wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').attributes('disabled')).toBe('disabled')
|
||||
})
|
||||
|
||||
test('remaining time', async () => {
|
||||
const origin = Vue.prototype.$t
|
||||
Vue.prototype.$t = (key, args) => key + JSON.stringify(args)
|
||||
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce(scoreInfo({
|
||||
user: { lastSignAt: Date.now() - 23.5 * 3600 * 1000 },
|
||||
}))
|
||||
.mockResolvedValueOnce(scoreInfo({
|
||||
user: { lastSignAt: Date.now() },
|
||||
}))
|
||||
|
||||
let wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').text()).toMatch(/(29)|(30)/)
|
||||
expect(wrapper.find('button').text()).toContain('min')
|
||||
|
||||
wrapper = mount(Dashboard)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').text()).toContain('23')
|
||||
expect(wrapper.find('button').text()).toContain('hour')
|
||||
|
||||
Vue.prototype.$t = origin
|
||||
})
|
||||
|
||||
test('sign', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue(scoreInfo({
|
||||
user: { lastSignAt: Date.now() - 30 * 3600 * 1000 },
|
||||
}))
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({
|
||||
code: 1, message: '1', data: {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
score: 233,
|
||||
storage: { used: 3, total: 4 },
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Dashboard)
|
||||
const button = wrapper.find('button')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith('/user/sign')
|
||||
expect(toast.warning).toBeCalledWith('1')
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(button.attributes('disabled')).toBe('disabled')
|
||||
expect(wrapper.text()).toContain('3 / 4 KB')
|
||||
})
|
||||
|
||||
test('disable button when signing', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue(scoreInfo())
|
||||
const wrapper = mount(Dashboard)
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('disabled')).toBeFalsy()
|
||||
wrapper.setData({ signing: true })
|
||||
expect(button.attributes('disabled')).toBeTruthy()
|
||||
})
|
161
resources/assets/tests/views/user/Dashboard.test.tsx
Normal file
161
resources/assets/tests/views/user/Dashboard.test.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, wait } from '@testing-library/react'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { trans } from '@/scripts/i18n'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import Dashboard from '@/views/user/Dashboard'
|
||||
|
||||
jest.mock('@/scripts/net')
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
function scoreInfo(data = {}, user = {}, stats = {}) {
|
||||
return {
|
||||
data: {
|
||||
user: { score: 600, lastSignAt: '2018-08-07 16:06:49', ...user },
|
||||
stats: {
|
||||
players: { used: 3, total: 15 },
|
||||
storage: { used: 5, total: 20 },
|
||||
...stats,
|
||||
},
|
||||
signAfterZero: false,
|
||||
signGapTime: '24',
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('info box', () => {
|
||||
it('players', async () => {
|
||||
fetch.get.mockResolvedValue(
|
||||
scoreInfo({}, {}, { players: { used: 13, total: 21 } }),
|
||||
)
|
||||
|
||||
const { getByText } = render(<Dashboard />)
|
||||
await wait()
|
||||
expect(getByText('13')).toBeInTheDocument()
|
||||
expect(getByText(/21/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('storage', () => {
|
||||
it('in KB', async () => {
|
||||
fetch.get.mockResolvedValue(
|
||||
scoreInfo({}, {}, { storage: { used: 700, total: 800 } }),
|
||||
)
|
||||
|
||||
const { getByText } = render(<Dashboard />)
|
||||
await wait()
|
||||
expect(getByText('700')).toBeInTheDocument()
|
||||
expect(getByText(/800/)).toBeInTheDocument()
|
||||
expect(getByText(/KB/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('in MB', async () => {
|
||||
fetch.get.mockResolvedValue(
|
||||
scoreInfo({}, {}, { storage: { used: 7168, total: 10240 } }),
|
||||
)
|
||||
|
||||
const { getByText } = render(<Dashboard />)
|
||||
await wait()
|
||||
expect(getByText('7')).toBeInTheDocument()
|
||||
expect(getByText(/10/)).toBeInTheDocument()
|
||||
expect(getByText(/MB/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sign', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get.mockResolvedValue(scoreInfo())
|
||||
})
|
||||
|
||||
it('should succeed', async () => {
|
||||
fetch.post.mockResolvedValue({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { score: 900, storage: { used: 5, total: 25 } },
|
||||
})
|
||||
|
||||
const { getByRole, queryByText } = render(<Dashboard />)
|
||||
await wait()
|
||||
|
||||
const button = getByRole('button')
|
||||
fireEvent.click(button)
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith('/user/sign')
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
expect(button).toBeDisabled()
|
||||
expect(queryByText(/25/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fail', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 1, message: 'f', data: {} })
|
||||
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByRole('button'))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith('/user/sign')
|
||||
expect(toast.warning).toBeCalledWith('f')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sign button', () => {
|
||||
it('should disabled when loading', () => {
|
||||
fetch.get.mockResolvedValue(scoreInfo())
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
expect(getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('sign is allowed', async () => {
|
||||
fetch.get.mockResolvedValue(scoreInfo())
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
const button = getByRole('button')
|
||||
|
||||
expect(button).toBeEnabled()
|
||||
expect(button).toHaveTextContent(trans('user.sign'))
|
||||
})
|
||||
|
||||
it('sign is allowed if last sign is yesterday', async () => {
|
||||
fetch.get.mockResolvedValue(scoreInfo({ signAfterZero: true }))
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
expect(getByRole('button')).toBeEnabled()
|
||||
})
|
||||
|
||||
it('sign is not allowed', async () => {
|
||||
fetch.get.mockResolvedValue(
|
||||
scoreInfo({ signAfterZero: true }, { lastSignAt: Date.now() }),
|
||||
)
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
expect(getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('remain in hours', async () => {
|
||||
fetch.get.mockResolvedValue(scoreInfo({}, { lastSignAt: Date.now() }))
|
||||
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
const button = getByRole('button')
|
||||
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveTextContent('23 h')
|
||||
})
|
||||
|
||||
it('remain in minutes', async () => {
|
||||
fetch.get.mockResolvedValue(
|
||||
scoreInfo({}, { lastSignAt: Date.now() - 23.5 * 3600 * 1000 }),
|
||||
)
|
||||
|
||||
const { getByRole } = render(<Dashboard />)
|
||||
await wait()
|
||||
const button = getByRole('button')
|
||||
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveTextContent(/(29|30)\smin/)
|
||||
})
|
||||
})
|
@ -1 +1 @@
|
||||
<div class="card" id="usage-box"></div>
|
||||
<div id="usage-box"></div>
|
||||
|
@ -62,9 +62,8 @@ const config = {
|
||||
options: {
|
||||
importLoaders: 2,
|
||||
modules: {
|
||||
localIdentName: devMode ? '[name]__[local]' : '[hash:base64]',
|
||||
localIdentName: devMode ? '[name]__[local]' : '[local]__[hash:base64:5]',
|
||||
},
|
||||
localsConvention: 'dashes',
|
||||
esModule: true,
|
||||
},
|
||||
},
|
||||
|
20
yarn.lock
20
yarn.lock
@ -1325,6 +1325,11 @@
|
||||
dependencies:
|
||||
"@types/sizzle" "*"
|
||||
|
||||
"@types/js-yaml@^3.12.2":
|
||||
version "3.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.2.tgz#a35a1809c33a68200fb6403d1ad708363c56470a"
|
||||
integrity sha512-0CFu/g4mDSNkodVwWijdlr8jH7RoplRWNgovjFLEZeT+QEbbZXjBmCe3HwaWheAlCbHwomTwzZoSedeOycABug==
|
||||
|
||||
"@types/json-schema@^7.0.3":
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
|
||||
@ -1407,6 +1412,11 @@
|
||||
"@types/react-dom" "*"
|
||||
"@types/testing-library__dom" "*"
|
||||
|
||||
"@types/tween.js@^17.2.0":
|
||||
version "17.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/tween.js/-/tween.js-17.2.0.tgz#25f98311daecb165ab91ee2cd7f17a9b1e6cc30c"
|
||||
integrity sha512-mOsqurEtFEzwgkVc/jDVE2XrjZBYTbrmDUyCr9GXmnfc6q5otokxFtKvSY/B21zgz9LVRIvRTawKczjKi57wrA==
|
||||
|
||||
"@types/uglify-js@*":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
|
||||
@ -6255,7 +6265,15 @@ jqvmap-novulnerability@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@^3.13.1, js-yaml@^3.9.0:
|
||||
js-yaml@^3.13.1:
|
||||
version "3.13.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
|
||||
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
|
||||
dependencies:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^3.9.0:
|
||||
version "3.13.1"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
|
||||
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
|
||||
|
Loading…
Reference in New Issue
Block a user