Add profile page

This commit is contained in:
Pig Fang 2018-08-02 17:29:43 +08:00
parent d946810c46
commit 3dfa5c6e4e
9 changed files with 570 additions and 113 deletions

View File

@ -41,3 +41,4 @@ rules:
globals:
blessing: false
__bs_data__: false

View File

@ -3,5 +3,10 @@ export default [
path: 'user/closet',
component: () => import('./user/closet'),
el: '.content'
}
},
{
path: 'user/profile',
component: () => import('./user/profile'),
el: '.content'
},
];

View File

@ -0,0 +1,299 @@
<template>
<section class="content">
<div class="row">
<div class="col-md-6">
<div v-once class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.profile.avatar.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body" v-t="'user.profile.avatar.notice'"></div><!-- /.box-body -->
</div>
<div class="box box-warning">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.profile.password.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group">
<label v-t="'user.profile.password.old'"></label>
<input type="password" class="form-control" v-model="oldPassword" ref="oldPassword">
</div>
<div class="form-group">
<label v-t="'user.profile.password.new'"></label>
<input type="password" class="form-control" v-model="newPassword" ref="newPassword">
</div>
<div class="form-group">
<label v-t="'user.profile.password.confirm'"></label>
<input type="password" class="form-control" v-model="confirmPassword" ref="confirmPassword">
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button
@click="changePassword"
class="btn btn-primary"
v-t="'user.profile.password.button'"
data-test="changePassword"
></button>
</div>
</div><!-- /.box -->
</div>
<div class="col-md-6">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.profile.nickname.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group has-feedback">
<input
v-model="nickname"
type="text"
class="form-control"
:placeholder="$t('user.profile.nickname.rule')"
ref="nickname"
>
<span class="glyphicon glyphicon-user form-control-feedback"></span>
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button
@click="changeNickName"
class="btn btn-primary"
v-t="'general.submit'"
data-test="changeNickName"
></button>
</div>
</div>
<div class="box box-warning">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.profile.email.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group has-feedback">
<input
v-model="email"
type="email"
class="form-control"
:placeholder="$t('user.profile.email.new')"
ref="email"
>
<span class="glyphicon glyphicon-envelope form-control-feedback"></span>
</div>
<div class="form-group has-feedback">
<input
v-model="currentPassword"
type="password"
class="form-control"
:placeholder="$t('user.profile.email.password')"
ref="currentPassword"
>
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button
@click="changeEmail"
class="btn btn-warning"
v-t="'user.profile.email.button'"
data-test="changeEmail"
></button>
</div>
</div>
<div class="box box-danger">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.profile.delete.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<template v-if="isAdmin">
<p v-t="'user.profile.delete.admin'"></p>
<button class="btn btn-danger" disabled v-t="'user.profile.delete.button'"></button>
</template>
<template v-else>
<p v-t="{ path: 'user.profile.delete.notice', args: { site: siteName } }"></p>
<button
class="btn btn-danger"
data-toggle="modal"
data-target="#modal-delete-account"
v-t="'user.profile.delete.button'"
></button>
</template>
</div><!-- /.box-body -->
</div>
</div>
</div>
<div id="modal-delete-account" class="modal modal-danger fade" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" v-t="'user.profile.delete.modal-title'"></h4>
</div>
<div class="modal-body">
<div v-once v-html="nl2br($t('user.profile.delete.modal-notice'))"></div>
<br />
<input type="password" class="form-control" v-model="deleteConfirm" :placeholder="$t('user.profile.delete.password')">
<br />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" data-dismiss="modal" v-t="'general.close'"></button>
<a @click="deleteAccount" class="btn btn-outline" v-t="'general.submit'" data-test="deleteAccount"></a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</section><!-- /.content -->
</template>
<script>
import axios from 'axios';
import toastr from 'toastr';
import { swal } from '../../js/notify';
export default {
name: 'Profile',
data: () => ({
oldPassword: '',
newPassword: '',
confirmPassword: '',
nickname: '',
email: '',
currentPassword: '',
deleteConfirm: '',
}),
computed: {
siteName: () => blessing.site_name,
isAdmin: () => __bs_data__.admin
},
methods: {
nl2br: str => str.replace(/\n/g, '<br>'),
async changePassword() {
const {
oldPassword, newPassword, confirmPassword
} = this;
if (!oldPassword) {
toastr.info(this.$t('user.emptyPassword'));
this.$refs.oldPassword.focus();
return;
}
if (!newPassword) {
toastr.info(this.$t('user.emptyNewPassword'));
this.$refs.newPassword.focus();
return;
}
if (!confirmPassword) {
toastr.info(this.$t('auth.emptyConfirmPwd'));
this.$refs.confirmPassword.focus();
return;
}
if (newPassword !== confirmPassword) {
toastr.info(this.$t('auth.invalidConfirmPwd'));
this.$refs.confirmPassword.focus();
return;
}
const { data: { errno, msg } } = await axios.post(
'/user/profile?action=password',
{ current_password: oldPassword, new_password: newPassword }
);
if (errno === 0) {
await swal({ type: 'success', text: msg });
return window.location = `${blessing.base_url}/auth/login`;
} else {
return swal({ type: 'warning', text: msg });
}
},
async changeNickName() {
const { nickname } = this;
if (!nickname) {
return swal({ type: 'error', html: this.$t('user.emptyNewNickName') });
}
const { dismiss } = await swal({
text: this.$t('user.changeNickName', { new_nickname: nickname }),
type: 'question',
showCancelButton: true
});
if (dismiss) {
return;
}
const { data: { errno, msg } } = await axios.post(
'/user/profile?action=nickname',
{ new_nickname: nickname }
);
if (errno === 0) {
$('.nickname').each(function () {
$(this).html(nickname);
});
return swal({ type: 'success', html: msg });
} else {
return swal({ type: 'warning', html: msg });
}
},
async changeEmail() {
const { email } = this;
if (!email) {
return swal({ type: 'error', html: this.$t('user.emptyNewEmail') });
}
if (!/\S+@\S+\.\S+/.test(email)) {
return swal({ type: 'warning', html: this.$t('auth.invalidEmail') });
}
const { dismiss } = await swal({
text: this.$t('user.changeEmail', { new_email: email }),
type: 'question',
showCancelButton: true
});
if (dismiss) {
return;
}
const { data: { errno, msg } } = await axios.post(
'/user/profile?action=email',
{ new_email: email, password: this.currentPassword }
);
if (errno === 0) {
await swal({ type: 'success', text: msg });
return window.location = `${blessing.base_url}/auth/login`;
} else {
return swal({ type: 'warning', text: msg });
}
},
async deleteAccount() {
const { deleteConfirm: password } = this;
if (!password) {
return swal({ type: 'warning', html: this.$t('user.emptyDeletePassword') });
}
const { data: { errno, msg } } = await axios.post(
'/user/profile?action=delete',
{ password }
);
if (errno === 0) {
await swal({
type: 'success',
html: msg
});
window.location = `${blessing.base_url}/auth/login`;
} else {
return swal({ type: 'warning', html: msg });
}
}
}
};
</script>

