Add support of customizing UI text

This commit is contained in:
Pig Fang 2019-09-08 18:57:19 +08:00
parent a72d46d2f2
commit 54d3b76c13
19 changed files with 529 additions and 0 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Foundation\Application;
use App\Services\Translations\JavaScript;
use Spatie\TranslationLoader\LanguageLine;
class TranslationsController extends Controller
{
public function list(Application $app)
{
return LanguageLine::all()->map(function ($line) use ($app) {
$line->text = $line->getTranslation($app->getLocale());
return $line;
});
}
public function create(Request $request, Application $app, JavaScript $js)
{
$data = $this->validate($request, [
'group' => 'required|string',
'key' => 'required|string',
'text' => 'required|string',
]);
$line = new LanguageLine();
$line->group = $data['group'];
$line->key = $data['key'];
$line->setTranslation($app->getLocale(), $data['text']);
$line->save();
if ($data['group'] === 'front-end') {
$js->resetTime($app->getLocale());
}
$request->session()->put('success', true);
return redirect('/admin/i18n');
}
public function update(Request $request, Application $app, JavaScript $js)
{
$data = $this->validate($request, [
'id' => 'required|integer',
'text' => 'required|string',
]);
$line = LanguageLine::findOrFail($data['id']);
$line->setTranslation($app->getLocale(), $data['text']);
$line->save();
if ($line->group === 'front-end') {
$js->resetTime($app->getLocale());
}
return json(trans('admin.i18n.updated'), 0);
}
public function delete(Request $request, Application $app, JavaScript $js)
{
['id' => $id] = $this->validate($request, ['id' => 'required|integer']);
$line = LanguageLine::findOrFail($id);
$line->delete();
if ($line->group === 'front-end') {
$js->resetTime($app->getLocale());
}
return json(trans('admin.i18n.deleted'), 0);
}
}

View File

@ -39,6 +39,11 @@ class JavaScript
return url("lang/$locale.js?t=$compiledModified");
}
public function resetTime(string $locale): void
{
$this->cache->put($this->prefix.$locale, 0);
}
public function plugin(string $locale): string
{
$path = public_path("lang/${locale}_plugin.js");

View File

@ -29,6 +29,7 @@ $menu['admin'] = [
['title' => 'general.player-manage', 'link' => 'admin/players', 'icon' => 'fa-gamepad'],
['title' => 'general.report-manage', 'link' => 'admin/reports', 'icon' => 'fa-flag'],
['title' => 'general.customize', 'link' => 'admin/customize', 'icon' => 'fa-paint-brush'],
['title' => 'general.i18n', 'link' => 'admin/i18n', 'icon' => 'fa-globe'],
['title' => 'general.score-options', 'link' => 'admin/score', 'icon' => 'fa-credit-card'],
['title' => 'general.options', 'link' => 'admin/options', 'icon' => 'fa-cog'],
['title' => 'general.res-options', 'link' => 'admin/resource', 'icon' => 'fa-atom'],

View File

@ -66,6 +66,11 @@ export default [
() => import('../views/admin/Customization'),
],
},
{
path: 'admin/i18n',
component: () => import('../views/admin/Translations.vue'),
el: '#table',
},
{
path: 'admin/plugins/manage',
component: () => import('../views/admin/Plugins.vue'),

View File

@ -0,0 +1,103 @@
<template>
<div>
<vue-good-table
:rows="lines"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'operations'">
<el-button size="medium" @click="modify(props.row)">
{{ $t('admin.i18n.modify') }}
</el-button>
<el-button type="danger" size="medium" @click="remove(props.row)">
{{ $t('admin.i18n.delete') }}
</el-button>
</span>
<span v-else-if="props.column.field === 'text'">
<span v-if="props.row.text" v-text="props.formattedRow[props.column.field]" />
<i v-else>{{ $t('admin.i18n.empty') }}</i>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import tableOptions from '../../components/mixins/tableOptions'
import emitMounted from '../../components/mixins/emitMounted'
export default {
name: 'Translations',
components: {
VueGoodTable,
},
mixins: [
emitMounted,
tableOptions,
],
data() {
return {
lines: [],
columns: [
{ field: 'group', label: this.$t('admin.i18n.group') },
{ field: 'key', label: this.$t('admin.i18n.key') },
{ field: 'text', label: this.$t('admin.i18n.text') },
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true,
},
],
}
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
this.lines = await this.$http.get('/admin/i18n/list')
},
async modify(line) {
let text = null
try {
({ value: text } = await this.$prompt(this.$t('admin.i18n.updating'), {
inputValue: line.text,
}))
} catch {
return
}
const { code, message } = await this.$http.put(
'/admin/i18n',
{ id: line.id, text }
)
if (code === 0) {
line.text = text
this.$message.success(message)
} else {
this.$message.warning(message)
}
},
async remove({ id, originalIndex }) {
try {
await this.$confirm(this.$t('admin.i18n.confirmDelete'), {
type: 'warning',
})
} catch {
return
}
const { message } = await this.$http.del('/admin/i18n', { id })
this.$delete(this.lines, originalIndex)
this.$message.success(message)
},
},
}
</script>

