...
 
Commits (17)
......@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- On Reports page add links to reported account and the author of the report
- In Notes add link to the note author's profile page
- In Moderation log add link to the actor's profile page
- Support pagination of local emoji packs and files
- Adds MRF Activity Expiration setting
### Changed
......
......@@ -37,10 +37,9 @@ export async function createPack(host, token, packName) {
export async function deleteEmojiFile(packName, shortcode, host, token) {
return await request({
baseURL: baseName(host),
url: `/api/pleroma/emoji/packs/${packName}/files`,
url: `/api/pleroma/emoji/packs/${packName}/files?shortcode=${shortcode}`,
method: 'delete',
headers: authHeaders(token),
data: { shortcode }
headers: authHeaders(token)
})
}
......@@ -53,25 +52,23 @@ export async function deletePack(host, token, packName) {
})
}
export async function downloadFrom(host, instance, packName, as, token) {
if (as.trim() === '') {
as = null
}
export async function downloadFrom(instanceAddress, packName, as, host, token) {
return await request({
baseURL: baseName(host),
url: '/api/pleroma/emoji/packs/download',
method: 'post',
headers: authHeaders(token),
data: { url: baseName(instance), name: packName, as },
data: as.trim() === ''
? { url: baseName(instanceAddress), name: packName }
: { url: baseName(instanceAddress), name: packName, as },
timeout: 0
})
}
export async function fetchPack(packName, host, token) {
export async function fetchPack(packName, page, pageSize, host, token) {
return await request({
baseURL: baseName(host),
url: `/api/pleroma/emoji/packs/${packName}`,
url: `/api/pleroma/emoji/packs/${packName}?page=${page}&page_size=${pageSize}`,
method: 'get',
headers: authHeaders(token)
})
......@@ -86,11 +83,12 @@ export async function importFromFS(host, token) {
})
}
export async function listPacks(host) {
export async function listPacks(page, pageSize, host, token) {
return await request({
baseURL: baseName(host),
url: `/api/pleroma/emoji/packs/`,
method: 'get'
url: `/api/pleroma/emoji/packs?page=${page}&page_size=${pageSize}`,
method: 'get',
headers: authHeaders(token)
})
}
......
......@@ -475,6 +475,7 @@ export default {
specifyShortcode: 'Specify a custom shortcode',
specifyFilename: 'Specify a custom filename',
copy: 'Copy',
copyToLocalPack: 'Copy to local pack'
copyToLocalPack: 'Copy to local pack',
emptyPack: 'This emoji pack is empty'
}
}
import Vue from 'vue'
import Vuex from 'vuex'
import app from './modules/app'
import emojiPacks from './modules/emojiPacks'
import errorLog from './modules/errorLog'
import moderationLog from './modules/moderationLog'
import getters from './getters'
import invites from './modules/invites'
import moderationLog from './modules/moderationLog'
import peers from './modules/peers'
import permission from './modules/permission'
import relays from './modules/relays'
......@@ -14,8 +16,6 @@ import tagsView from './modules/tagsView'
import user from './modules/user'
import userProfile from './modules/userProfile'
import users from './modules/users'
import getters from './getters'
import emojiPacks from './modules/emojiPacks.js'
Vue.use(Vuex)
......@@ -23,6 +23,7 @@ const store = new Vuex.Store({
modules: {
app,
errorLog,
emojiPacks,
moderationLog,
invites,
peers,
......@@ -34,8 +35,7 @@ const store = new Vuex.Store({
tagsView,
user,
userProfile,
users,
emojiPacks
users
},
getters
})
......
......@@ -4,6 +4,7 @@ import {
deleteEmojiFile,
deletePack,
downloadFrom,
fetchPack,
importFromFS,
listPacks,
listRemotePacks,
......@@ -16,20 +17,41 @@ import { Message } from 'element-ui'
import Vue from 'vue'
const packs = {
const emojiPacks = {
state: {
activeCollapseItems: [],
activeTab: '',
currentFilesPage: 1,
currentPage: 1,
filesPageSize: 30,
localPackFilesCount: 0,
localPacks: {},
localPacksCount: 0,
pageSize: 50,
remoteInstance: '',
remotePacks: {}
},
mutations: {
SET_ACTIVE_COLLAPSE_ITEMS: (state, items) => {
state.activeCollapseItems = items
SET_ACTIVE_TAB: (state, tab) => {
state.activeTab = tab
},
SET_FILES_COUNT: (state, count) => {
state.localPackFilesCount = count
},
SET_FILES_PAGE: (state, page) => {
state.currentFilesPage = page
},
SET_LOCAL_PACKS: (state, packs) => {
state.localPacks = packs
},
SET_LOCAL_PACKS_COUNT: (state, count) => {
state.localPacksCount = count
},
SET_PACK_FILES: (state, { name, files }) => {
state.localPacks = { ...state.localPacks, [name]: { ...state.localPacks[name], files }}
},
SET_PAGE: (state, page) => {
state.currentPage = page
},
SET_REMOTE_INSTANCE: (state, name) => {
state.remoteInstance = name
},
......@@ -67,10 +89,12 @@ const packs = {
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: result.data })
},
async DeleteEmojiFile({ commit, getters }, { packName, shortcode }) {
let result
async DeleteEmojiFile({ commit, dispatch, getters, state }, { packName, shortcode }) {
const { [shortcode]: value, ...updatedPackFiles } = state.localPacks[packName].files
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: updatedPackFiles })
try {
result = await deleteEmojiFile(packName, shortcode, getters.authHost, getters.token)
await deleteEmojiFile(packName, shortcode, getters.authHost, getters.token)
} catch (_e) {
return
}
......@@ -79,8 +103,11 @@ const packs = {
type: 'success',
duration: 5 * 1000
})
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: result.data })
if (Object.keys(updatedPackFiles).length === 0 && state.currentFilesPage > 1) {
dispatch('FetchSinglePack', { name: packName, page: state.currentFilesPage - 1 })
} else {
dispatch('FetchSinglePack', { name: packName, page: state.currentFilesPage })
}
},
async CreatePack({ getters }, { name }) {
await createPack(getters.authHost, getters.token, name)
......@@ -89,7 +116,7 @@ const packs = {
await deletePack(getters.authHost, getters.token, name)
},
async DownloadFrom({ getters }, { instanceAddress, packName, as }) {
const result = await downloadFrom(getters.authHost, instanceAddress, packName, as, getters.token)
const result = await downloadFrom(instanceAddress, packName, as, getters.authHost, getters.token)
if (result.data === 'ok') {
Message({
......@@ -99,6 +126,25 @@ const packs = {
})
}
},
async FetchLocalEmojiPacks({ commit, getters, state }, page) {
const { data } = await listPacks(page, state.pageSize, getters.authHost, getters.token)
const { packs, count } = data
const updatedPacks = Object.keys(packs).reduce((acc, packName) => {
const { files, ...pack } = packs[packName]
acc[packName] = pack
return acc
}, {})
commit('SET_LOCAL_PACKS', updatedPacks)
commit('SET_LOCAL_PACKS_COUNT', count)
commit('SET_PAGE', page)
},
async FetchSinglePack({ getters, commit, state }, { name, page }) {
const { data } = await fetchPack(name, page, state.filesPageSize, getters.authHost, getters.token)
const { files, files_count } = data
commit('SET_PACK_FILES', { name, files })
commit('SET_FILES_COUNT', files_count)
commit('SET_FILES_PAGE', page)
},
async ImportFromFS({ getters }) {
const result = await importFromFS(getters.authHost, getters.token)
......@@ -136,12 +182,8 @@ const packs = {
commit('UPDATE_LOCAL_PACK_PACK', { name: packName, pack: result.data })
}
},
SetActiveCollapseItems({ commit, state }, activeItems) {
commit('SET_ACTIVE_COLLAPSE_ITEMS', activeItems)
},
async SetLocalEmojiPacks({ commit, getters }) {
const { data } = await listPacks(getters.authHost)
commit('SET_LOCAL_PACKS', data)
SetActiveTab({ commit }, activeTab) {
commit('SET_ACTIVE_TAB', activeTab)
},
async SetRemoteEmojiPacks({ commit, getters }, { remoteInstance }) {
const { data } = await listRemotePacks(getters.authHost, getters.token, remoteInstance)
......@@ -152,10 +194,19 @@ const packs = {
SetRemoteInstance({ commit }, instance) {
commit('SET_REMOTE_INSTANCE', instance)
},
async UpdateEmojiFile({ commit, getters }, { packName, shortcode, newShortcode, newFilename, force }) {
let result
async UpdateEmojiFile({ commit, dispatch, getters, state }, { packName, shortcode, newShortcode, newFilename, force }) {
const updatedPackFiles = Object.keys(state.localPacks[packName].files).reduce((acc, el) => {
if (el === shortcode) {
acc[newShortcode] = newFilename
} else {
acc[el] = state.localPacks[packName].files[el]
}
return acc
}, {})
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: updatedPackFiles })
try {
result = await updateEmojiFile(packName, shortcode, newShortcode, newFilename, force, getters.authHost, getters.token)
await updateEmojiFile(packName, shortcode, newShortcode, newFilename, force, getters.authHost, getters.token)
} catch (_e) {
return
}
......@@ -165,7 +216,7 @@ const packs = {
duration: 5 * 1000
})
commit('UPDATE_LOCAL_PACK_FILES', { name: packName, files: result.data })
dispatch('FetchSinglePack', { name: packName, page: state.currentFilesPage })
},
async UpdateLocalPackVal({ commit }, args) {
commit('UPDATE_LOCAL_PACK_VAL', args)
......@@ -173,4 +224,4 @@ const packs = {
}
}
export default packs
export default emojiPacks
......@@ -38,19 +38,32 @@
</el-link>
</div>
</div>
<el-collapse v-model="showPackContent" class="contents-collapse">
<el-collapse v-model="showPackContent" class="contents-collapse" @change="handleChange($event, name)">
<el-collapse-item v-if="isLocal" :title=" $t('emoji.addNewEmoji')" name="addEmoji" class="no-background">
<new-emoji-uploader :pack-name="name"/>
</el-collapse-item>
<el-collapse-item v-if="pack.files.length > 0" :title=" $t('emoji.manageEmoji')" name="manageEmoji" class="no-background">
<single-emoji-editor
v-for="[shortcode, file] in pack.files"
:key="shortcode"
:host="host"
:pack-name="name"
:shortcode="shortcode"
:file="file"
:is-local="isLocal" />
<el-collapse-item :title=" $t('emoji.manageEmoji')" name="manageEmoji" class="no-background">
<div v-if="pack.files && Object.keys(pack.files).length > 0">
<single-emoji-editor
v-for="(file, shortcode) in pack.files"
:key="shortcode"
:host="host"
:pack-name="name"
:shortcode="shortcode"
:file="file"
:is-local="isLocal" />
</div>
<span v-else class="expl">{{ $t('emoji.emptyPack') }}</span>
<div class="files-pagination">
<el-pagination
:total="localPackFilesCount"
:current-page="currentFilesPage"
:page-size="pageSize"
hide-on-single-page
layout="prev, pager, next"
@current-change="handleFilesPageChange"
/>
</div>
</el-collapse-item>
</el-collapse>
</el-collapse-item>
......@@ -86,6 +99,12 @@ export default {
}
},
computed: {
currentFilesPage() {
return this.$store.state.emojiPacks.currentFilesPage
},
currentPage() {
return this.$store.state.emojiPacks.currentPage
},
isMobile() {
return this.$store.state.app.device === 'mobile'
},
......@@ -101,6 +120,12 @@ export default {
return '155px'
}
},
localPackFilesCount() {
return this.$store.state.emojiPacks.localPackFilesCount
},
pageSize() {
return this.$store.state.emojiPacks.filesPageSize
},
share: {
get() { return this.pack.pack['share-files'] },
set(value) {
......@@ -159,6 +184,9 @@ export default {
}
},
methods: {
collapse() {
this.showPackContent = []
},
deletePack() {
this.$confirm('This will delete the pack, are you sure?', 'Warning', {
confirmButtonText: 'Yes, delete the pack',
......@@ -167,9 +195,24 @@ export default {
}).then(() => {
this.$store.dispatch('DeletePack', { name: this.name })
.then(() => this.$store.dispatch('ReloadEmoji'))
.then(() => this.$store.dispatch('SetLocalEmojiPacks'))
.then(() => {
const { [this.name]: value, ...updatedPacks } = this.$store.state.emojiPacks.localPacks
if (Object.keys(updatedPacks).length === 0 && this.currentPage > 1) {
this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage - 1)
} else {
this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage)
}
})
}).catch(() => {})
},
handleChange(openTabs, name) {
if (openTabs.includes('manageEmoji')) {
this.$store.dispatch('FetchSinglePack', { name, page: 1 })
}
},
handleFilesPageChange(page) {
this.$store.dispatch('FetchSinglePack', { name: this.name, page })
},
savePackMetadata() {
this.$store.dispatch('SavePackMetadata', { packName: this.name })
}
......@@ -217,6 +260,10 @@ export default {
margin-bottom: 10px;
}
}
.files-pagination {
margin: 25px 0;
text-align: center;
}
.has-background .el-collapse-item__header {
background: #f6f6f6;
}
......
......@@ -24,7 +24,7 @@
</el-form-item>
<el-form-item>
<el-link
v-if="pack.pack['can-download']"
v-if="pack.pack['can-download'] && pack.pack['fallback-src']"
:href="pack.pack['fallback-src']"
:underline="false"
type="primary"
......@@ -34,15 +34,18 @@
</el-form-item>
</el-form>
<el-collapse v-model="showPackContent" class="contents-collapse">
<el-collapse-item v-if="pack.files.length > 0" :title=" $t('emoji.manageEmoji')" name="manageEmoji" class="no-background">
<single-emoji-editor
v-for="[shortcode, file] in pack.files"
:key="shortcode"
:host="host"
:pack-name="name"
:shortcode="shortcode"
:file="file"
:is-local="isLocal" />
<el-collapse-item :title=" $t('emoji.manageEmoji')" name="manageEmoji" class="no-background">
<div v-if="pack.files && Object.keys(pack.files).length > 0">
<single-emoji-editor
v-for="(file, shortcode) in pack.files"
:key="shortcode"
:host="host"
:pack-name="name"
:shortcode="shortcode"
:file="file"
:is-local="isLocal" />
</div>
<span v-else class="expl">{{ $t('emoji.emptyPack') }}</span>
</el-collapse-item>
<el-collapse-item :title=" $t('emoji.downloadPack')" name="downloadPack" class="no-background">
<p>
......@@ -92,6 +95,9 @@ export default {
}
},
computed: {
currentPage() {
return this.$store.state.emojiPacks.currentPage
},
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
......@@ -111,7 +117,7 @@ export default {
}
},
loadRemotePack() {
return this.$store.state.emojiPacks.activeCollapseItems.includes(this.name)
return this.$store.state.emojiPacks.activeTab === this.name
},
remoteInstanceAddress() {
return this.$store.state.emojiPacks.remoteInstance
......@@ -179,7 +185,7 @@ export default {
'DownloadFrom',
{ instanceAddress: this.remoteInstanceAddress, packName: this.name, as: this.downloadSharedAs }
).then(() => this.$store.dispatch('ReloadEmoji'))
.then(() => this.$store.dispatch('SetLocalEmojiPacks'))
.then(() => this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage))
}
}
}
......
......@@ -103,7 +103,7 @@ export default {
return this.$store.state.emojiPacks.localPacks
},
remoteInstance() {
return new URL(this.$store.state.emojiPacks.remoteInstance).host
return this.$store.state.emojiPacks.remoteInstance
}
},
methods: {
......
......@@ -6,56 +6,72 @@
</div>
<div class="emoji-header-container">
<div class="emoji-packs-header-button-container">
<el-button type="primary" class="reload-emoji-button" @click="reloadEmoji">{{ $t('emoji.reloadEmoji') }}</el-button>
<el-button class="reload-emoji-button" @click="reloadEmoji">{{ $t('emoji.reloadEmoji') }}</el-button>
<el-tooltip :content="$t('emoji.importEmojiTooltip')" effects="dark" placement="bottom" popper-class="import-pack-button">
<el-button type="primary" @click="importFromFS">
<el-button @click="importFromFS">
{{ $t('emoji.importPacks') }}
</el-button>
</el-tooltip>
</div>
</div>
<el-divider class="divider"/>
<el-form :label-width="labelWidth" class="emoji-packs-form">
<el-form-item :label="$t('emoji.localPacks')">
<el-button type="primary" @click="refreshLocalPacks">{{ $t('emoji.refreshLocalPacks') }}</el-button>
</el-form-item>
<el-form-item :label="$t('emoji.createLocalPack')">
<div class="create-pack">
<el-input v-model="newPackName" :placeholder="$t('users.name')" />
<el-button
:disabled="newPackName.trim() === ''"
class="create-pack-button"
@click="createLocalPack">
{{ $t('users.create') }}
</el-button>
</div>
</el-form-item>
<el-form-item v-if="Object.keys(localPacks).length > 0" :label="$t('emoji.packs')">
<el-collapse v-for="(pack, name) in localPacks" :key="name" v-model="activeLocalPack">
<local-emoji-pack :name="name" :pack="sortPack(pack)" :host="$store.getters.authHost" :is-local="true" />
</el-collapse>
</el-form-item>
<el-divider class="divider"/>
<el-form-item :label="$t('emoji.remotePacks')">
<div class="create-pack">
<el-input
v-model="remoteInstanceAddress"
:placeholder="$t('emoji.remoteInstanceAddress')" />
<el-button
v-loading.fullscreen.lock="fullscreenLoading"
:disabled="remoteInstanceAddress.trim() === ''"
class="create-pack-button"
@click="refreshRemotePacks">
{{ $t('emoji.refreshRemote') }}
</el-button>
<el-tabs v-model="activeTab" type="card" class="emoji-packs-tabs">
<el-tab-pane :label="$t('emoji.localPacks')" name="local">
<el-form :label-width="labelWidth" class="emoji-packs-form">
<el-form-item :label="$t('emoji.localPacks')">
<el-button @click="refreshLocalPacks">{{ $t('emoji.refreshLocalPacks') }}</el-button>
</el-form-item>
<el-form-item :label="$t('emoji.createLocalPack')">
<div class="create-pack">
<el-input v-model="newPackName" :placeholder="$t('users.name')" />
<el-button
:disabled="newPackName.trim() === ''"
class="create-pack-button"
@click="createLocalPack">
{{ $t('users.create') }}
</el-button>
</div>
</el-form-item>
<el-form-item v-if="Object.keys(localPacks).length > 0" :label="$t('emoji.packs')">
<el-collapse v-for="(pack, name) in localPacks" :key="name" v-model="activeLocalPack" accordion @change="setActiveTab">
<local-emoji-pack ref="localEmojiPack" :name="name" :pack="pack" :host="$store.getters.authHost" :is-local="true" />
</el-collapse>
</el-form-item>
</el-form>
<div class="pagination">
<el-pagination
:total="localPacksCount"
:current-page="currentPage"
:page-size="pageSize"
hide-on-single-page
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</el-form-item>
<el-form-item v-if="Object.keys(remotePacks).length > 0" :label="$t('emoji.packs')">
<el-collapse v-for="(pack, name) in remotePacks" :key="name" v-model="activeRemotePack" @change="setActiveCollapseItems">
<remote-emoji-pack :name="name" :pack="sortPack(pack)" :host="$store.getters.authHost" :is-local="false" />
</el-collapse>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane :label="$t('emoji.remotePacks')" name="remote">
<el-form :label-width="labelWidth" class="emoji-packs-form">
<el-form-item :label="$t('emoji.remotePacks')">
<div class="create-pack">
<el-input
v-model="remoteInstanceAddress"
:placeholder="$t('emoji.remoteInstanceAddress')" />
<el-button
v-loading.fullscreen.lock="fullscreenLoading"
:disabled="remoteInstanceAddress.trim() === ''"
class="create-pack-button"
@click="refreshRemotePacks">
{{ $t('emoji.refreshRemote') }}
</el-button>
</div>
</el-form-item>
<el-form-item v-if="Object.keys(remotePacks).length > 0" :label="$t('emoji.packs')">
<el-collapse v-for="(pack, name) in remotePacks" :key="name" v-model="activeRemotePack" accordion @change="setActiveTab">
<remote-emoji-pack :name="name" :pack="pack" :host="$store.getters.authHost" :is-local="false" />
</el-collapse>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
......@@ -69,6 +85,7 @@ export default {
components: { LocalEmojiPack, RebootButton, RemoteEmojiPack },
data() {
return {
activeTab: 'local',
newPackName: '',
activeLocalPack: [],
activeRemotePack: [],
......@@ -76,6 +93,9 @@ export default {
}
},
computed: {
currentPage() {
return this.$store.state.emojiPacks.currentPage
},
isMobile() {
return this.$store.state.app.device === 'mobile'
},
......@@ -88,12 +108,18 @@ export default {
} else if (this.isTablet) {
return '180px'
} else {
return '240px'
return '200px'
}
},
localPacks() {
return this.$store.state.emojiPacks.localPacks
},
localPacksCount() {
return this.$store.state.emojiPacks.localPacksCount
},
pageSize() {
return this.$store.state.emojiPacks.pageSize
},
remoteInstanceAddress: {
get() {
return this.$store.state.emojiPacks.remoteInstance
......@@ -117,25 +143,23 @@ export default {
.then(() => {
this.newPackName = ''
this.$store.dispatch('SetLocalEmojiPacks')
this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage)
this.$store.dispatch('ReloadEmoji')
})
},
handlePageChange(page) {
this.$store.dispatch('FetchLocalEmojiPacks', page)
},
importFromFS() {
this.$store.dispatch('ImportFromFS')
.then(() => {
this.$store.dispatch('SetLocalEmojiPacks')
this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage)
this.$store.dispatch('ReloadEmoji')
})
},
sortPack(pack) {
const orderedFiles = Object.keys(pack.files).sort((a, b) => a.localeCompare(b))
.map(key => [key, pack.files[key]])
return { ...pack, files: orderedFiles }
},
refreshLocalPacks() {
try {
this.$store.dispatch('SetLocalEmojiPacks')
this.$store.dispatch('FetchLocalEmojiPacks', this.currentPage)
} catch (e) {
return
}
......@@ -160,15 +184,22 @@ export default {
message: i18n.t('emoji.reloaded')
})
},
setActiveCollapseItems(activeItems) {
const items = Array.isArray(activeItems) ? activeItems : [activeItems]
this.$store.dispatch('SetActiveCollapseItems', items)
setActiveTab(activeTab) {
this.$refs.localEmojiPack.forEach(el => el.collapse())
this.$store.dispatch('SetActiveTab', activeTab)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.create-pack {
display: flex;
justify-content: space-between
}
.create-pack-button {
margin-left: 10px;
}
.emoji-header-container {
display: flex;
align-items: center;
......@@ -178,15 +209,8 @@ export default {
.emoji-packs-header-button-container {
display: flex;
}
.create-pack {
display: flex;
justify-content: space-between
}
.create-pack-button {
margin-left: 10px;
}
.emoji-packs-form {
margin: 0 30px;
margin-top: 15px;
}
.emoji-packs-header {
display: flex;
......@@ -194,6 +218,9 @@ export default {
justify-content: space-between;
margin: 10px 15px 15px 15px;
}
.emoji-packs-tabs {
margin: 0 15px 15px 15px;
}
.import-pack-button {
margin-left: 10px;
width: 30%;
......@@ -208,6 +235,10 @@ h1 {
border: 1px solid #eee;
margin-bottom: 22px;
}
.pagination {
margin: 25px 0;
text-align: center;
}
.reboot-button {
padding: 10px;
margin: 0;
......
......@@ -95,7 +95,7 @@
:total="usersCount"
:current-page="currentPage"
:page-size="pageSize"
background
hide-on-single-page
layout="prev, pager, next"
@current-change="handlePageChange"
/>
......