Notifications

This commit is contained in:
Pig Fang 2019-07-03 16:19:13 +08:00
parent 3d88d56a9d
commit 7a7cc2ddd9
27 changed files with 459 additions and 8 deletions

View File

@ -4,8 +4,10 @@ namespace App\Http\Controllers;
use Cache;
use Option;
use Notification;
use Carbon\Carbon;
use App\Models\User;
use App\Notifications;
use App\Models\Player;
use App\Models\Texture;
use Illuminate\Support\Str;
@ -68,6 +70,38 @@ class AdminController extends Controller
];
}
public function sendNotification(Request $request)
{
$data = $this->validate($request, [
'receiver' => 'required|in:all,normal,uid,email',
'uid' => 'required_if:receiver,uid|nullable|integer|exists:users',
'email' => 'required_if:receiver,email|nullable|email|exists:users',
'title' => 'required|max:20',
'content' => 'string|nullable',
]);
$notification = new Notifications\SiteMessage($data['title'], $data['content']);
switch ($data['receiver']) {
case 'all':
$users = User::all();
break;
case 'normal':
$users = User::where('permission', User::NORMAL)->get();
break;
case 'uid':
$users = User::where('uid', $data['uid'])->get();
break;
case 'email':
$users = User::where('email', $data['email'])->get();
break;
}
Notification::send($users, $notification);
session(['sentResult' => trans('admin.notifications.send.success')]);
return redirect('/admin');
}
public function customize(Request $request)
{
$homepage = Option::form('homepage', OptionForm::AUTO_DETECT, function ($form) {

View File

@ -301,5 +301,20 @@ class UserController extends Controller
}
}
// @codeCoverageIgnore
public function readNotification($id)
{
$notification = auth()
->user()
->unreadNotifications
->first(function ($notification) use ($id) {
return $notification->id === $id;
});
$notification->markAsRead();
return [
'title' => $notification->data['title'],
'content' => app('parsedown')->text($notification->data['content']),
'time' => $notification->created_at->toDateTimeString(),
];
}
}

View File