View File

@ -49,6 +49,8 @@ Vue.directive('t', (el, { value }) => {
Vue.prototype.$http = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
del: jest.fn(),
}
Vue.use(Button)

View File

@ -0,0 +1,86 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { Button } from 'element-ui'
import { MessageBoxData } from 'element-ui/types/message-box'
import { flushPromises } from '../../utils'
import Translations from '@/views/admin/Translations.vue'
test('fetch data', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
id: 1, group: 'general', key: 'submit', text: '',
},
])
const wrapper = mount(Translations)
await flushPromises()
expect(Vue.prototype.$http.get).toBeCalledWith('/admin/i18n/list')
expect(wrapper.text()).toContain('admin.i18n.empty')
})
test('modify line', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
id: 1, group: 'general', key: 'submit', text: '',
},
])
Vue.prototype.$http.put
.mockResolvedValueOnce({ code: 1, message: 'failed' })
.mockResolvedValueOnce({ code: 0, message: 'ok' })
Vue.prototype.$prompt
.mockRejectedValueOnce(null)
.mockResolvedValueOnce({ value: '' } as MessageBoxData)
.mockResolvedValueOnce({ value: 'wanshengwei' } as MessageBoxData)
const wrapper = mount(Translations)
await flushPromises()
const button = wrapper.findAll(Button).at(0)
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.put).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.put).toBeCalledWith(
'/admin/i18n',
{ id: 1, text: '' }
)
expect(Vue.prototype.$message.warning).toBeCalledWith('failed')
expect(wrapper.text()).not.toContain('wanshengwei')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.put).toBeCalledWith(
'/admin/i18n',
{ id: 1, text: 'wanshengwei' }
)
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
expect(wrapper.text()).toContain('wanshengwei')
})
test('delete line', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
id: 1, group: 'general', key: 'submit', text: '',
},
])
Vue.prototype.$http.del.mockResolvedValueOnce({ message: 'ok' })
Vue.prototype.$confirm
.mockRejectedValueOnce(null)
.mockResolvedValueOnce('confirm')
const wrapper = mount(Translations)
await flushPromises()
const button = wrapper.findAll(Button).at(1)
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.del).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.del).toBeCalledWith('/admin/i18n', { id: 1 })
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
expect(wrapper.text()).not.toContain('general')
})

View File

@ -84,6 +84,16 @@ customize:
black: Black
black-light: Black Light
i18n:
add: Add New Language Line
added: Language line added.
updated: Language line updated.
deleted: Language line deleted.
group: Group
key: Key
text: Text
tip: How can I use this page?
status:
info: Information
health: Health

View File

@ -296,6 +296,15 @@ admin:
updateButton: Update Now
downloading: Downloading...
updateCompleted: Update completed.
i18n:
group: Group
key: Key
text: Text
empty: (Empty)
modify: Modify
delete: Delete
updating: 'Please type new text:'
confirmDelete: Are you sure? This is irreversible.
report:
tid: Texture ID

View File

@ -22,6 +22,7 @@ plugin-manage: Plugins
plugin-market: Plugin Market
plugin-configs: Plugin Configs
customize: Customize
i18n: Internationalization
options: Options
score-options: Score Options
res-options: Resource Options