View File

@ -0,0 +1,179 @@
import { mount } from '@vue/test-utils';
import Profile from '@/components/user/Profile';
import axios from 'axios';
import toastr from 'toastr';
import { swal } from '@/js/notify';
jest.mock('axios');
jest.mock('@/js/notify');
test('computed values', () => {
window.__bs_data__ = { admin: true };
const wrapper = mount(Profile);
expect(wrapper.vm.siteName).toBe('Blessing Skin');
expect(wrapper.vm.isAdmin).toBeTrue();
window.__bs_data__ = { admin: false };
expect(mount(Profile).vm.isAdmin).toBeFalse();
});
test('convert linebreak', () => {
const wrapper = mount(Profile);
expect(wrapper.vm.nl2br('a\nb\nc')).toBe('a<br>b<br>c');
});
test('change password', async () => {
jest.spyOn(toastr, 'info');
axios.post
.mockResolvedValueOnce({ data: { errno: 1, msg: 'w' } })
.mockResolvedValueOnce({ data: { errno: 0, msg: 'o' } });
swal.mockResolvedValue();
const wrapper = mount(Profile);
const button = wrapper.find('[data-test=changePassword]');
button.trigger('click');
expect(toastr.info).toBeCalledWith('user.emptyPassword');
expect(axios.post).not.toBeCalled();
wrapper.setData({ oldPassword: '1' });
button.trigger('click');
expect(toastr.info).toBeCalledWith('user.emptyNewPassword');
expect(axios.post).not.toBeCalled();
wrapper.setData({ newPassword: '1' });
button.trigger('click');
expect(toastr.info).toBeCalledWith('auth.emptyConfirmPwd');
expect(axios.post).not.toBeCalled();
wrapper.setData({ confirmPassword: '2' });
button.trigger('click');
expect(toastr.info).toBeCalledWith('auth.invalidConfirmPwd');
expect(axios.post).not.toBeCalled();
wrapper.setData({ confirmPassword: '1' });
button.trigger('click');
await wrapper.vm.$nextTick();
expect(axios.post).toBeCalledWith(
'/user/profile?action=password',
{ current_password: '1', new_password: '1' }
);
expect(swal).toBeCalledWith({ type: 'warning', text: 'w' });
button.trigger('click');
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'success', text: 'o' });
});
test('change nickname', async () => {
axios.post
.mockResolvedValueOnce({ data: { errno: 1, msg: 'w' } })
.mockResolvedValue({ data: { errno: 0, msg: 'o' } });
swal.mockResolvedValueOnce({})
.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
window.$ = jest.fn(() => ({
each(fn) {
fn();
},
html() {}
}));
const wrapper = mount(Profile);
const button = wrapper.find('[data-test=changeNickName]');
button.trigger('click');
expect(axios.post).not.toBeCalled();
expect(swal).toBeCalledWith({ type: 'error', html: 'user.emptyNewNickName' });
wrapper.setData({ nickname: 'nickname' });
button.trigger('click');
expect(axios.post).not.toBeCalled();
expect(swal).toBeCalledWith({
text: 'user.changeNickName',
type: 'question',
showCancelButton: true
});
button.trigger('click');
await wrapper.vm.$nextTick();
expect(axios.post).toBeCalledWith(
'/user/profile?action=nickname',
{ new_nickname: 'nickname' }
);
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'warning', html: 'w' });
button.trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'success', html: 'o' });
});
test('change email', async () => {
axios.post
.mockResolvedValueOnce({ data: { errno: 1, msg: 'w' } })
.mockResolvedValue({ data: { errno: 0, msg: 'o' } });
swal.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Profile);
const button = wrapper.find('[data-test=changeEmail]');
button.trigger('click');
expect(swal).toBeCalledWith({ type: 'error', html: 'user.emptyNewEmail' });
expect(axios.post).not.toBeCalled();
wrapper.setData({ email: 'e' });
button.trigger('click');
expect(swal).toBeCalledWith({ type: 'warning', html: 'auth.invalidEmail' });
expect(axios.post).not.toBeCalled();
wrapper.setData({ email: 'a@b.c', currentPassword: 'abc' });
button.trigger('click');
expect(swal).toBeCalledWith({
text: 'user.changeEmail',
type: 'question',
showCancelButton: true
});
expect(axios.post).not.toBeCalled();
button.trigger('click');
await wrapper.vm.$nextTick();
expect(axios.post).toBeCalledWith(
'/user/profile?action=email',
{ new_email: 'a@b.c', password: 'abc' }
);
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'warning', text: 'w' });
button.trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // There are two promises, so call it twice.
expect(swal).toBeCalledWith({ type: 'success', text: 'o' });
});
test('delete account', async () => {
window.__bs_data__ = { admin: true };
swal.mockResolvedValue();
axios.post
.mockResolvedValueOnce({ data: { errno: 1, msg: 'w' } })
.mockResolvedValue({ data: { errno: 0, msg: 'o' } });
const wrapper = mount(Profile);
const button = wrapper.find('[data-test=deleteAccount]');
button.trigger('click');
expect(swal).toBeCalledWith({ type: 'warning', html: 'user.emptyDeletePassword' });
expect(axios.post).not.toBeCalled();
wrapper.setData({ deleteConfirm: 'abc' });
button.trigger('click');
expect(axios.post).toBeCalledWith(
'/user/profile?action=delete',
{ password: 'abc' }
);
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'warning', html: 'w' });
button.trigger('click');
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'success', html: 'o' });
});

