Add user dashboard page

This commit is contained in:
Pig Fang 2018-08-08 09:50:35 +08:00
parent 061f7ff6f8
commit f0e177d62d
10 changed files with 377 additions and 85 deletions

View File

@ -29,6 +29,23 @@ class UserController extends Controller
]);
}
public function scoreInfo()
{
$user = Auth::user();
return [
'user' => [
'score' => $user->score,
'lastSignAt' => $user->last_sign_at,
],
'stats' => [
'players' => $this->calculatePercentageUsed($user->players->count(), option('score_per_player')),
'storage' => $this->calculatePercentageUsed($user->getStorageUsed(), option('score_per_storage'))
],
'signAfterZero' => option('sign_after_zero'),
'signGapTime' => option('sign_gap_time')
];
}
/**
* Calculate percentage of resources used by user.
*

View File

@ -1,4 +1,9 @@
export default [
{
path: 'user',
component: () => import('./user/dashboard'),
el: '#usage-box'
},
{
path: 'user/closet',
component: () => import('./user/closet'),

View File

@ -0,0 +1,158 @@
<template>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.used.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="row">
<div class="col-md-8">
<div class="progress-group">
<span class="progress-text" v-t="'user.used.players'"></span>
<span class="progress-number"><b>{{ playersUsed }}</b> / {{ playersTotal }}</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" :style="{ width: playersPercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
<div class="progress-group">
<span class="progress-text" v-t="'user.used.storage'"></span>
<span class="progress-number" id="user-storage">
<template v-if="storageUsed > 1024">
<b>{{ round(storageUsed / 1024) }}</b> / {{ round(storageTotal / 1024) }} MB
</template>
<template v-else>
<b>{{ storageUsed }}</b> / {{ storageTotal }} KB
</template>
</span>
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" id="user-storage-bar" :style="{ width: storagePercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
</div><!-- /.col -->
<div class="col-md-4">
<p class="text-center">
<strong v-t="'user.cur-score'"></strong>
</p>
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
{{ score }}
</p>
<p class="text-center" style="font-size: smaller; margin-top: 20px;" v-t="'user.score-notice'"></p>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- ./box-body -->
<div class="box-footer">
<button v-if="canSign" class="btn btn-primary pull-left" @click="sign">
<i class="fa fa-calendar-check-o" aria-hidden="true"></i> &nbsp;{{ $t('user.sign') }}
</button>
<button
v-else
class="btn btn-primary pull-left"
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
disabled
>
<i class="fa fa-calendar-check-o" aria-hidden="true"></i> &nbsp;
{{ remainingTimeText }}
</button>
</div><!-- /.box-footer -->
</div>
</template>
<script>
import { swal } from '../../js/notify';
import toastr from 'toastr';
const ONE_DAY = 24 * 3600 * 1000;
export default {
name: 'Dashboard',
data: () => ({
score: 0,
lastSignAt: new Date(),
signAfterZero: false,
signGap: 0,
playersUsed: 0,
playersTotal: 0,
storageUsed: 0,
storageTotal: 0,
}),
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());
} else {
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') }
);
} else {
return this.$t(
'user.sign-remain-time',
{ time: ~~(time / 60), unit: this.$t('user.time-unit-hour') }
);
}
},
canSign() {
return this.signRemainingTime <= 0;
}
},
beforeMount() {
this.fetchScoreInfo();
},
methods: {
async fetchScoreInfo() {
const data = await this.$http.get('/user/score-info');
this.score = data.user.score;
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;
},
round: Math.round,
async sign() {
const result = await this.$http.post('/user/sign');
if (result.errno === 0) {
swal({ type: 'success', html: result.msg });
this.score = result.score;
this.lastSignAt = new Date();
this.storageUsed = result.storage.used;
this.storageTotal = result.storage.total;
} else {
toastr.warning(result.msg);
}
}
}
};
</script>
<style lang="stylus">
#score {
font-family: Minecraft;
font-size: 50px;
text-align: center;
margin-top: 20px;
cursor: help;
}
.progress {
margin-top: 4px;
}
</style>

View File

@ -56,14 +56,3 @@
margin-bottom: 10px;
}
#score {
font-family: Minecraft;
font-size: 50px;
text-align: center;
margin-top: 20px;
cursor: help;
}
.progress {
margin-top: 4px;
}

View File

@ -0,0 +1,142 @@
import { promisify } from 'util';
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Dashboard from '@/components/user/Dashboard';
import toastr from 'toastr';
import { swal } from '@/js/notify';
jest.mock('@/js/notify');
function scoreInfo(data = {}) {
return {
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 wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('3 / 15');
expect(wrapper.find('.progress-bar-aqua').attributes().style).toBe('width: 20%;');
});
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 wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('5 / 20 KB');
expect(wrapper.find('.progress-bar-yellow').attributes().style).toBe('width: 25%;');
wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('2 / 4 MB');
expect(wrapper.find('.progress-bar-yellow').attributes().style).toBe('width: 50%;');
});
test('display score', async () => {
Vue.prototype.$http.get.mockResolvedValue(scoreInfo());
const wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
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 wrapper.vm.$nextTick();
expect(wrapper.find('button').attributes()).not.toHaveProperty('disabled');
wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
expect(wrapper.find('button').attributes()).toHaveProperty('disabled', 'disabled');
wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
expect(wrapper.find('button').attributes()).not.toHaveProperty('disabled');
wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
expect(wrapper.find('button').attributes()).toHaveProperty('disabled', '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 wrapper.vm.$nextTick();
expect(wrapper.find('button').text()).toContain('29');
expect(wrapper.find('button').text()).toContain('min');
wrapper = mount(Dashboard);
await wrapper.vm.$nextTick();
expect(wrapper.find('button').text()).toContain('23');
expect(wrapper.find('button').text()).toContain('hour');
Vue.prototype.$t = origin;
});
test('sign', async () => {
jest.spyOn(toastr, 'warning');
swal.mockResolvedValue();
Vue.prototype.$http.get.mockResolvedValue(scoreInfo({
user: { lastSignAt: Date.now() - 30 * 3600 * 1000 }
}));
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValueOnce({
errno: 0,
score: 233,
storage: { used: 3, total: 4 }
});
const wrapper = mount(Dashboard);
const button = wrapper.find('button');
await wrapper.vm.$nextTick();
button.trigger('click');
await wrapper.vm.$nextTick();
expect(Vue.prototype.$http.post).toBeCalledWith('/user/sign');
expect(toastr.warning).toBeCalledWith('1');
button.trigger('click');
await wrapper.vm.$nextTick();
expect(button.attributes()).toHaveProperty('disabled', 'disabled');
expect(wrapper.text()).toContain('3 / 4 KB');
});

View File

@ -126,6 +126,18 @@ user:
This is permanent! No backups, no restores, no magic undo button.
We warned you, ok?
password: Current Password
used:
title: Resources Used
players: Registered players
storage: Storage used
cur-score: Current Score
score-notice: Click the score to show introduction.
sign: Sign
sign-success: Signed successfully. You got :score scores.
time-unit-hour: h
time-unit-min: min
last-sign: Last signed at :time
sign-remain-time: Available after :time :unit
admin:
operationsTitle: Operations

View File

@ -127,6 +127,18 @@ user:
我们不提供任何备份,或者神奇的撤销按钮。
我们警告过你了,确定要这样做吗?
password: 当前密码
used:
title: 使用情况
players: 角色数量
storage: 存储空间
cur-score: 当前积分
score-notice: 点击积分查看说明
sign: 签到
sign-success: 签到成功,获得了 :score 积分~
time-unit-hour: 小时
time-unit-min: 分钟
last-sign: 上次签到于 :time
sign-remain-time: :time :unit后可签到
admin:
operationsTitle: 更多操作

View File

@ -15,79 +15,9 @@
<!-- Main content -->
<section class="content">
<div class="row">
</div><!-- /.row -->
<div class="row">
<div class="col-md-8">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.used.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="row">
<div class="col-md-8">
<div class="progress-group">
<span class="progress-text">@lang('user.used.players')</span>
<span class="progress-number"><b>{{ $statistics['players']['used'] }}</b>/ {{ $statistics['players']['total'] }}</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" style="width: {{ $statistics['players']['percentage'] }}%"></div>
</div>
</div><!-- /.progress-group -->
<div class="progress-group">
<span class="progress-text">@lang('user.used.storage')</span>
@php
$used = $statistics['storage']['used'];
$total = $statistics['storage']['total'];
@endphp
<span class="progress-number" id="user-storage">
@if ($used > 1024)
<b>{{ round($used / 1024, 1) }}</b>/ {{ is_string($total) ? $total : round($total / 1024, 1) }} MB
@else
<b>{{ $used }}</b>/ {{ $total }} KB
@endif
</span>
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" id="user-storage-bar" style="width: {{ $statistics['storage']['percentage'] }}%"></div>
</div>
</div><!-- /.progress-group -->
</div><!-- /.col -->
<div class="col-md-4">
<p class="text-center">
<strong>@lang('user.cur-score')</strong>
</p>
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
{{ $user->getScore() }}
</p>
<p class="text-center" style="font-size: smaller; margin-top: 20px;">@lang('user.score-notice')</p>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- ./box-body -->
<div class="box-footer">
@if ($user->canSign())
<button id="sign-button" class="btn btn-primary pull-left" onclick="sign()">
<i class="fa fa-calendar-check-o" aria-hidden="true"></i> &nbsp;@lang('user.sign')
</button>
@else
<button class="btn btn-primary pull-left" title="@lang('user.last-sign', ['time' => $user->getLastSignTime()])" disabled="disabled">
<i class="fa fa-calendar-check-o" aria-hidden="true"></i> &nbsp;
@php
$hours = $user->getSignRemainingTime() / 3600;
@endphp
@if ($hours >= 1)
@lang('user.sign-remain-time', ['time' => round($hours), 'unit' => trans('user.time-unit-hour')])
@else
@lang('user.sign-remain-time', ['time' => round($hours * 60), 'unit' => trans('user.time-unit-min')])
@endif
</button>
@endif
</div><!-- /.box-footer -->
</div><!-- /.box -->
<div class="box" id="usage-box"></div><!-- /.box -->
</div><!-- /.col -->
<div class="col-md-4">

View File

@ -45,6 +45,7 @@ Route::group(['prefix' => 'auth'], function ()
Route::group(['middleware' => ['web', 'auth'], 'prefix' => 'user'], function ()
{
Route::any ('', 'UserController@index');
Route::get ('/score-info', 'UserController@scoreInfo');
Route::post('/sign', 'UserController@sign');
// Profile

View File

@ -15,17 +15,43 @@ class UserControllerTest extends TestCase
$user = factory(User::class)->create();
factory(\App\Models\Player::class)->create(['uid' => $user->uid]);
$players_count = option('score_per_player') / option('user_initial_score');
$this->actAs($user)
->get('/user')
->assertViewHas('user')
->assertViewHas('statistics')
->assertSee((string) (1 / $players_count * 100)) // Players
->assertSee('0') // Storage
->assertSee((new Parsedown())->text(option_localized('announcement')))
->assertSee((string) $user->score);
}
public function testScoreInfo()
{
$user = factory(User::class)->create();
factory(\App\Models\Player::class)->create(['uid' => $user->uid]);
$this->actingAs($user)
->get('/user/score-info')
->assertJson([
'user' => [
'score' => $user->score,
'lastSignAt' => $user->last_sign_at
],
'stats' => [
'players' => [
'used' => 1,
'total' => 11,
'percentage' => 1 / 11 * 100
],
'storage' => [
'used' => 0,
'total' => $user->score,
'percentage' => 0
]
],
'signAfterZero' => option('sign_after_zero'),
'signGapTime' => option('sign_gap_time')
]);
}
public function testSign()
{
option(['sign_score' => '50,50']);