@ -8,10 +8,12 @@ use Illuminate\Support\Arr;
use Laravel\Passport\HasApiTokens;
use App\Events\EncryptUserPassword;
use Tymon\JWTAuth\Contracts\JWTSubject;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
use HasApiTokens;
/**

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class SiteMessage extends Notification
{
public $title;
public $content;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(string $title, $content = '')
{
$this->title = $title;
$this->content = $content;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['database'];
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'title' => $this->title,
'content' => $this->content,
];
}
}

View File

@ -7,6 +7,8 @@ namespace App\Services;
use Event;
use Closure;
use App\Events;
use Notification;
use App\Notifications;
use Illuminate\Support\Str;
class Hook
@ -126,4 +128,9 @@ class Hook
}
);
}
public static function sendNotification($users, string $title, $content = ''): void
{
Notification::send($users, new Notifications\SiteMessage($title, $content));
}
}

View File

@ -160,6 +160,7 @@ return [
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
@ -217,6 +218,7 @@ return [
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateNotificationsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('notifications');
}
}

View File

@ -0,0 +1,7 @@
<?php
return [
'请您手动打开终端或 PowerShell 执行以下命令以完成升级:',
'Please open terminal or PowerShell to complete upgrade:',
'<code>php artisan migrate --force</code>',
];

View File

@ -11,5 +11,6 @@ import 'admin-lte/build/js/Tree'
import './i18n'
import './net'
import './event'
import './notification'
import './element'
import './logout'

View File

@ -0,0 +1,25 @@
import { get } from './net'
import { showModal } from './notify'
export default async function handler(event: Event) {
const item = event.target as HTMLAnchorElement
const id = item.getAttribute('data-nid')
const {
title, content, time,
} = await get(`/user/notifications/${id}`)
showModal(`${content}<br><small>${time}</small>`, title)
item.remove()
const counter = document.querySelector('.notifications-counter') as HTMLSpanElement
const value = Number.parseInt(counter.textContent!) - 1
if (value > 0) {
counter.textContent = value.toString()
} else {
counter.remove()
}
}
const el = document.querySelector('.notifications-list')
// istanbul ignore next
if (el) {
el.addEventListener('click', handler)
}

View File

@ -0,0 +1,34 @@
import { get } from '@/scripts/net'
import { showModal } from '@/scripts/notify'
import { flushPromises } from '../utils'
import handler from '@/scripts/notification'
jest.mock('@/scripts/net')
jest.mock('@/scripts/notify')
test('read notification', async () => {
document.body.innerHTML = `
<div class="notifications-list">
<span class="notifications-counter">2</span>
<a data-nid="1"></a>
<a data-nid="2"></a>
</div>
`
document.querySelector('.notifications-list')!.addEventListener('click', handler)
get.mockResolvedValue({
title: 'title',
content: 'content',
time: 'time',
})
document.querySelector('a')!.click()
await flushPromises()
expect(get).toBeCalledWith('/user/notifications/1')
expect(showModal).toBeCalledWith('content<br><small>time</small>', 'title')
expect(document.querySelectorAll('a')).toHaveLength(1)
expect(document.querySelector('span')!.textContent).toBe('1')
document.querySelector('a')!.click()
await flushPromises()
expect(document.querySelector('span')).toBeNull()
})

View File

@ -7,6 +7,19 @@ index:
texture-uploads: Texture Uploads
user-registration: User Registration
notifications:
send:
title: Send Notification
success: Sent successfully!
receiver:
title: Receiver
all: All Users
normal: Normal Users
uid: Specified UID
email: Specified Email
title: Title
content: Content (Markdown is supported.)
users:
status:
normal: Normal

View File

@ -13,6 +13,7 @@ cant-sign-until: You can't sign in in :time :unit
last-sign: Last signed at :time
sign-remain-time: Available after :time :unit
announcement: Announcement
no-unread: No new notifications.
verification:
disabled: Email verification is not available.

View File

@ -7,6 +7,19 @@ index:
texture-uploads: 材质上传
user-registration: 用户注册
notifications:
send:
title: 发送通知
success: 发送成功
receiver:
title: 通知对象
all: 所有用户
normal: 普通用户
uid: 指定 UID
email: 指定邮箱
title: 标题
content: 内容(可使用 Markdown
users:
status:
normal: 普通用户

View File

@ -13,6 +13,7 @@ cant-sign-until: :time :unit 后才能再次签到哦
last-sign: 上次签到于 :time
sign-remain-time: :time :unit 后可签到
announcement: 公告
no-unread: 无未读通知
verification:

View File

@ -6,6 +6,8 @@
- Custom `PLUGINS_DIR` with relative path is allowed.
- Added link for editing announcement.
- New plugin API: [`Hook::addUserBadge`](https://bs-plugin.netlify.com/guide/bootstrap.html#%E6%98%BE%E7%A4%BA%E7%94%A8%E6%88%B7-badge).
- New feature: Notifications.
- New plugin API: [`Hook::sendNotification`](https://bs-plugin.netlify.com/guide/bootstrap.html#%E5%8F%91%E9%80%81%E9%80%9A%E7%9F%A5)
## Tweaked

View File

@ -6,6 +6,8 @@
- 允许在 `PLUGINS_DIR` 配置项中使用相对路径
- 添加「编辑公告」的链接
- 新插件 API[`Hook::addUserBadge`](https://bs-plugin.netlify.com/guide/bootstrap.html#%E6%98%BE%E7%A4%BA%E7%94%A8%E6%88%B7-badge)
- 新功能:发送通知。
- 新插件 API[`Hook::sendNotification`](https://bs-plugin.netlify.com/guide/bootstrap.html#%E5%8F%91%E9%80%81%E9%80%9A%E7%9F%A5)
## 调整

View File

@ -69,6 +69,63 @@
</div>
</div>
</div>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">@lang('admin.notifications.send.title')</h3>
</div>
<form method="post" action="{{ url('/admin/notifications/send') }}">
@csrf
<div class="box-body">
@if ($errors->any())
<div class="callout callout-danger">{{ $errors->first() }}</div>
@endif
@if ($sentResult = Session::pull('sentResult'))
<div class="callout callout-success">{{ $sentResult }}</div>
@endif
<div class="form-group">
<label>@lang('admin.notifications.receiver.title')</label>
<div class="radio">
<label>
<input type="radio" name="receiver" value="all" required>
@lang('admin.notifications.receiver.all')
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="receiver" value="normal" required>
@lang('admin.notifications.receiver.normal')
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="receiver" value="uid" required>
@lang('admin.notifications.receiver.uid') &nbsp;
<input type="number" name="uid" class="form-control">
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="receiver" value="email" required>
@lang('admin.notifications.receiver.email') &nbsp;
<input type="email" name="email" class="form-control">
</label>
</div>
</div>
<div class="form-group">
<label>@lang('admin.notifications.title')</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="form-group">
<label>@lang('admin.notifications.content')</label>
<textarea name="content" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="box-footer">
<input type="submit" value="@lang('general.submit')" class="el-button el-button--primary">
</div>
</form>
</div>
</div>
<div class="col-md-6">
@ -78,8 +135,8 @@
</div>
<div class="box-body">
<div id="chart"></div>
</div><!-- /.box-body -->
</div><!-- /.box -->
</div>
</div>
</div>
</div>

View File

@ -43,6 +43,8 @@
<!-- Navbar Right Menu -->
<div class="navbar-custom-menu">
<ul class="nav navbar-nav">
@include('common.notifications-menu')
@include('common.language')
@include('common.user-menu')

View File

@ -0,0 +1,30 @@
@php
$notifications = $user->unreadNotifications;
$count = $notifications->count();
@endphp
<li class="dropdown notifications-menu">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<i class="fas fa-bell"></i>
@if ($count > 0)
<span class="label label-warning notifications-counter">{{ $count }}</span>
@endif
</a>
<ul class="dropdown-menu">
@if ($count === 0)
<li class="header text-center">@lang('user.no-unread')</li>
@else
<li>
<ul class="menu notifications-list">
@foreach ($notifications as $notification)
<li>
<a href="#" data-nid="{{ $notification->id }}">
<i class="far fa-circle text-aqua"></i> {{ $notification->data['title'] }}
</a>
</li>
@endforeach
</ul>
</li>
@endif
</ul>
</li>

View File

@ -37,15 +37,20 @@
<li class="active">
<a href="{{ url('skinlib') }}">@lang('general.skinlib')</a>
</li>
@auth
<li>
<a href="{{ url('user/closet') }}">@lang('general.my-closet')</a>
</li>
@endauth
</ul>
</div><!-- /.navbar-collapse -->
<!-- Navbar Right Menu -->
<div class="navbar-custom-menu">
<ul class="nav navbar-nav">
@auth
<li><a href="{{ url('skinlib/upload') }}"><i class="fas fa-upload" aria-hidden="true"></i> <span class="description-text">@lang('skinlib.general.upload-new-skin')</span></a></li>
@include('common.notifications-menu')
@endauth
@include('common.language')

View File

@ -43,6 +43,8 @@
<!-- Navbar Right Menu -->
<div class="navbar-custom-menu">
<ul class="nav navbar-nav">
@include('common.notifications-menu')
@include('common.language')
@include('common.user-menu')

View File

@ -49,6 +49,7 @@ Route::group([
'prefix' => 'user',
], function () {
Route::any('', 'UserController@index');
Route::get('/notifications/{id}', 'UserController@readNotification');
Route::get('/score-info', 'UserController@scoreInfo');
Route::post('/sign', 'UserController@sign');
@ -115,6 +116,7 @@ Route::group(['prefix' => 'skinlib'], function () {
Route::group(['middleware' => ['authorize', 'admin'], 'prefix' => 'admin'], function () {
Route::view('/', 'admin.index');
Route::get('/chart', 'AdminController@chartData');
Route::post('/notifications/send', 'AdminController@sendNotification');
Route::any('/customize', 'AdminController@customize');
Route::any('/score', 'AdminController@score');

View File

@ -2,7 +2,9 @@
namespace Tests;
use Notification;
use App\Models\User;
use App\Notifications;
use App\Models\Player;
use App\Models\Texture;
use Illuminate\Support\Str;
@ -32,7 +34,83 @@ class AdminControllerTest extends TestCase
->assertJsonStructure(['labels', 'xAxis', 'data']);
}
public function testSendNotification()
{
$admin = factory(User::class, 'admin')->create();
$normal = factory(User::class)->create();
Notification::fake();
$this->actingAs($admin)
->post('/admin/notifications/send', [
'receiver' => 'all',
'title' => 'all users',
'content' => null,
])
->assertRedirect('/admin')
->assertSessionHas('sentResult', trans('admin.notifications.send.success'));
Notification::assertSentTo(
[$admin, $normal],
Notifications\SiteMessage::class,
function ($notification) {
$this->assertEquals('all users', $notification->title);
return true;
}
);
Notification::fake();
Notification::assertNothingSent();
$this->post('/admin/notifications/send', [
'receiver' => 'normal',
'title' => 'normal only',
'content' => 'hi',
]);
Notification::assertSentTo(
$normal,
Notifications\SiteMessage::class,
function ($notification) {
$this->assertEquals('normal only', $notification->title);
$this->assertEquals('hi', $notification->content);
return true;
}
);
Notification::assertNotSentTo($admin, Notifications\SiteMessage::class);
Notification::fake();
Notification::assertNothingSent();
$this->post('/admin/notifications/send', [
'receiver' => 'uid',
'title' => 'uid',
'content' => null,
'uid' => $normal->uid,
]);
Notification::assertSentTo(
$normal,
Notifications\SiteMessage::class,
function ($notification) {
$this->assertEquals('uid', $notification->title);
return true;
}
);
Notification::assertNotSentTo($admin, Notifications\SiteMessage::class);
Notification::fake();
Notification::assertNothingSent();
$this->post('/admin/notifications/send', [
'receiver' => 'email',
'title' => 'email',
'content' => null,
'email' => $normal->email,
]);
Notification::assertSentTo(
$normal,
Notifications\SiteMessage::class,
function ($notification) {
$this->assertEquals('email', $notification->title);
return true;
}
);
Notification::assertNotSentTo($admin, Notifications\SiteMessage::class);
}
public function testUsers()
{

View File

@ -2,6 +2,7 @@
namespace Tests;
use App\Models\User;
use App\Services\Hook;
use Illuminate\Support\Facades\File;
use Tests\Concerns\GeneratesFakePlugins;
@ -92,4 +93,14 @@ class HookTest extends TestCase
->get('/user')
->assertSee('<small class="label bg-green">hi</small>');
}
public function testSendNotification()
{
$user = factory(User::class)->create();
Hook::sendNotification([$user], 'Ibara Mayaka');
$this->actingAs($user)
->get('/user')
->assertSee('<span class="label label-warning">1</span>')
->assertSee('Ibara Mayaka');
}
}

View File

@ -41,6 +41,12 @@ class SetupControllerTest extends TestCase
'textures',
'users',
'reports',
'oauth_auth_codes',
'oauth_access_tokens',
'oauth_clients',
'oauth_personal_access_clients',
'oauth_refresh_tokens',
'notifications',
];
array_walk($tables, function ($table) {
Schema::dropIfExists($table);
@ -84,11 +90,6 @@ class SetupControllerTest extends TestCase
public function testInfo()
{
$this->get('/setup/info')->assertViewIs('setup.wizard.info');
Schema::dropIfExists('oauth_auth_codes');
Schema::dropIfExists('oauth_access_tokens');
Schema::dropIfExists('oauth_clients');
Schema::dropIfExists('oauth_personal_access_clients');
Schema::dropIfExists('oauth_refresh_tokens');
Artisan::call('migrate:refresh');
Schema::drop('users');
$this->get('/setup/info')->assertSee('already exist');

View File

@ -6,6 +6,7 @@ use Event;
use Parsedown;
use App\Events;
use App\Models\User;
use App\Notifications;
use Illuminate\Support\Str;
use App\Mail\EmailVerification;
use Illuminate\Support\Facades\Mail;
@ -490,4 +491,22 @@ class UserControllerTest extends TestCase
->assertJson(['code' => 0]);
$this->assertEquals(0, User::find($user->uid)->avatar);
}
public function testReadNotification()
{
$user = factory(User::class)->create();
$user->notify(new Notifications\SiteMessage('Hyouka', 'Kotenbu?'));
$user->refresh();
$notification = $user->unreadNotifications->first();
$this->actingAs($user)
->get('/user/notifications/'.$notification->id)
->assertJson([
'title' => $notification->data['title'],
'content' => app('parsedown')->text($notification->data['content']),
'time' => $notification->created_at->toDateTimeString(),
]);
$notification->refresh();
$this->assertNotNull($notification->read_at);
}
}