View File

@ -2,7 +2,8 @@ import 'jest-extended';
import Vue from 'vue';
window.blessing = {
base_url: ''
base_url: '',
site_name: 'Blessing Skin'
};
console.log = console.warn = console.error = () => {};

View File

@ -93,6 +93,39 @@ user:
title: Which player should be applied to?
empty: It seems that you own no player...
add: Add new player
profile:
avatar:
title: Change Avatar?
notice: Click the gear icon「<i class="fa fa-cog"></i>」of any skin in your skinlib, then click 「Set as avatar」. We will cut the head segment of that skin for you. If there is no icon like this, please unable the extensions like ADBlock.
password:
title: Change Password
old: Old Password
new: New Password
confirm: Repeat Password
button: Change Password
nickname:
title: Change Nickname
rule: Whatever you like expect special characters
email:
title: Change Email
new: New Email
password: Current Password
button: Change Email
delete:
title: Delete Account
notice: Sure to delete your account on :site?
admin: Admin account can not be deleted.
button: Delete My Account
modal-title: You need to enter your password to continue
modal-notice: |
You're about to delete your account.
This is permanent! No backups, no restores, no magic undo button.
We warned you, ok?
password: Current Password
admin:
operationsTitle: Operations
@ -162,6 +195,7 @@ general:
confirm: OK
cancel: Cancel
submit: Submit
close: Close
more: More
pagination: 'Page :page, total :total'
searchResult: '(Search result of keyword ":keyword")'

