加入廣告系統 20250701

This commit is contained in:
allen.yan 2025-07-01 10:25:50 +08:00
parent 5ce742da13
commit 4572ecddbe
19 changed files with 808 additions and 2 deletions

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use App\Models\TextAd;
use App\Models\Room;
use App\Services\TcpSocketClient;
class RotateTextAd extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ads:rotate';
/**
* The console command description.
*
* @var string
*/
protected $description = '輪播下一則啟用中的文字廣告';
/**
* Execute the console command.
*/
public function handle()
{
$ads = TextAd::where('is_active', true)->orderBy('id')->get();
if ($ads->isEmpty()) {
$this->info('❌ 沒有啟用中的廣告。');
return;
}
$rooms = Room::where('type', '!=', 'svr')
->where('is_online', 1)
->get(['id', 'name', 'internal_ip', 'port']);
foreach ($rooms as $room) {
$nextTimeKey = "text_ad_next_time_{$room->id}";
$indexKey = "text_ad_index_{$room->id}";
$nextTime = Cache::get($nextTimeKey);
// 還沒到時間,就跳過這個房間
if ($nextTime && now()->lt($nextTime)) {
$this->info("⏳ 房間 {$room->id} 尚未到下一次播放時間(下次播放:{$nextTime}");
continue;
}
$index = Cache::get($indexKey, 0) % $ads->count();
$ad = $ads[$index];
$roomCode = str_pad($room->name, 4, '0', STR_PAD_LEFT);
$content = "{$roomCode}({$ad->color->labels()})-" . $ad->content;
try {
$client = new TcpSocketClient($room->internal_ip, $room->port);
$response = $client->send($content);
$this->info("✅ 廣告 #{$ad->id} 已推送到房間 {$room->id}{$room->internal_ip}:{$room->port}");
} catch (\Throwable $e) {
$this->error("❌ 房間 {$room->id} 發送失敗:{$e->getMessage()}");
}
// 更新此房間的播放狀態
Cache::put($nextTimeKey, now()->addMinutes($ad->duration));
Cache::put($indexKey, ($index + 1) % $ads->count());
}
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\TcpSocketClient;
class TestMarqueeMessage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tcp:marquee
{ip : 目標設備 IP}
{port : 目標設備 Port}
{message : 要顯示的文字}';
/**
* The console command description.
*
* @var string
*/
protected $description = '發送跑馬燈訊息到指定的設備';
/**
* Execute the console command.
*/
public function handle()
{
$ip = $this->argument('ip');
$port = (int) $this->argument('port');
$message = $this->argument('message');
$client = new TcpSocketClient($ip, $port);
try {
$this->info("📤 發送中:{$message}");
$response = $client->send($message);
$this->info("✅ 回應:{$response}");
} catch (\Exception $e) {
$this->error("❌ 發送失敗:" . $e->getMessage());
}
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Enums;
use App\Enums\Traits\HasLabels;
enum TextAdColors: string
{
use HasLabels;
case Black = 'black';
case White = 'white';
case Red = 'red';
case Green = 'green';
case Blue = 'blue';
public function labels(): string
{
return match ($this) {
self::Black => '黑色',
self::White => '白色',
self::Red => '紅色',
self::Green => '綠色',
self::Blue => '藍色',
};
}
public function colorCode(): string
{
return match ($this) {
self::White => '#FFFFFF',
self::Red => '#FF0000',
self::Green => '#90EE90',
self::Blue => '#ADD8E6',
};
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Forms;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
use App\Models\Room;
use App\Models\TextAd;
use App\Services\TcpSocketClient;
class TextAdTestForm extends Component
{
use WireUiActions;
protected $listeners = ['openModal','closeModal'];
public bool $showModal = false;
public ?string $prefix ="";
public ?string $content = "";
public ?int $roomId = null;
public array $roomOptions =[];
public function mount()
{
$this->roomOptions = Room::where('type', '!=', 'svr')->get()->map(fn ($room) => [
'name' => $room->type->value.$room->name,
'value' => $room->id,
])->toArray();
}
public function openModal($id = null)
{
$textAd=TextAd::findOrFail($id);
$this->prefix = "({$textAd->color->labels()})-測試:";
$this->content = $textAd->content;
$this->showModal = true;
}
public function closeModal()
{
$this->textAd=null;
$this->resetFields();
$this->showModal = false;
}
public function send()
{
$room = Room::find($this->roomId);
$roomCode = str_pad($room->name, 4, '0', STR_PAD_LEFT);
try {
$client = new TcpSocketClient($room->internal_ip, $room->port);
$client->send($roomCode.$this->prefix.$this->content);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => "✅ 已送出至房間 {$room->name}",
]);
} catch (\Throwable $e) {
$this->notification()->send([
'icon' => 'error',
'title' => '失敗',
'description' => "❌ 發送失敗:{$e->getMessage()}",
]);
}
$this->resetFields();
$this->showModal = false;
$this->dispatch('pg:eventRefresh-text-ads-table');
}
public function resetFields()
{
$this->content='';
$this->roomId=null;
}
public function render()
{
return view('livewire.forms.text-ad-test-form');
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Livewire\Forms;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
use App\Models\TextAd;
use App\Enums\TextAdColors;
class TextAdsForm extends Component
{
use WireUiActions;
protected $listeners = ['openModal','closeModal', 'deleteTextAd'];
public bool $canCreate;
public bool $canEdit;
public bool $canDelect;
public bool $showModal = false;
public ?int $textAdId = null;
public array $colorOptions =[];
public array $fields = [
'content' =>'',
'color' => 'black',
'duration' => 1,
'is_active' => 1,
];
public function mount()
{
$this->colorOptions = collect(TextAdColors::cases())->map(fn ($color) => [
'name' => $color->labels(),
'value' => $color->value,
])->toArray();
$this->canCreate = Auth::user()?->can('song-edit') ?? false;
$this->canEdit = Auth::user()?->can('song-edit') ?? false;
$this->canDelect = Auth::user()?->can('song-delete') ?? false;
}
public function openModal($id = null)
{
$this->resetFields();
if ($id) {
$textAd = TextAd::findOrFail($id);
$this->textAdId = $textAd->id;
$this->fields = $textAd->only(array_keys($this->fields));
}
$this->showModal = true;
}
public function closeModal()
{
$this->resetFields();
$this->showModal = false;
}
public function save()
{
if ($this->textAdId) {
if ($this->canEdit) {
$textAd = TextAd::findOrFail($this->textAdId);
$textAd->update($this->fields);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '文字廣吿已更新',
]);
}
} else {
if ($this->canCreate) {
$textAd = TextAd::create($this->fields);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '文字廣吿已新增',
]);
}
}
$this->resetFields();
$this->showModal = false;
$this->dispatch('pg:eventRefresh-text-ads-table');
}
public function deleteTextAd($id)
{
if ($this->canDelect) {
TextAd::findOrFail($id)->delete();
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '文字廣吿已刪除',
]);
$this->dispatch('pg:eventRefresh-text-ads-table');
}
}
public function resetFields()
{
foreach ($this->fields as $key => $value) {
if ($key == 'color') {
$this->fields[$key] = 'black';
} else if ($key == 'content'){
$this->fields[$key] = '';
} else {
$this->fields[$key] = 1;
}
}
$this->textAdId = null;
}
public function render()
{
return view('livewire.forms.text-ads-form');
}
}