View File

@ -84,6 +84,16 @@ customize:
black: 黑色主题
black-light: 黑色主题 - 白色侧边栏
i18n:
add: 添加新条目
added: 条目增加成功
updated: 条目更新成功
deleted: 条目已删除
group: 分组
key:
text: 文本
tip: 如何使用本页面的功能?
status:
info: 信息
health: 健康

View File

@ -288,6 +288,15 @@ admin:
updateButton: 马上升级
downloading: 正在下载更新包
updateCompleted: 更新完成
i18n:
group: 分组
key:
text: 文本
empty: (空)
modify: 修改
delete: 删除
updating: 请输入新的文本内容:
confirmDelete: 确认删除吗?此操作不可恢复。
report:
tid: 材质 ID

View File

@ -22,6 +22,7 @@ plugin-manage: 插件管理
plugin-market: 插件市场
plugin-configs: 插件配置
customize: 个性化
i18n: 多语言
options: 站点配置
score-options: 积分配置
res-options: 资源配置

View File

@ -8,6 +8,7 @@
- Allow to cache options by running `php artisan options:cache`.
- Support multiple plugins directories. (Splited by comma in ".env" file.)
- Added "Status" page.
- Added support of customizing UI text.
## Tweaked

View File

@ -8,6 +8,7 @@
- 允许通过 `php artisan options:cache` 命令缓存站点选项
- 支持指定多个插件目录(在 .env 文件中以逗号分隔)
- 新增「运行状态」页面
- 支持自定义 UI 文本
## 调整

View File

@ -0,0 +1,67 @@
@extends('admin.master')
@section('title', trans('general.i18n'))
@section('content')
<div class="content-wrapper">
<section class="content-header">
<h1>@lang('general.i18n')</h1>
</section>
<section class="content">
<div class="row">
<div class="col-lg-8">
<div id="table"></div>
</div>
<div class="col-lg-4">
<form action="{{ url('/admin/i18n') }}" method="post">
<div class="box box-primary">
<div class="box-header">
<h3 class="box-title">@lang('admin.i18n.add')</h3>
</div>
<div class="box-body">
@if (session()->pull('success'))
<div class="callout callout-success">@lang('admin.i18n.added')</div>
@endif
@if ($errors->any())
<div class="callout callout-danger">{{ $errors->first() }}</div>
@endif
@csrf
<table class="table">
<tbody>
<tr>
<td>@lang('admin.i18n.group')</td>
<td>
<input type="text" class="form-control" name="group" required>
</td>
</tr>
<tr>
<td>@lang('admin.i18n.key')</td>
<td>
<input type="text" class="form-control" name="key" required>
</td>
</tr>
<tr>
<td>@lang('admin.i18n.text')</td>
<td>
<input type="text" class="form-control" name="text" required>
</td>
</tr>
</tbody>
</table>
</div>
<div class="box-footer">
<input type="submit" value="@lang('general.submit')" class="el-button el-button--primary">
</div>
</div>
</form>
<div class="callout callout-info">
<a href="https://blessing.netlify.com/ui-text.html" target="_blank">
@lang('admin.i18n.tip')
</a>
</div>
</div>
</div>
</section>
</div>
@endsection

View File