View File

@ -93,6 +93,40 @@ user:
title: 要给哪个角色使用呢?
empty: 你好像还没有添加任何角色哦
add: 添加角色
profile:
avatar:
title: 更改头像?
notice: 请在衣柜中任意皮肤的右下角「<i class="fa fa-cog"></i>」处选择「设为头像」,将会自动截取该皮肤的头部作为头像哦~ 如果看不到这个图标,请关闭 ADBlockABP 之类的广告过滤扩展。
password:
title: 更改密码
old: 旧的密码
new: 新密码
confirm: 确认密码
button: 修改密码
nickname:
title: 更改昵称
rule: 可使用除一些特殊符号外的任意字符
email:
title: 更改邮箱
new: 新邮箱
password: 当前密码
button: 修改邮箱
delete:
title: 删除账号
notice: 确定要删除你在 :site 上的账号吗?
admin: 管理员账号不能被删除哟
button: 删除我的账户
modal-title: 这是危险操作,输入密码以继续
modal-notice: |
此操作不可恢复!
你所上传至皮肤库的材质仍会被保留,但你的角色将被永久删除。
我们不提供任何备份,或者神奇的撤销按钮。
我们警告过你了,确定要这样做吗?
password: 当前密码
admin:
operationsTitle: 更多操作
@ -158,6 +192,7 @@ general:
confirm: 确定
cancel: 取消
submit: 提交
close: 关闭
more: 更多
pagination: '第 :page 页,共 :total 页'
searchResult: '(关键词 “:keyword” 的搜索结果)'

