Add closet page

This commit is contained in:
Pig Fang 2018-08-02 09:45:33 +08:00
parent 5bccfd3d26
commit b7f0ddd7f0
6 changed files with 627 additions and 81 deletions

View File

@ -0,0 +1,309 @@
<template>
<section class="content">
<div class="row">
<!-- Left col -->
<div class="col-md-8">
<!-- Custom tabs -->
<div class="nav-tabs-custom">
<!-- Tabs within a box -->
<ul class="nav nav-tabs">
<li :class="{ active: category === 'skin' }">
<a href="#" @click="switchCategory" v-t="'general.skin'" class="category-switch" data-toggle="tab" />
</li>
<li :class="{ active: category === 'cape' }">
<a href="#" @click="switchCategory" v-t="'general.cape'" class="category-switch" data-toggle="tab" />
</li>
<li class="pull-right" style="padding: 7px;">
<div class="has-feedback pull-right">
<div class="user-search-form">
<input type="text" v-model="query" @input="search" class="form-control input-sm" :placeholder="$t('user.typeToSearch')">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</div>
</li>
</ul>
<div class="tab-content no-padding">
<div v-if="category === 'skin'" class="tab-pane box-body" :class="{ active: category === 'skin' }" id="skin-category">
<div v-if="skinItems.length === 0" class="empty-msg">
<div v-if="query !== ''" v-t="'general.noResult'"></div>
<div v-else v-t="{ path: 'user.emptyClosetMsg', args: { url: linkToSkin } }" />
</div>
<div v-else>
<closet-item
v-for="item in skinItems"
:key="item.tid"
:tid="item.tid"
:name="item.name"
:type="item.type"
:selected="selectedSkin === item.tid"
@select="selectTexture(item.tid)"
@item-removed="removeSkinItem"
></closet-item>
</div>
</div>
<div v-else class="tab-pane box-body" :class="{ active: category === 'cape' }" id="cape-category">
<div v-if="capeItems.length === 0" class="empty-msg">
<div v-if="query !== ''" v-t="'general.noResult'"></div>
<div v-else v-t="{ path: 'user.emptyClosetMsg', args: { url: linkToCape } }" />
</div>
<div v-else>
<closet-item
v-for="item in capeItems"
:key="item.tid"
:tid="item.tid"
:name="item.name"
:type="item.type"
:selected="selectedCape === item.tid"
@select="selectTexture(item.tid)"
@item-removed="removeCapeItem"
></closet-item>
</div>
</div>
</div>
<div class="box-footer">
<paginate
v-if="category === 'skin'"
v-model="skinCurrentPage"
:page-count="skinTotalPages"
class="pull-right"
container-class="pagination pagination-sm no-margin"
first-button-text="«"
prev-text=""
next-text=""
last-button-text="»"
:click-handler="pageChanged"
:first-last-button="true"
></paginate>
<paginate
v-else
v-model="capeCurrentPages"
:page-count="capeTotalPages"
class="pull-right"
container-class="pagination pagination-sm no-margin"
first-button-text="«"
prev-text=""
next-text=""
last-button-text="»"
:click-handler="pageChanged"
:first-last-button="true"
></paginate>
</div>
</div><!-- /.nav-tabs-custom -->
</div>
<!-- Right col -->
<div class="col-md-4">
<previewer closet-mode :skin="skinUrl" :cape="capeUrl">
<template slot="footer">
<button
class="btn btn-primary"
data-toggle="modal"
data-target="#modal-use-as"
v-t="'user.useAs'"
@click="applyTexture"
></button>
<button
class="btn btn-default pull-right"
v-t="'user.resetSelected'"
@click="resetSelected"
></button>
</template>
</previewer>
</div>
</div>
<div id="modal-use-as" class="modal 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.closet.use-as.title'"></h4>
</div>
<div class="modal-body">
<template v-if="players.length !== 0">
<div v-for="player in players" :key="player.pid" class="player-item">
<label class="model-label" :for="player.pid">
<input type="radio" name="player" :value="player.pid" v-model="selectedPlayer" />
<img :src="avatarUrl(player)" width="35" height="35" />
<span>{{ player.player_name }}</span>
</label>
</div>
</template>
<p v-else v-t="'user.closet.use-as.empty'"></p>
</div>
<div class="modal-footer">
<a href="./player" class="btn btn-default pull-left" v-t="'user.closet.use-as.add'"></a>
<a class="btn btn-primary" v-t="'general.submit'" @click="submitApplyTexture"></a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</section><!-- /.content -->
</template>
<script>
import axios from 'axios';
import toastr from 'toastr';
import Paginate from 'vuejs-paginate';
import ClosetItem from './ClosetItem';
import { debounce } from '../../js/utils';
import { swal } from '../../js/notify';
export default {
name: 'Closet',
components: {
Paginate,
ClosetItem,
Previewer: () => import('../common/Previewer'),
},
data: () => ({
category: 'skin',
query: '',
skinItems: [],
skinCurrentPage: 1,
skinTotalPages: 1,
capeItems: [],
capeCurrentPages: 1,
capeTotalPages: 1,
selectedSkin: 0,
skinUrl: '',
selectedCape: 0,
capeUrl: '',
players: [],
selectedPlayer: 0,
}),
computed: {
linkToSkin: () => `${blessing.base_url}/skinlib?filter=skin`,
linkToCape: () => `${blessing.base_url}/skinlib?filter=cape`,
},
methods: {
search() {},
async loadCloset(page = 1) {
const { data: { items, category, total_pages } } = await axios({
method: 'GET',
url: '/user/closet-data',
params: {
category: this.category,
q: this.query,
page,
}
});
this[`${category}TotalPages`] = total_pages;
this[`${category}Items`] = items;
},
removeSkinItem(tid) {
this.skinItems = this.skinItems.filter(item => item.tid !== tid);
},
removeCapeItem(tid) {
this.capeItems = this.capeItems.filter(item => item.tid !== tid);
},
switchCategory() {
this.category = this.category === 'skin' ? 'cape' : 'skin';
this.loadCloset();
},
pageChanged(page) {
this.loadCloset(page);
},
avatarUrl(player) {
const tid = player.preference === 'default' ? player.tid_steve : player.tid_alex;
return `${blessing.base_url}/avatar/35/${tid}`;
},
async selectTexture(tid) {
const { data: { type, hash } } = await axios.post(`/skinlib/info/${tid}`);
if (type === 'cape') {
this.capeUrl = `/textures/${hash}`;
this.selectedCape = tid;
} else {
this.skinUrl = `/textures/${hash}`;
this.selectedSkin = tid;
}
},
async applyTexture() {
const { data: players } = await axios.get('/user/player/list');
this.players = players;
setTimeout(() => {
$(this.$el).iCheck({
radioClass: 'iradio_square-blue',
checkboxClass: 'icheckbox_square-blue'
}).on('ifChanged', function () {
$(this)[0].dispatchEvent(new Event('change'));
});
}, 0);
},
async submitApplyTexture() {
if (!this.selectedPlayer) {
return toastr.info(this.$t('user.emptySelectedPlayer'));
}
if (!this.selectedSkin && !this.selectedCape) {
return toastr.info(this.$t('user.emptySelectedTexture'));
}
const { data: { errno, msg } } = await axios.post(
'/user/player/set',
{
pid: this.selectedPlayer,
tid: {
skin: this.selectedSkin || undefined,
cape: this.selectedCape || undefined
}
}
);
if (errno === 0) {
swal({ type: 'success', html: msg });
$('#modal-use-as').modal('hide');
} else {
toastr.warning(msg);
}
},
resetSelected() {
this.selectedSkin = this.selectedCape = 0;
this.skinUrl = this.capeUrl = '';
}
},
created() {
this.search = debounce(this.loadCloset, 350);
},
beforeMount() {
this.loadCloset();
}
};
</script>
<style lang="stylus">
.empty-msg {
text-align: center;
font-size: 16px;
padding: 10px 0;
}
.texture-name {
width: 65%;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-footer > .dropdown-menu {
margin-left: 180px;
}
.box-title {
a {
color: #6d6d6d;
}
a.selected {
color: #3c8dbc;
}
}
.player-item:not(:nth-child(1)) {
margin-top: 10px;
}
</style>

View File

@ -0,0 +1,11 @@
/**
* @param {Function} func
* @param {number} delay
*/
export function debounce(func, delay) {
let timer;
return () => {
clearTimeout(timer);
timer = setTimeout(func, delay);
};
}

View File

@ -0,0 +1,284 @@
import { mount } from '@vue/test-utils';
import Closet from '@/components/user/Closet';
import ClosetItem from '@/components/user/ClosetItem';
import Previewer from '@/components/common/Previewer';
import axios from 'axios';
import toastr from 'toastr';
import { swal } from '@/js/notify';
jest.mock('axios');
jest.mock('@/js/notify');
test('fetch closet data before mount', () => {
axios.mockResolvedValue({ data: {} });
mount(Closet);
jest.runAllTicks();
expect(axios).toBeCalledWith({
method: 'GET',
url: '/user/closet-data',
params: {
category: 'skin',
q: '',
page: 1,
}
});
});
test('switch tabs', () => {
axios.mockResolvedValue({
data: {
items: [],
category: 'skin',
total_pages: 1
}
}).mockResolvedValueOnce({
data: {
items: [],
category: 'cape',
total_pages: 1
}
});
const wrapper = mount(Closet);
const tabSkin = wrapper.findAll('.nav-tabs > li').at(0);
tabSkin.find('a').trigger('click');
jest.runAllTicks();
expect(axios).toBeCalledWith({
method: 'GET',
url: '/user/closet-data',
params: {
category: 'skin',
q: '',
page: 1,
}
});
const tabCape = wrapper.findAll('.nav-tabs > li').at(1);
tabCape.find('a').trigger('click');
jest.runAllTicks();
expect(axios).toBeCalledWith({
method: 'GET',
url: '/user/closet-data',
params: {
category: 'cape',
q: '',
page: 1,
}
});
});
test('different categories', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
expect(wrapper.findAll('.nav-tabs > li').at(0).classes()).toContain('active');
expect(wrapper.find('#skin-category').classes()).toContain('active');
wrapper.setData({ category: 'cape' });
expect(wrapper.findAll('.nav-tabs > li').at(1).classes()).toContain('active');
expect(wrapper.find('#cape-category').classes()).toContain('active');
});
test('search textures', () => {
jest.useFakeTimers();
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
const input = wrapper.find('input');
input.element.value = 'q';
input.trigger('input');
jest.runAllTimers();
jest.runAllTicks();
expect(axios).toBeCalledWith({
method: 'GET',
url: '/user/closet-data',
params: {
category: 'skin',
q: 'q',
page: 1,
}
});
jest.useRealTimers();
});
test('empty closet', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
expect(wrapper.find('#skin-category').text()).toContain('user.emptyClosetMsg');
wrapper.setData({ category: 'cape' });
expect(wrapper.find('#cape-category').text()).toContain('user.emptyClosetMsg');
});
test('no matched search result', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
wrapper.setData({ query: 'q' });
expect(wrapper.find('#skin-category').text()).toContain('general.noResult');
wrapper.setData({ category: 'cape' });
expect(wrapper.find('#cape-category').text()).toContain('general.noResult');
});
test('render items', async () => {
axios.mockResolvedValue({ data: {
items: [
{ tid: 1 },
{ tid: 2 }
],
category: 'skin',
total_pages: 1
} });
const wrapper = mount(Closet);
await wrapper.vm.$nextTick();
expect(wrapper.findAll(ClosetItem)).toHaveLength(2);
});
test('reload closet when page changed', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
wrapper.vm.pageChanged();
jest.runAllTicks();
expect(axios).toHaveBeenCalledTimes(2);
});
test('remove skin item', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
wrapper.setData({ skinItems: [{ tid: 1 }, { tid: 2 }] });
wrapper.vm.removeSkinItem(1);
expect(wrapper.findAll(ClosetItem)).toHaveLength(1);
});
test('remove cape item', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
wrapper.setData({ capeItems: [{ tid: 1 }, { tid: 2 }], category: 'cape' });
wrapper.vm.removeCapeItem(1);
expect(wrapper.findAll(ClosetItem)).toHaveLength(1);
});
test('compute avatar URL', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
const { avatarUrl } = wrapper.vm;
expect(avatarUrl({ preference: 'default', tid_steve: 1 })).toBe('/avatar/35/1');
expect(avatarUrl({ preference: 'slim', tid_alex: 1 })).toBe('/avatar/35/1');
});
test('select texture', async () => {
axios.mockResolvedValue({ data: {} });
axios.post.mockResolvedValueOnce({ data: { type: 'steve', hash: 'a' } })
.mockResolvedValueOnce({ data: { type: 'cape', hash: 'b' } });
const wrapper = mount(Closet);
wrapper.setData({ skinItems: [{ tid: 1 }] });
wrapper.find(ClosetItem).vm.$emit('select');
await wrapper.vm.$nextTick();
expect(axios.post).toBeCalledWith('/skinlib/info/1');
expect(wrapper.vm.skinUrl).toBe('/textures/a');
wrapper.setData({ skinItems: [], capeItems: [{ tid: 2 }], category: 'cape' });
wrapper.find(ClosetItem).vm.$emit('select');
await wrapper.vm.$nextTick();
expect(axios.post).toBeCalledWith('/skinlib/info/2');
expect(wrapper.vm.capeUrl).toBe('/textures/b');
});
test('apply texture', async () => {
jest.useFakeTimers();
window.$ = jest.fn(() => ({
iCheck: () => ({
on(evt, cb) {
cb();
},
}),
0: {
dispatchEvent: () => {}
}
}));
axios.mockResolvedValue({ data: {} });
axios.get.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [
{ pid: 1, player_name: 'name', preference: 'default', tid_steve: 10 }
] });
const wrapper = mount(Closet);
const button = wrapper.find(Previewer).findAll('button').at(0);
button.trigger('click');
jest.runAllTicks();
expect(wrapper.find('.modal-body').text()).toContain('user.closet.use-as.empty');
button.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('input[type="radio"]').attributes()).toHaveProperty('value', '1');
expect(wrapper.find('.model-label > img').attributes())
.toHaveProperty('src', '/avatar/35/10');
expect(wrapper.find('.modal-body').text()).toContain('name');
jest.runAllTimers();
jest.useRealTimers();
});
test('submit applying texture', async () => {
window.$ = jest.fn(() => ({ modal() {} }));
jest.spyOn(toastr, 'info');
axios.mockResolvedValue({ data: {} });
axios.post.mockResolvedValueOnce({ data: { errno: 1 } })
.mockResolvedValue({ data: { errno: 0, msg: 'ok' } });
const wrapper = mount(Closet);
const button = wrapper.find('.modal-footer > a:nth-child(2)');
button.trigger('click');
expect(toastr.info).toBeCalledWith('user.emptySelectedPlayer');
wrapper.setData({ selectedPlayer: 1 });
button.trigger('click');
expect(toastr.info).toBeCalledWith('user.emptySelectedTexture');
wrapper.setData({ selectedSkin: 1 });
button.trigger('click');
expect(axios.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: 1,
cape: undefined
}
}
);
wrapper.setData({ selectedSkin: 0, selectedCape: 1 });
button.trigger('click');
expect(axios.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: undefined,
cape: 1
}
}
);
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'success', html: 'ok' });
});
test('reset selected texture', () => {
axios.mockResolvedValue({ data: {} });
const wrapper = mount(Closet);
wrapper.setData({
selectedSkin: 1,
selectedCape: 2,
skinUrl: 'a',
capeUrl: 'b'
});
wrapper.find(Previewer).findAll('button').at(1).trigger('click');
expect(wrapper.vm).toEqual(expect.objectContaining({
selectedSkin: 0,
selectedCape: 0,
skinUrl: '',
capeUrl: ''
}));
});