@ -137,6 +137,14 @@ Route::group(['middleware' => ['authorize', 'admin'], 'prefix' => 'admin'], func
Route::post('/reports', 'ReportController@review');
Route::any('/report-data', 'ReportController@manage');
Route::group(['prefix' => 'i18n'], function () {
Route::view('', 'admin.i18n');
Route::get('list', 'TranslationsController@list');
Route::post('', 'TranslationsController@create');
Route::put('', 'TranslationsController@update');
Route::delete('', 'TranslationsController@delete');
});
Route::group(['prefix' => 'plugins', 'middleware' => 'super-admin'], function () {
Route::get('/data', 'PluginController@getPluginData');

View File

@ -66,6 +66,17 @@ class JavaScriptTest extends TestCase
$this->assertEquals(url('lang/en.js?t=1'), resolve(JavaScript::class)->generate('en'));
}
public function testResetTime()
{
$this->spy(Repository::class, function ($spy) {
$spy->shouldReceive('put')
->with('front-end-trans-en', 0)
->once();
});
resolve(JavaScript::class)->resetTime('en');
}
public function testPlugin()
{
$this->mock(Filesystem::class, function ($mock) {

View File

@ -0,0 +1,127 @@
<?php
namespace Tests;
use App\Services\Translations\JavaScript;
use Spatie\TranslationLoader\LanguageLine;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class TranslationsControllerTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->actAs('admin');
}
public function testList()
{
LanguageLine::create([
'group' => 'general',
'key' => 'submit',
'text' => ['en' => 'submit'],
]);
$this->getJson('/admin/i18n/list')
->assertJson([
[
'group' => 'general',
'key' => 'submit',
'text' => 'submit',
],
]);
}
public function testCreate()
{
// Request validation
$this->post('/admin/i18n', [])->assertRedirect('/');
$this->post('/admin/i18n', ['group' => 'general'])
->assertRedirect('/');
$this->post('/admin/i18n', ['group' => 'general', 'key' => 'submit'])
->assertRedirect('/');
$this->spy(JavaScript::class, function ($spy) {
$spy->shouldReceive('resetTime')->with('en')->once();
});
$this->post('/admin/i18n', [
'group' => 'front-end',
'key' => 'general.submit',
'text' => 'submit',
])->assertRedirect('/admin/i18n')->assertSessionHas('success', true);
$this->post('/admin/i18n', [
'group' => 'general',
'key' => 'submit',
'text' => 'submit',
])->assertRedirect('/admin/i18n');
$this->get('/admin/i18n')->assertSee(trans('admin.i18n.added'));
}
public function testUpdate()
{
// Request validation
$this->putJson('/admin/i18n', [])->assertJsonValidationErrors('id');
$this->putJson('/admin/i18n', ['id' => 'a'])
->assertJsonValidationErrors('id');
$this->putJson('/admin/i18n', ['id' => 1])
->assertJsonValidationErrors('text');
$this->putJson('/admin/i18n', ['id' => 1, 'text' => 's'])->assertNotFound();
$this->spy(JavaScript::class, function ($spy) {
$spy->shouldReceive('resetTime')->with('en')->once();
});
LanguageLine::create([
'group' => 'general',
'key' => 'submit',
'text' => ['en' => 'submit'],
]);
LanguageLine::create([
'group' => 'front-end',
'key' => 'general.submit',
'text' => ['en' => 'submit'],
]);
$this->putJson('/admin/i18n', ['id' => 1, 'text' => 's'])
->assertJson(['code' => 0, 'message' => trans('admin.i18n.updated')]);
$this->putJson('/admin/i18n', ['id' => 2, 'text' => 's'])
->assertJson(['code' => 0, 'message' => trans('admin.i18n.updated')]);
$this->assertEquals('s', trans('general.submit'));
$this->assertEquals('s', trans('front-end.general.submit'));
}
public function testDelete()
{
// Request validation
$this->deleteJson('/admin/i18n', [])->assertJsonValidationErrors('id');
$this->deleteJson('/admin/i18n', ['id' => 'a'])
->assertJsonValidationErrors('id');
$this->deleteJson('/admin/i18n', ['id' => 1])->assertNotFound();
$this->spy(JavaScript::class, function ($spy) {
$spy->shouldReceive('resetTime')->with('en')->once();
});
LanguageLine::create([
'group' => 'general',
'key' => 'submit',
'text' => ['en' => 'submit'],
]);
LanguageLine::create([
'group' => 'front-end',
'key' => 'general.submit',
'text' => ['en' => 'submit'],
]);
$this->deleteJson('/admin/i18n', ['id' => 1])
->assertJson(['code' => 0, 'message' => trans('admin.i18n.deleted')]);
$this->deleteJson('/admin/i18n', ['id' => 2])
->assertJson(['code' => 0, 'message' => trans('admin.i18n.deleted')]);
$this->assertEquals('Submit', trans('general.submit'));
$this->assertEquals('Submit', trans('front-end.general.submit'));
}
}