View File

@ -91,6 +91,8 @@
</div><!-- ./wrapper -->
@yield('pre-script')
<!-- App Scripts -->
@include('common.dependencies.script', ['module' => 'user'])

View File

@ -14,117 +14,18 @@
</section>
<!-- Main content -->
<section class="content">
<div class="row">
<div class="col-md-6">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.profile.avatar.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
{!! trans('user.profile.avatar.notice') !!}
</div><!-- /.box-body -->
</div>
<div class="box box-warning">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.profile.password.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group">
<label for="password">@lang('user.profile.password.old')</label>
<input type="password" class="form-control" id="password" value="">
</div>
<div class="form-group">
<label for="new-passwd">@lang('user.profile.password.new')</label>
<input type="password" class="form-control" id="new-passwd" value="">
</div>
<div class="form-group">
<label for="confirm-pwd">@lang('user.profile.password.confirm')</label>
<input type="password" class="form-control" id="confirm-pwd" value="">
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button onclick="changePassword()" class="btn btn-primary">@lang('user.profile.password.button')</button>
</div>
</div><!-- /.box -->
</div>
<div class="col-md-6">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.profile.nickname.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group has-feedback">
<input id="new-nickname" type="text" class="form-control" placeholder="{{ ($user->getNickName() == '') ? trans('user.profile.nickname.empty') : '' . trans('user.profile.nickname.rule') }}">
<span class="glyphicon glyphicon-user form-control-feedback"></span>
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button onclick="changeNickName()" class="btn btn-primary">@lang('general.submit')</button>
</div>
</div>
<div class="box box-warning">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.profile.email.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="form-group has-feedback">
<input id="new-email" type="email" class="form-control" placeholder="@lang('user.profile.email.new')">
<span class="glyphicon glyphicon-envelope form-control-feedback"></span>
</div>
<div class="form-group has-feedback" style="display: none;">
<input id="current-password" type="password" class="form-control" placeholder="@lang('user.profile.email.password')">
<span class="glyphicon glyphicon-lock form-control-feedback"></span>
</div>
</div><!-- /.box-body -->
<div class="box-footer">
<button onclick="changeEmail()" class="btn btn-warning">@lang('user.profile.email.button')</button>
</div>
</div>
<div class="box box-danger">
<div class="box-header with-border">
<h3 class="box-title">@lang('user.profile.delete.title')</h3>
</div><!-- /.box-header -->
<div class="box-body">
@if (!$user->isAdmin())
<p>@lang('user.profile.delete.notice', ['site' => option_localized('site_name')])</p>
<button id="delete" class="btn btn-danger" data-toggle="modal" data-target="#modal-delete-account">@lang('user.profile.delete.button')</button>
@else
<p>@lang('user.profile.delete.admin')</p>
<button class="btn btn-danger" disabled="disabled">@lang('user.profile.delete.button')</button>
@endif
</div><!-- /.box-body -->
</div>
</div>
</div>
</section><!-- /.content -->
<section class="content"></section><!-- /.content -->
</div><!-- /.content-wrapper -->
<div id="modal-delete-account" class="modal modal-danger fade" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">@lang('user.profile.delete.modal-title')</h4>
</div>
<div class="modal-body">
{!! nl2br(trans('user.profile.delete.modal-notice')) !!}
<br />
<input type="password" class="form-control" id="password" placeholder="@lang('user.profile.delete.password')">
<br />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" data-dismiss="modal">@lang('general.close')</button>
<a onclick="deleteAccount();" class="btn btn-outline">@lang('general.submit')</a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
@endsection
@section('pre-script')
<script>
Object.defineProperty(window, '__bs_data__', {
value: Object.freeze({
admin: !!{{ $user->isAdmin() }}
}),
writable: false
})
</script>
@endsection