View File

@ -0,0 +1,215 @@
<?php
namespace App\Livewire\Tables;
use App\Models\TextAd;
use App\Enums\TextAdColors;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Database\Eloquent\Builder;
use PowerComponents\LivewirePowerGrid\Button;
use PowerComponents\LivewirePowerGrid\Column;
use PowerComponents\LivewirePowerGrid\Facades\Filter;
use PowerComponents\LivewirePowerGrid\Facades\PowerGrid;
use PowerComponents\LivewirePowerGrid\PowerGridFields;
use PowerComponents\LivewirePowerGrid\PowerGridComponent;
use PowerComponents\LivewirePowerGrid\Traits\WithExport;
use PowerComponents\LivewirePowerGrid\Components\SetUp\Exportable;
use PowerComponents\LivewirePowerGrid\Facades\Rule;
use Livewire\Attributes\On;
use WireUi\Traits\WireUiActions;
final class TextAdsTable extends PowerGridComponent
{
use WithExport, WireUiActions;
public string $tableName = 'text-ads-table';
public bool $canCreate;
public bool $canEdit;
public bool $canDownload;
public bool $canDelect;
public bool $showFilters = false;
public function boot(): void
{
config(['livewire-powergrid.filter' => 'outside']);
$this->canCreate = Auth::user()?->can('text-ad-create') ?? false;
$this->canEdit = Auth::user()?->can('text-ad-edit') ?? false;
$this->canDownload=Auth::user()?->can('text-ad-delete') ?? false;
$this->canDelect = Auth::user()?->can('text-ad-delete') ?? false;
}
public function setUp(): array
{
if($this->canDownload || $this->canDelect){
$this->showCheckBox();
}
$actions = [];
if($this->canDownload){
$actions[]=PowerGrid::exportable(fileName: $this->tableName.'-file')
->type(Exportable::TYPE_XLS, Exportable::TYPE_CSV);
}
$header = PowerGrid::header()->showSoftDeletes()->showToggleColumns();
if($this->canCreate){
$header->includeViewOnTop('livewire.header.text-ad');
}
$actions[]=$header;
$actions[]=PowerGrid::footer()->showPerPage()->showRecordCount();
return $actions;
}
public function header(): array
{
$actions = [];
if ($this->canDelect) {
$actions[]=Button::add('bulk-delete')
->slot('Bulk delete (<span x-text="window.pgBulkActions.count(\'' . $this->tableName . '\')"></span>)')
->icon('solid-trash',['id' => 'my-custom-icon-id', 'class' => 'font-bold'])
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatch('bulkDelete.' . $this->tableName, []);
}
return $actions;
}
public function datasource(): Builder
{
return TextAd::query();
}
public function relationSearch(): array
{
return [];
}
public function fields(): PowerGridFields
{
return PowerGrid::fields()
->add('id')
->add('content')
->add(
'content_short',
fn (TextAd $model) =>
'<span title="' . e($model->content) . '">' . e(Str::limit($model->content, 50)) . '</span>'
)
->add('color')
->add('color_str', function (TextAd $model) {
if ($this->canEdit) {
return Blade::render(
'<x-select-category type="occurrence" :options=$options :modelId=$modelId :fieldName=$fieldName :selected=$selected/>',
[
'options' => TextAdColors::options(),
'modelId' => intval($model->id),
'fieldName'=>'color',
'selected' => $model->color->value
]
);
}
// 沒有權限就顯示對應的文字
return $model->color->labelPowergridFilter(); // 假設 label() 會回傳顯示文字
} )
->add('duration')
->add('is_active')
->add('created_at');
}
public function columns(): array
{
$column=[];
$column[] = Column::make(__('text_ads.id'), 'id');
$column[] = Column::make(__('text_ads.content'), 'content_short', 'text_ads.content')->sortable()->searchable();
$column[] = Column::make(__('text_ads.color'),'color_str', 'text-ads.color')->searchable();
$column[] = Column::make(__('text_ads.duration'), 'duration')->sortable()->searchable();
$column[] = Column::make(__('text_ads.is_active'), 'is_active')->toggleable(hasPermission: $this->canEdit, trueLabel: 'yes', falseLabel: 'no');
$column[] = Column::make(__('text_ads.created_at'), 'created_at')->sortable()->searchable();
$column[] = Column::action(__('text_ads.actions'));
return $column;
}
public function filters(): array
{
return [
];
}
#[On('bulkDelete.{tableName}')]
public function bulkDelete(): void
{
$this->js('alert(window.pgBulkActions.get(\'' . $this->tableName . '\'))');
if($this->checkboxValues){
foreach ($this->checkboxValues as $id) {
$textAd = TextAd::find($id);
if ($textAd) {
$textAd->delete();
}
}
$this->js('window.pgBulkActions.clearAll()'); // clear the count on the interface.
}
}
#[On('categoryChanged')]
public function categoryChanged($value,$fieldName, $modelId): void
{
//dd($value,$fieldName, $modelId);
if (in_array($fieldName, ['color'])) {
$this->noUpdated($modelId,$fieldName,$value);
}
}
#[On('onUpdatedEditable')]
public function onUpdatedEditable($id, $field, $value): void
{
if (in_array($field,[
''
]) && $this->canEdit) {
$this->noUpdated($id,$field,$value);
}
}
#[On('onUpdatedToggleable')]
public function onUpdatedToggleable($id, $field, $value): void
{
if (in_array($field,['is_active']) && $this->canEdit) {
$this->noUpdated($id,$field,$value);
}
}
private function noUpdated($id,$field,$value){
$textAd = TextAd::find($id);
if ($textAd) {
$textAd->{$field} = $value;
$textAd->save(); // 明確觸發 saving
}
$this->notification()->send([
'icon' => 'success',
'title' => $id.'.'.__('text_ads.'.$field).':'.$value,
'description' => '已經寫入',
]);
}
public function actions(TextAd $row): array
{
$actions = [];
$actions[] = Button::add('text-ad-test')
->slot('測試')
->icon('solid-cog')
->class('inline-flex items-center gap-1 px-3 py-1 rounded bg-amber-200 text-black')
->dispatchTo('forms.text-ad-test-form', 'openModal', ['id' => $row->id]);
if ($this->canEdit) {
$actions[]=Button::add('edit')
->slot(__('text_ads.edit'))
->icon('solid-pencil-square')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('forms.text-ads-form', 'openModal', ['id' => $row->id]);
}
if($this->canDelect){
$actions[]=Button::add('delete')
->slot(__('text_ads.delete'))
->icon('solid-trash')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('forms.text-ads-form', 'deleteTextAd', ['id' => $row->id]);
}
return $actions;
}
}