View File

@ -83,6 +83,16 @@ user:
emptyNewEmail: Empty new email address.
changeEmail: 'Sure to change your email address to :new_email?'
emptyDeletePassword: 'Please enter the current password:'
typeToSearch: Type to search
useAs: Apply...
resetSelected: Clear selected
closet:
use-as:
button: Apply...
reset: Clear selected
title: Which player should be applied to?
empty: It seems that you own no player...
add: Add new player
admin:
operationsTitle: Operations
@ -151,6 +161,7 @@ general:
confirmLogout: Sure to log out?
confirm: OK
cancel: Cancel
submit: Submit
more: More
pagination: 'Page :page, total :total'
searchResult: '(Search result of keyword ":keyword")'

View File

@ -83,6 +83,16 @@ user:
emptyNewEmail: 你还没有填写新邮箱啊
changeEmail: '确定要将用户邮箱更改为 :new_email 吗?'
emptyDeletePassword: 请先输入当前用户密码
typeToSearch: 输入即搜索
useAs: 使用...
resetSelected: 重置已选材质
closet:
use-as:
button: 使用...
reset: 重置已选材质
title: 要给哪个角色使用呢?
empty: 你好像还没有添加任何角色哦
add: 添加角色
admin:
operationsTitle: 更多操作
@ -147,6 +157,7 @@ general:
confirmLogout: 确定要登出吗?
confirm: 确定
cancel: 取消
submit: 提交
more: 更多
pagination: '第 :page 页,共 :total 页'
searchResult: '(关键词 “:keyword” 的搜索结果)'

