new plugins management page

This commit is contained in:
Pig Fang 2020-01-16 12:33:14 +08:00
parent 85f9f8378a
commit 7ecea9e7e6
12 changed files with 137 additions and 294 deletions

View File

@ -21,7 +21,6 @@ class MarketController extends Controller
$plugin = $manager->get($item['name']);
if ($plugin) {
$item['enabled'] = $plugin->isEnabled();
$item['installed'] = $plugin->version;
$item['can_update'] = Comparator::greaterThan($item['version'], $item['installed']);
} else {

View File

@ -88,21 +88,19 @@ class PluginController extends Controller
public function getPluginData(PluginManager $plugins)
{
return $plugins->all()
->map(function ($plugin) use ($plugins) {
->map(function (Plugin $plugin) {
return [
'name' => $plugin->name,
'title' => trans($plugin->title ?: 'EMPTY'),
'author' => $plugin->author,
'description' => trans($plugin->description ?: 'EMPTY'),
'title' => trans($plugin->title),
'description' => trans($plugin->description ?? ''),
'version' => $plugin->version,
'url' => $plugin->url,
'enabled' => $plugin->isEnabled(),
'readme' => (bool) $plugin->getReadme(),
'config' => $plugin->hasConfig(),
'dependencies' => [
'all' => $plugin->require,
'unsatisfied' => $plugins->getUnsatisfied($plugin),
],
'icon' => array_merge(
['fa' => 'plug', 'faType' => 'fas', 'bg' => 'navy'],
$plugin->getManifestAttr('enchants.icon', [])
),
];
})
->values();

View File

@ -16,7 +16,7 @@ class Plugin
];
/**
* The full directory of this plugin.
* The full path of this plugin.
*
* @var string
*/
@ -29,11 +29,6 @@ class Plugin
*/
protected $manifest;
/**
* Whether the plugin is enabled.
*
* @var bool
*/
protected $enabled = false;
public function __construct(string $path, array $manifest)

View File

@ -1,40 +0,0 @@
import Vue from 'vue'
import { showModal, toast } from '../../scripts/notify'
import alertUnresolvedPlugins from './alertUnresolvedPlugins'
export default Vue.extend({
data: () => ({ plugins: [] }),
methods: {
async enablePlugin({
name, dependencies: { all }, originalIndex,
}: {
name: string
dependencies: { all: Record<string, string> }
originalIndex: number
}) {
if (Object.keys(all).length === 0) {
try {
await showModal({
text: this.$t('admin.noDependenciesNotice'),
okButtonType: 'warning',
})
} catch {
return
}
}
const {
code, message, data: { reason } = { reason: [] },
} = await this.$http.post(
'/admin/plugins/manage',
{ action: 'enable', name },
) as { code: number, message: string, data: { reason: string[] } }
if (code === 0) {
toast.success(message)
this.$set(this.plugins[originalIndex], 'enabled', true)
} else {
alertUnresolvedPlugins(message, reason)
}
},
},
})

View File

@ -43,11 +43,8 @@
<i class="fas fa-sync-alt" /> {{ $t('admin.updatePlugin') }}
</template>
</button>
<button v-else-if="props.row.enabled" class="btn btn-primary" disabled>
<i class="fas fa-check" /> {{ $t('admin.statusEnabled') }}
</button>
<button v-else class="btn btn-primary" @click="enablePlugin(props.row)">
<i class="fas fa-plug" /> {{ $t('admin.enablePlugin') }}
<button v-else class="btn btn-default" disabled>
<i class="fas fa-download" /> {{ $t('admin.installPlugin') }}
</button>
</template>
<button
@ -74,7 +71,6 @@
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import alertUnresolvedPlugins from '../../components/mixins/alertUnresolvedPlugins'
import enablePlugin from '../../components/mixins/enablePlugin'
import tableOptions from '../../components/mixins/tableOptions'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
@ -86,7 +82,6 @@ export default {
},
mixins: [
emitMounted,
enablePlugin,
tableOptions,
],
data() {

View File

@ -1,104 +1,51 @@
<template>
<div class="container-fluid">
<vue-good-table
:rows="plugins"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
:row-style-class="rowStyleClassFn"
>
<template #table-row="props">
<span v-if="props.column.field === 'title'">
<strong>{{ props.formattedRow[props.column.field] }}</strong>
<div class="actions">
<template v-if="props.row.readme">
<a
:href="`${baseUrl}/admin/plugins/readme/${props.row.name}`"
class="text-primary"
>{{ $t('admin.pluginReadme') }}</a> |
</template>
<template v-if="props.row.enabled" class="actions">
<template v-if="props.row.config">
<a
v-t="'admin.configurePlugin'"
class="text-primary"
:href="`${baseUrl}/admin/plugins/config/${props.row.name}`"
/> |
</template>
<a
v-t="'admin.disablePlugin'"
href="#"
class="text-primary"
@click="disablePlugin(props.row)"
/>
</template>
<template v-else class="actions">
<a
v-t="'admin.enablePlugin'"
href="#"
class="text-primary"
@click="enablePlugin(props.row)"
/> |
<a
v-t="'admin.deletePlugin'"
href="#"
class="text-danger"
@click="deletePlugin(props.row)"
/>
</template>
<div class="container-fluid d-flex flex-wrap">
<div v-for="(plugin, index) in plugins" :key="plugin.name" class="info-box mr-3">
<span class="info-box-icon" :class="`bg-${plugin.icon.bg}`">
<i :class="`${plugin.icon.faType} fa-${plugin.icon.fa}`" />
</span>
<div class="info-box-content">
<div class="d-flex justify-content-between">
<div>
<input :checked="plugin.enabled" type="checkbox" @click.prevent="switchPlugin(plugin, $event)">&nbsp;
<strong>{{ plugin.title }}</strong>&nbsp;
<span class="text-gray">v{{ plugin.version }}</span>
</div>
</span>
<span v-else-if="props.column.field === 'description'">
<div><p>{{ props.formattedRow.description }}</p></div>
<div class="plugin-version-author">
{{ $t('admin.pluginVersion') }}
<span class="text-primary">{{ props.row.version }}</span> |
{{ $t('admin.pluginAuthor') }}
<a :href="props.row.url">{{ props.row.author }}</a> |
{{ $t('admin.pluginName') }}
{{ props.row.name }}
</div>
</span>
<span v-else-if="props.column.field === 'dependencies'">
<span v-if="Object.keys(props.row.dependencies.all).length === 0">
<i v-t="'admin.noDependencies'" />
</span>
<div v-else>
<span
v-for="(constraint, name) in props.row.dependencies.all"
:key="name"
class="badge"
:class="`bg-${name in props.row.dependencies.unsatisfied ? 'red' : 'green'}`"
<div class="plugin-actions">
<a
v-if="plugin.readme"
:href="`${baseUrl}/admin/plugins/readme/${plugin.name}`"
>
{{ name }}: {{ constraint }}
<br>
</span>
<i class="fas fa-question" />
</a>
<a
v-if="plugin.enabled && plugin.config"
:href="`${baseUrl}/admin/plugins/config/${plugin.name}`"
>
<i class="fas fa-cog" />
</a>
<a href="#" @click="deletePlugin(plugin, index)">
<i class="fas fa-trash" />
</a>
</div>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</div>
<div class="mt-2 plugin-desc" :title="plugin.description">
{{ plugin.description }}
</div>
</div>
</div>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import enablePlugin from '../../components/mixins/enablePlugin'
import tableOptions from '../../components/mixins/tableOptions'
import alertUnresolvedPlugins from '../../components/mixins/alertUnresolvedPlugins'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
export default {
name: 'Plugins',
components: {
VueGoodTable,
},
mixins: [
emitMounted,
enablePlugin,
tableOptions,
],
props: {
baseUrl: {
@ -109,23 +56,6 @@ export default {
data() {
return {
plugins: [],
columns: [
{
field: 'title', label: this.$t('admin.pluginTitle'), width: '17%',
},
{
field: 'description',
label: this.$t('admin.pluginDescription'),
sortable: false,
width: '65%',
},
{
field: 'dependencies',
label: this.$t('admin.pluginDependencies'),
sortable: false,
globalSearchDisabled: true,
},
],
}
},
beforeMount() {
@ -135,27 +65,47 @@ export default {
async fetchData() {
this.plugins = await this.$http.get('/admin/plugins/data')
},
rowStyleClassFn(row) {
return row.enabled ? 'plugin-enabled' : 'plugin'
async switchPlugin(plugin, { target }) {
if (target.checked) {
if (await this.enablePlugin(plugin.name)) {
plugin.enabled = true
}
} else if (await this.disablePlugin(plugin.name)) {
plugin.enabled = false
}
},
async disablePlugin({ name, originalIndex }) {
async enablePlugin(name) {
const {
code, message, data: { reason } = { reason: [] },
} = await this.$http.post(
'/admin/plugins/manage',
{ action: 'enable', name },
)
if (code === 0) {
toast.success(message)
} else {
alertUnresolvedPlugins(message, reason)
}
return code === 0
},
async disablePlugin(name) {
const { code, message } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'disable', name },
)
if (code === 0) {
toast.success(message)
this.plugins[originalIndex].enabled = false
} else {
toast.error(message)
}
return code === 0
},
async deletePlugin({
name, title, originalIndex,
}) {
async deletePlugin(plugin, index) {
try {
await showModal({
title,
title: plugin.title,
text: this.$t('admin.confirmDeletion'),
okButtonType: 'danger',
})
@ -165,10 +115,10 @@ export default {
const { code, message } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'delete', name },
{ action: 'delete', name: plugin.name },
)
if (code === 0) {
this.$delete(this.plugins, originalIndex)
this.$delete(this.plugins, index)
toast.success(message)
} else {
toast.error(message)
@ -179,25 +129,35 @@ export default {
</script>
<style lang="stylus">
.actions
margin-top 5px
color #ddd
.info-box
cursor default
transition-property box-shadow
transition-duration 0.3s
width 32%
@media (max-width: 1280px)
width 47%
@media (max-width: 768px)
width 100%
&:hover
box-shadow 0 .5rem 1rem rgba(0,0,0,.15)
.plugin-version-author
color #777
font-size small
a
color #337ab7
.info-box-content
max-width 85%
.plugin:nth-of-type(2n+1) > td:first-child
border-left 5px solid rgba(51, 68, 109, 0.03)
.plugin-actions
margin-top -7px
a
transition-property color
transition-duration 0.3s
color #000
&:hover
color #999
&:not(:last-child)
margin-right 7px
.plugin:nth-of-type(2n) > td:first-child
border-left 5px solid #fff
.plugin-enabled
background-color #f7fcfe
.plugin-enabled > td:first-child
border-left 5px solid #3c8dbc
.plugin-desc
font-size 14px
white-space nowrap
overflow hidden
text-overflow ellipsis
</style>

View File

@ -30,13 +30,10 @@ test('render operation buttons', async () => {
name: 'a', dependencies: { all: {}, unsatisfied: {} }, installed: true, can_update: true,
},
{
name: 'b', dependencies: { all: {}, unsatisfied: {} }, installed: true, enabled: true,
name: 'b', dependencies: { all: {}, unsatisfied: {} }, installed: true,
},
{
name: 'c', dependencies: { all: {}, unsatisfied: {} }, installed: true,
},
{
name: 'd', dependencies: { all: {}, unsatisfied: {} }, installed: false,
name: 'c', dependencies: { all: {}, unsatisfied: {} }, installed: false,
},
])
const wrapper = mount(Market)
@ -44,9 +41,9 @@ test('render operation buttons', async () => {
const tbody = wrapper.find('tbody')
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.updatePlugin')
expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.statusEnabled')
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin')
expect(tbody.find('tr:nth-child(4)').text()).toContain('admin.installPlugin')
expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.installPlugin')
expect(tbody.find('tr:nth-child(2) button').attributes('disabled')).toBeTruthy()
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.installPlugin')
})
test('install plugin', async () => {
@ -80,7 +77,7 @@ test('install plugin', async () => {
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('admin.enablePlugin')
expect(wrapper.find('.btn-default').attributes('disabled')).toBeTruthy()
})
test('update plugin', async () => {

View File

@ -6,54 +6,30 @@ import Plugins from '@/views/admin/Plugins.vue'
jest.mock('@/scripts/notify')
test('render dependencies', async () => {
test('render config button', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { all: {}, unsatisfied: {} } },
{
name: 'b',
dependencies: {
all: { a: '^1.0.0', c: '^2.0.0' }, unsatisfied: { c: '' },
},
name: 'a', icon: {}, enabled: true, config: true,
},
{
name: 'b', icon: {}, enabled: false, config: true,
},
{
name: 'c', icon: {}, enabled: false, config: false,
},
])
const wrapper = mount(Plugins)
await flushPromises()
expect(wrapper.text()).toContain('admin.noDependencies')
expect(wrapper.find('span.badge.bg-green').text()).toBe('a: ^1.0.0')
expect(wrapper.find('span.badge.bg-red').text()).toBe('c: ^2.0.0')
})
test('render operation buttons', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
name: 'a', dependencies: { all: {}, unsatisfied: {} }, enabled: true, config: true,
},
{
name: 'b', dependencies: { all: {}, unsatisfied: {} }, enabled: true, config: false,
},
{
name: 'c', dependencies: { all: {}, unsatisfied: {} }, enabled: false,
},
])
const wrapper = mount(Plugins)
await flushPromises()
const tbody = wrapper.find('tbody')
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.disablePlugin')
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.configurePlugin')
expect(tbody.find('tr:nth-child(2)').text()).not.toContain('admin.configurePlugin')
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin')
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.deletePlugin')
expect(wrapper.find('.info-box:nth-child(1) .fa-cog').exists()).toBeTrue()
expect(wrapper.find('.info-box:nth-child(2) .fa-cog').exists()).toBeFalse()
expect(wrapper.find('.info-box:nth-child(3) .fa-cog').exists()).toBeFalse()
})
test('enable plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
name: 'a', dependencies: { all: {}, unsatisfied: {} }, enabled: false,
},
{
name: 'b', dependencies: { all: { c: '' }, unsatisfied: {} }, enabled: false,
name: 'a', icon: {}, enabled: false,
},
])
Vue.prototype.$http.post
@ -61,29 +37,11 @@ test('enable plugin', async () => {
code: 1, message: '1', data: { reason: ['abc'] },
})
.mockResolvedValue({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '' })
const wrapper = mount(Plugins)
await flushPromises()
const checkbox = wrapper.find('input[type=checkbox]')
wrapper
.findAll('.actions')
.at(0)
.find('a')
.trigger('click')
await flushPromises()
expect(showModal).toBeCalledWith({
text: 'admin.noDependenciesNotice',
okButtonType: 'warning',
})
expect(Vue.prototype.$http.post).not.toBeCalled()
wrapper
.findAll('.actions')
.at(0)
.find('a')
.trigger('click')
checkbox.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
@ -94,19 +52,15 @@ test('enable plugin', async () => {
dangerousHTML: expect.stringContaining('<li>abc</li>'),
})
wrapper
.findAll('.actions')
.at(1)
.find('a')
.trigger('click')
checkbox.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('admin.disablePlugin')
expect(toast.success).toBeCalled()
})
test('disable plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
name: 'a', dependencies: { all: {}, unsatisfied: {} }, enabled: true, config: false,
name: 'a', icon: {}, enabled: true,
},
])
Vue.prototype.$http.post
@ -114,18 +68,18 @@ test('disable plugin', async () => {
.mockResolvedValue({ code: 0, message: '0' })
const wrapper = mount(Plugins)
await flushPromises()
const button = wrapper.find('.actions').find('a')
const checkbox = wrapper.find('input[type="checkbox"]')
button.trigger('click')
checkbox.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
{ action: 'disable', name: 'a' },
)
button.trigger('click')
checkbox.trigger('click')
await flushPromises()
expect(toast.success).toBeCalledWith('0')
expect(wrapper.text()).toContain('admin.enablePlugin')
expect(checkbox.attributes('checked')).toBeFalsy()
})
test('delete plugin', async () => {
@ -133,8 +87,7 @@ test('delete plugin', async () => {
{
name: 'a',
title: 'My Plugin',
dependencies: { all: {}, unsatisfied: {} },
enabled: false,
icon: {},
},
])
Vue.prototype.$http.post
@ -145,8 +98,7 @@ test('delete plugin', async () => {
.mockResolvedValue({ value: '' })
const wrapper = mount(Plugins)
await flushPromises()
const button = wrapper.find('.actions').findAll('a')
.at(1)
const button = wrapper.find('.plugin-actions a')
button.trigger('click')
await flushPromises()
@ -167,7 +119,7 @@ test('delete plugin', async () => {
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No data')
expect(wrapper.text()).not.toContain('My Plugin')
})
test('readme link', async () => {
@ -175,13 +127,12 @@ test('readme link', async () => {
{
name: 'a',
readme: true,
dependencies: { all: {}, unsatisfied: {} },
icon: {},
},
])
const wrapper = mount(Plugins)
await flushPromises()
const link = wrapper.find('.actions > a:nth-child(1)')
expect(link.text()).toContain('admin.pluginReadme')
const link = wrapper.find('.plugin-actions > a:nth-child(1)')
expect(link.attributes('href')).toBe('/admin/plugins/readme/a')
})

View File

@ -36,6 +36,7 @@
- Respond with unformatted Profile JSON to reduce bytes.
- Switched to a new PHP texture renderer.
- Display 3D avatar of player when applying texture to player.
- New "Plugins Management" page.
## Fixed

View File

@ -36,6 +36,7 @@
- 响应未格式化过的 Profile JSON 以节省流量
- 使用新的 PHP 材质渲染器
- 将材质应用到角色时显示角色的 3D 头像
- 新的「插件管理」页面
## 修复

View File

@ -18,8 +18,7 @@ class PluginControllerTest extends TestCase
public function testShowManage()
{
$this->get('/admin/plugins/manage')
->assertSee(trans('general.plugin-manage'));
$this->get('/admin/plugins/manage')->assertSee(trans('general.plugin-manage'));
}
public function testConfig()
@ -250,14 +249,6 @@ class PluginControllerTest extends TestCase
'version' => '0.0.0',
'title' => '',
])]));
$mock->shouldReceive('getUnsatisfied')
->withArgs(function ($plugin) {
$this->assertEquals('a', $plugin->name);
return true;
})
->once()
->andReturn(collect(['b' => null]));
});
$this->getJson('/admin/plugins/data')
->assertJsonStructure([
@ -266,12 +257,9 @@ class PluginControllerTest extends TestCase
'version',
'title',
'description',
'author',
'url',
'enabled',
'config',
'readme',
'dependencies',
],
]);
}

View File

@ -20,9 +20,7 @@ const config = {
'admin-lte/dist/css/alt/adminlte.components.min.css',
'admin-lte/dist/css/alt/adminlte.extra-components.min.css',
'admin-lte/dist/css/alt/adminlte.pages.min.css',
'@fortawesome/fontawesome-free/css/fontawesome.min.css',
'@fortawesome/fontawesome-free/css/regular.min.css',
'@fortawesome/fontawesome-free/css/solid.min.css',
'@fortawesome/fontawesome-free/css/all.min.css',
'./resources/assets/src/styles/common.styl',
],
setup: './resources/assets/src/styles/setup.styl',