Add user dashboard page
This commit is contained in:
parent
061f7ff6f8
commit
f0e177d62d
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
export default [
|
||||
{
|
||||
path: 'user',
|
||||
component: () => import('./user/dashboard'),
|
||||
el: '#usage-box'
|
||||
},
|
||||
{
|
||||
path: 'user/closet',
|
||||
component: () => import('./user/closet'),
|
||||
|
|
|
|||
158
resources/assets/src/components/user/Dashboard.vue
Normal file
158
resources/assets/src/components/user/Dashboard.vue
Normal 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> {{ $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>
|
||||
{{ 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
142
resources/assets/tests/components/user/Dashboard.test.js
Normal file
142
resources/assets/tests/components/user/Dashboard.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: 更多操作
|
||||
|
|
|
|||
|
|
@ -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> @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>
|
||||
@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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user