View File

@ -18,87 +18,7 @@
</section>
<!-- Main content -->
<section class="content">
<div class="row">
<!-- Left col -->
<div class="col-md-8">
<!-- Custom tabs -->
<div class="nav-tabs-custom" id="closet-container">
<!-- Tabs within a box -->
<ul class="nav nav-tabs">
<li class="active"><a href="#skin-category" class="category-switch" data-toggle="tab">@lang('general.skin')</a></li>
<li><a href="#cape-category" class="category-switch" data-toggle="tab">@lang('general.cape')</a></li>
<li class="pull-right" style="padding: 7px;">
<div class="has-feedback pull-right">
<div class="user-search-form">
<input type="text" name="q" class="form-control input-sm" placeholder="@lang('user.closet.type-to-search')">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</div>
</li>
</ul>
<div class="tab-content no-padding">
<div class="tab-pane active box-body" id="skin-category"></div>
<div class="tab-pane box-body" id="cape-category"></div>
</div>
<div class="box-footer">
<div class="pull-right" id="closet-paginator" last-skin-page="1" last-cape-page="1"></div>
</div>
</div><!-- /.nav-tabs-custom -->
</div>
<!-- Right col -->
<div class="col-md-4">
<div class="box box-default">
@include('common.texture-preview')
<div class="box-footer">
<button class="btn btn-primary" data-toggle="modal" data-target="#modal-use-as">@lang('user.closet.use-as.button')</button>
<button class="btn btn-default pull-right" id="closet-reset">@lang('user.closet.use-as.reset')</button>
</div><!-- /.box-footer -->
</div>
</div>
</div>
</section><!-- /.content -->
<section class="content"></section><!-- /.content -->
</div><!-- /.content-wrapper -->
<div id="modal-use-as" class="modal 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.closet.use-as.title')</h4>
</div>
<div class="modal-body">
@forelse($user->players as $player)
<label class="model-label" for="{{ $player->pid }}">
<input type="radio" id="{{ $player->pid }}" name="player" /> {{ $player->player_name }}
</label><br />
@empty
<p>@lang('user.closet.use-as.empty')</p>
@endforelse
</div>
<div class="modal-footer">
<a href="./player" class="btn btn-default pull-left">@lang('user.closet.use-as.add')</a>
<a onclick="setTexture();" class="btn btn-primary">@lang('general.submit')</a>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
@endsection
@section('script')
<script>
$(document).ready(function () {
$.msp.config.skinUrl = defaultSteveSkin;
initSkinViewer();
registerAnimationController();
registerWindowResizeHandler();
});
</script>
@endsection