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 @@
+
+
+
+
+
+
+ {{ $t('admin.i18n.modify') }}
+
+
+ {{ $t('admin.i18n.delete') }}
+
+
+
+
+ {{ $t('admin.i18n.empty') }}
+
+
+
+
+
+
+
+
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')
+
+@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'));
+ }
+}