From 54d3b76c13ec33c60e532f890e3d757959b265e0 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Sun, 8 Sep 2019 18:57:19 +0800 Subject: [PATCH] Add support of customizing UI text --- .../Controllers/TranslationsController.php | 72 ++++++++++ app/Services/Translations/JavaScript.php | 5 + config/menu.php | 1 + resources/assets/src/scripts/route.ts | 5 + .../assets/src/views/admin/Translations.vue | 103 ++++++++++++++ resources/assets/tests/setup.js | 2 + .../tests/views/admin/Translations.test.ts | 86 ++++++++++++ resources/lang/en/admin.yml | 10 ++ resources/lang/en/front-end.yml | 9 ++ resources/lang/en/general.yml | 1 + resources/lang/zh_CN/admin.yml | 10 ++ resources/lang/zh_CN/front-end.yml | 9 ++ resources/lang/zh_CN/general.yml | 1 + resources/misc/changelogs/en/5.0.0.md | 1 + resources/misc/changelogs/zh_CN/5.0.0.md | 1 + resources/views/admin/i18n.blade.php | 67 +++++++++ routes/web.php | 8 ++ .../TranslationsTest/JavaScriptTest.php | 11 ++ tests/TranslationsControllerTest.php | 127 ++++++++++++++++++ 19 files changed, 529 insertions(+) create mode 100644 app/Http/Controllers/TranslationsController.php create mode 100644 resources/assets/src/views/admin/Translations.vue create mode 100644 resources/assets/tests/views/admin/Translations.test.ts create mode 100644 resources/views/admin/i18n.blade.php create mode 100644 tests/TranslationsControllerTest.php diff --git a/app/Http/Controllers/TranslationsController.php b/app/Http/Controllers/TranslationsController.php new file mode 100644 index 00000000..95109a50 --- /dev/null +++ b/app/Http/Controllers/TranslationsController.php @@ -0,0 +1,72 @@ +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); + } +} diff --git a/app/Services/Translations/JavaScript.php b/app/Services/Translations/JavaScript.php index 28be4f24..5144d56b 100644 --- a/app/Services/Translations/JavaScript.php +++ b/app/Services/Translations/JavaScript.php @@ -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"); diff --git a/config/menu.php b/config/menu.php index c9c479da..38b4fe14 100644 --- a/config/menu.php +++ b/config/menu.php @@ -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'], diff --git a/resources/assets/src/scripts/route.ts b/resources/assets/src/scripts/route.ts index e6cbaa76..3dd073d6 100644 --- a/resources/assets/src/scripts/route.ts +++ b/resources/assets/src/scripts/route.ts @@ -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'), diff --git a/resources/assets/src/views/admin/Translations.vue b/resources/assets/src/views/admin/Translations.vue new file mode 100644 index 00000000..18e81950 --- /dev/null +++ b/resources/assets/src/views/admin/Translations.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/assets/tests/setup.js b/resources/assets/tests/setup.js index 29e965e8..febc86e7 100644 --- a/resources/assets/tests/setup.js +++ b/resources/assets/tests/setup.js @@ -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) diff --git a/resources/assets/tests/views/admin/Translations.test.ts b/resources/assets/tests/views/admin/Translations.test.ts new file mode 100644 index 00000000..01faadef --- /dev/null +++ b/resources/assets/tests/views/admin/Translations.test.ts @@ -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') +}) diff --git a/resources/lang/en/admin.yml b/resources/lang/en/admin.yml index 5965893e..3d365e23 100644 --- a/resources/lang/en/admin.yml +++ b/resources/lang/en/admin.yml @@ -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 diff --git a/resources/lang/en/front-end.yml b/resources/lang/en/front-end.yml index 90fe92f6..f4b077ed 100644 --- a/resources/lang/en/front-end.yml +++ b/resources/lang/en/front-end.yml @@ -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 diff --git a/resources/lang/en/general.yml b/resources/lang/en/general.yml index e54a2c2c..6d214873 100644 --- a/resources/lang/en/general.yml +++ b/resources/lang/en/general.yml @@ -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 diff --git a/resources/lang/zh_CN/admin.yml b/resources/lang/zh_CN/admin.yml index 8f4a6786..5bcb3bd6 100644 --- a/resources/lang/zh_CN/admin.yml +++ b/resources/lang/zh_CN/admin.yml @@ -84,6 +84,16 @@ customize: black: 黑色主题 black-light: 黑色主题 - 白色侧边栏 +i18n: + add: 添加新条目 + added: 条目增加成功 + updated: 条目更新成功 + deleted: 条目已删除 + group: 分组 + key: 键 + text: 文本 + tip: 如何使用本页面的功能? + status: info: 信息 health: 健康 diff --git a/resources/lang/zh_CN/front-end.yml b/resources/lang/zh_CN/front-end.yml index 03c5f8d2..3d8ec02a 100644 --- a/resources/lang/zh_CN/front-end.yml +++ b/resources/lang/zh_CN/front-end.yml @@ -288,6 +288,15 @@ admin: updateButton: 马上升级 downloading: 正在下载更新包 updateCompleted: 更新完成 + i18n: + group: 分组 + key: 键 + text: 文本 + empty: (空) + modify: 修改 + delete: 删除 + updating: 请输入新的文本内容: + confirmDelete: 确认删除吗?此操作不可恢复。 report: tid: 材质 ID diff --git a/resources/lang/zh_CN/general.yml b/resources/lang/zh_CN/general.yml index d6e513a8..274ef49d 100644 --- a/resources/lang/zh_CN/general.yml +++ b/resources/lang/zh_CN/general.yml @@ -22,6 +22,7 @@ plugin-manage: 插件管理 plugin-market: 插件市场 plugin-configs: 插件配置 customize: 个性化 +i18n: 多语言 options: 站点配置 score-options: 积分配置 res-options: 资源配置 diff --git a/resources/misc/changelogs/en/5.0.0.md b/resources/misc/changelogs/en/5.0.0.md index 49713043..646e195b 100644 --- a/resources/misc/changelogs/en/5.0.0.md +++ b/resources/misc/changelogs/en/5.0.0.md @@ -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 diff --git a/resources/misc/changelogs/zh_CN/5.0.0.md b/resources/misc/changelogs/zh_CN/5.0.0.md index 5a46d77f..adda9e67 100644 --- a/resources/misc/changelogs/zh_CN/5.0.0.md +++ b/resources/misc/changelogs/zh_CN/5.0.0.md @@ -8,6 +8,7 @@ - 允许通过 `php artisan options:cache` 命令缓存站点选项 - 支持指定多个插件目录(在 .env 文件中以逗号分隔) - 新增「运行状态」页面 +- 支持自定义 UI 文本 ## 调整 diff --git a/resources/views/admin/i18n.blade.php b/resources/views/admin/i18n.blade.php new file mode 100644 index 00000000..d72d500b --- /dev/null +++ b/resources/views/admin/i18n.blade.php @@ -0,0 +1,67 @@ +@extends('admin.master') + +@section('title', trans('general.i18n')) + +@section('content') +
+
+

@lang('general.i18n')

+
+ +
+
+
+
+
+
+
+
+
+

@lang('admin.i18n.add')

+
+
+ @if (session()->pull('success')) +
@lang('admin.i18n.added')
+ @endif + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + @csrf + + + + + + + + + + + + + + + +
@lang('admin.i18n.group') + +
@lang('admin.i18n.key') + +
@lang('admin.i18n.text') + +
+
+ +
+
+ +
+
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index fd7e9aa8..01263523 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/ServicesTest/TranslationsTest/JavaScriptTest.php b/tests/ServicesTest/TranslationsTest/JavaScriptTest.php index e9598640..8c6c8b31 100644 --- a/tests/ServicesTest/TranslationsTest/JavaScriptTest.php +++ b/tests/ServicesTest/TranslationsTest/JavaScriptTest.php @@ -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) { diff --git a/tests/TranslationsControllerTest.php b/tests/TranslationsControllerTest.php new file mode 100644 index 00000000..f6d1050d --- /dev/null +++ b/tests/TranslationsControllerTest.php @@ -0,0 +1,127 @@ +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')); + } +}