20
app/Models/TextAd.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TextAd extends Model
{
protected $fillable = [
'content',
'color',
'duration',
'is_active',
];
protected $casts = [
'color' => \App\Enums\TextAdColors::class,
'is_active' => 'boolean',
];
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('text_ads', function (Blueprint $table) {
$table->id();
$table->string('content')->comment('廣告內容');
$table->enum('color', ['black','white', 'red', 'green','blue'])->default('black')->comment('顯示顏色');
$table->integer('duration')->default(1)->comment('播放間隔時間(分鐘)');
$table->boolean('is_active')->default(true); // 啟用狀態
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('text_ads');
}
};

View File

@ -13,10 +13,12 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
(new TransferSqliteTableJob('database/User.data', false))->handle();
//$this->call([
$this->call([
TextAdPermissionSeeder::class,
TextAdSeeder::class,
// PermissionTableSeeder::class,
// CreateAdminUserSeeder::class,
// FavoriteSongsSeeder::class,
//]);
]);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class TextAdPermissionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$permissions = [
'text-ad-list',
'text-ad-create',
'text-ad-edit',
'text-ad-delete',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
// 把權限加給 Admin 角色
$adminRole = Role::where('name', 'Admin')->first();
if ($adminRole) {
$adminRole->givePermissionTo($permissions);
}
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\TextAd;
use App\Enums\TextAdColors;
class TextAdSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$ads = [
[
'content' => '本公司消費方式:包廂費(原價 * 時數 * 折數)+ 清潔費(一次)+ 總金額10%服務費(一次)。',
'color' => TextAdColors::Red,
],
[
'content' => '一般專案為包廂計費免收低消及人頭費如各族群優惠專案為包廂計費、不限人數K歌一口價。',
'color' => TextAdColors::Red,
],
[
'content' => '工商專案 上班族趕快來報到 不限人數 包廂計費 歡唱K歌一口價★星期一至星期日 上午0800 ~ 下午1700 ★歡唱3小時 小包廂 666元 中包廂 999元 大包廂(臺中公園店為中大包廂) 1299元',
'color' => TextAdColors::Green,
],
[
'content' => '趁年輕 一定要大膽瘋狂不限人數 包廂計費 歡唱K歌一口價★星期一至星期五 上午0800 ~ 下午1700 ★歡唱3小時(員林中山店為4小時) 小包廂 333元 中包廂 666元 大包廂(臺中公園店為中大包廂) 999元',
'color' => TextAdColors::Blue,
],
[
'content' => '重拾當年意氣風發的活力 不輸少年人啦不限人數 包廂計費 歡唱K歌一口價 ★星期一至星期五 上午0800 ~ 下午1700 ★歡唱4小時 小包廂 333元 中包廂 666元 大包廂 999元',
'color' => TextAdColors::Blue,
],
[
'content' => '各分店皆適用 生日快樂!吹個蠟燭 許個心願吧 壽星們 生日開趴的通通站出來 ★當日壽星限定(須出示相關證件供服務人員確認).享有好禮雙重送 ★好禮一生日紅酒一瓶🍷超級巨星6吋特製蛋糕什錦水果一盤義式冰淇淋莓果調酒(五選一) ★好禮二餐飲券600元(可當日折抵使用)',
'color' => TextAdColors::White,
],
];
foreach ($ads as $ad) {
TextAd::create([
'content' => $ad['content'],
'color' => $ad['color'],
'duration' => 1,
'is_active' => true,
]);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
return [
'management' => '文字廣吿管理',
'list' => '文字廣吿列表',
'CreateNew' => '新增文字廣吿',
'EditArtist' => '編輯文字廣吿',
'ImportData' => '滙入文字廣吿',
'create_edit' => '新增 / 編輯',
'create' => '新增',
'edit' => '編輯',
'delete' => '刪除',
'id' => '編號',
'content' => '廣告內容',
'color' => '顏色',
'duration' => '播放間隔時間(分鐘)',
'is_active' => '是否推播',
'created_at' =>'建立於',
'actions' => '操作',
'view' => '查看',
'submit' => '提交',
'cancel' => '取消',
];

View File

@ -0,0 +1,7 @@
<x-layouts.admin>
<x-wireui:notifications/>
<livewire:tables.text-ads-table />
<livewire:forms.text-ads-form />
<livewire:forms.text-ad-test-form />
</x-layouts.admin>

View File

@ -0,0 +1,24 @@
<div class="p-4 space-y-4">
<x-wireui:modal-card title="測試" blur wire:model.defer="showModal">
<x-wireui:select
label="選擇房間"
wire:model.defer="roomId"
placeholder="請選擇房間"
:options="$roomOptions"
option-label="name"
option-value="value"
/>
<div class="text-gray-700 bg-gray-100 p-3 rounded shadow">
<div class="font-semibold mb-1">將發送內容:</div>
<div>{{ $content }}</div>
</div>
<x-slot name="footer">
<div class="flex justify-between w-full">
<x-wireui:button flat label="{{__('text_ads.cancel')}}" wire:click="closeModal" />
<x-wireui:button primary wire:click="send" primary label="立即發送" spinner />
</div>
</x-slot>
</x-wireui:modal-card>
</div>

View File

@ -0,0 +1,24 @@
<x-wireui:modal-card title="{{ $textAdId ? __('text_ads.EditArtist') : __('text_ads.CreateNew') }}" blur wire:model.defer="showModal">
<div class="space-y-4">
<x-wireui:input label="{{__('text_ads.content')}}" wire:model.defer="fields.content" />
<x-wireui:select
label="{{__('text_ads.color')}}"
wire:model.defer="fields.color"
placeholder="{{__('text_ads.select_color')}}"
:options="$colorOptions"
option-label="name"
option-value="value"
/>
<x-wireui:input label="{{__('text_ads.duration')}}" type="number" min="1" max="60" wire:model.defer="fields.duration" />
<x-wireui:toggle label="{{__('text_ads.is_active')}}" wire:model.defer="fields.is_active" />
</div>
<x-slot name="footer">
<div class="flex justify-between w-full">
<x-wireui:button flat label="{{__('text_ads.cancel')}}" wire:click="closeModal" />
<x-wireui:button primary label="{{__('text_ads.submit')}}" wire:click="save" />
</div>
</x-slot>
</x-wireui:modal-card>

View File

@ -0,0 +1,8 @@
<x-admin.section-header title="{{ __('text_ads.list') }}">
<x-wireui:button
wire:click="$dispatchTo('forms.text-ads-form', 'openModal')"
icon="plus"
label="{{ __('text_ads.CreateNew') }}"
class="bg-blue-600 text-white"
/>
</x-admin.section-header>

View File

@ -14,6 +14,7 @@ new class extends Component
['label' => 'User', 'route' => 'admin.users', 'icon' => 'user-circle', 'permission' => 'user-list'],
['label' => 'Room', 'route' => 'admin.rooms', 'icon' => 'building-library', 'permission' => 'room-list'],
['label' => 'RoomGrid', 'route' => 'admin.room-grids', 'icon' => 'film', 'permission' => 'room-list'],
['label' => 'TextAd', 'route' => 'admin.text-ads', 'icon' => 'megaphone', 'permission' => 'text-ad-list'],
];
/**

View File

@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Artisan;
Schedule::command('app:clear-machine-statuses')->dailyAt('12:00'); // 每天凌晨 12:10 執行
Schedule::command('rooms:check-online-status')->everyMinute(); //每分驗証
Schedule::command('ads:rotate')->everyMinute();
//首次部署或有新增命令時)建立或更新任務排程 Crontab
// 檢查是否已有下列 crontab 設定crontab -e
//分鐘 小時 日 月 星期 指令

View File

@ -26,4 +26,5 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function ()
Route::get('/users', function () {return view('livewire.admin.users');})->name('users');
Route::get('/rooms', function () {return view('livewire.admin.rooms');})->name('rooms');
Route::get('/room-grids', function () {return view('livewire.admin.room-grids');})->name('room-grids');
Route::get('/text-ads', function () {return view('livewire.admin.text-ads');})->name('text-ads');
});