diff --git a/app/Enums/OrderedSongStatus.php b/app/Enums/OrderedSongStatus.php
new file mode 100644
index 0000000..4d05b3e
--- /dev/null
+++ b/app/Enums/OrderedSongStatus.php
@@ -0,0 +1,35 @@
+ __('enums.NotPlayed'),
+ self::Playing => __('enums.Playing'),
+ self::Played => __('enums.Played'),
+ self::NoFile => __('enums.NoFile'),
+ self::Skipped => __('enums.Skipped'),
+ self::InsertPlayback => __('enums.InsertPlayback'),
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/RoomSongController.php b/app/Http/Controllers/Api/RoomSongController.php
new file mode 100644
index 0000000..f59831c
--- /dev/null
+++ b/app/Http/Controllers/Api/RoomSongController.php
@@ -0,0 +1,364 @@
+getRoomSession($request->api_token) ;
+
+ // 找這個 session 的最大 order_number,下一首加 1
+ $lastOrder = OrderedSong::where('room_session_id', $roomSession->id)->max('order_number');
+ $orderNumber = $lastOrder ? $lastOrder + 1 : 1;
+
+ // 取得歌曲名稱
+ $song = SongLibraryCache::findOrFail($request->song_id);
+
+ // 建立 OrderedSong
+ $orderedSong = OrderedSong::create([
+ 'room_session_id' => $roomSession->id,
+ 'from_by' => $request->from_by,
+ 'order_number' => $orderNumber,
+ 'status' => $request->status,
+ 'song_id' => $request->song_id,
+ 'song_name' => $song->song_name,
+ 'artist_name' => $song->str_artists_plus(),
+ 'ordered_at' => now(),
+ ]);
+
+ // 檢查這首歌在此 session 是否第一次點
+ $countInSession = OrderedSong::where('room_session_id', $roomSession->id)
+ ->where('song_id', $request->song_id)
+ ->count();
+
+ if ($countInSession === 1) { // 第一次點才加
+ $song->increment('song_counts');
+ }
+ $this->sys($roomSession,$orderedSong);
+ return ApiResponse::success([
+ 'ordered_song' => $orderedSong,
+ 'next_song_name' => $this->nextSongName($roomSession),
+ ]);
+ }
+ private function sys($roomSession,$orderedSong)
+ {
+ $validated = [
+ 'api_token' => $roomSession->api_token,
+ 'order_number' => $orderedSong->order_number,
+ 'from_by' => $orderedSong->from_by,
+ 'song_id' => $orderedSong->song_id,
+ 'song_name' => $orderedSong->song_name,
+ 'artist_name' => $orderedSong->artist_name,
+ 'status' => $orderedSong->status,
+ 'ordered_at' => $orderedSong->ordered_at,
+ 'started_at' => $orderedSong->started_at,
+ 'finished_at' => $orderedSong->finished_at,
+ ];
+ $response = (new MachineStatusForwarder(
+ $roomSession->room->branch->external_ip,
+ "/api/room/sync-order-song",
+ $validated
+ ))->forward();
+ }
+ public function syncOrderSong(SyncOrderSongRequest $request)
+ {
+ $roomSession = $this->getRoomSession($request->api_token) ;
+ // 建立或更新 OrderedSong
+ $orderedSong = OrderedSong::updateOrCreate(
+ [
+ 'room_session_id' => $roomSession->id,
+ 'order_number' => $request->order_number,
+ ],
+ [
+ 'from_by' => $request->from_by,
+ 'song_id' => $request->song_id,
+ 'song_name' => $request->song_name,
+ 'artist_name' => $request->artist_name,
+ 'status' => $request->status,
+ 'ordered_at' => $request->ordered_at,
+ 'started_at' => $request->started_at,
+ 'finished_at' => $request->finished_at,
+ ]
+ );
+
+ // 檢查這首歌在此 session 是否第一次點
+ if ($orderedSong->wasRecentlyCreated) {
+ $countInSession = OrderedSong::where('room_session_id', $roomSession->id)
+ ->where('song_id', $request->song_id)->count();
+ if ($countInSession === 1) { // 第一次點才加
+ $song->increment('song_counts');
+ }
+ }
+
+ return ApiResponse::success([
+ 'ordered_song' => $orderedSong
+ ]);
+ }
+ /**
+ * @OA\Get(
+ * path="/api/room/ordered-songs",
+ * summary="取得包廂點歌列表",
+ * description="取得指定包廂的已播、正在播放、待播/插播歌曲列表",
+ * tags={"Room Control Song"},
+ * security={{"Authorization":{}}},
+ * @OA\Parameter(ref="#/components/parameters/ApiTokenQuery"),
+ * @OA\Response(
+ * response=200,
+ * description="成功取得點歌列表",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(
+ * property="played",
+ * type="array",
+ * description="已播放 (Played, Skipped, NoFile)",
+ * @OA\Items(ref="#/components/schemas/OrderedSong")
+ * ),
+ * @OA\Property(
+ * property="playing",
+ * type="array",
+ * description="正在播放中 (Playing)",
+ * @OA\Items(ref="#/components/schemas/OrderedSong")
+ * ),
+ * @OA\Property(
+ * property="not_played",
+ * type="array",
+ * description="未播放 (NotPlayed, InsertPlayback)",
+ * @OA\Items(ref="#/components/schemas/OrderedSong")
+ * )
+ * )
+ * ),
+ * @OA\Response(
+ * response=422,
+ * description="驗證失敗",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(property="message", type="string", example="The given data was invalid."),
+ * @OA\Property(property="errors", type="object")
+ * )
+ * )
+ * )
+ */
+ public function listOrderedSongs(RoomSongRequest $request)
+ {
+ $roomSession = $this->getRoomSession($request->api_token) ;
+ // 已結束 (finished + canceled)
+ $played = OrderedSong::where('room_session_id', $roomSession->id)
+ ->whereIn('status', ['Played', 'Skipped', 'NoFile'])
+ ->orderByDesc('finished_at')
+ ->get();
+ $playing = OrderedSong::where('room_session_id', $roomSession->id)
+ ->whereIn('status', ['Playing'])
+ ->get();
+
+ // 正在播 + 插播 + 待播
+ $not_played = OrderedSong::where('room_session_id', $roomSession->id)
+ ->whereIn('status', ['Playing', 'InsertPlayback', 'NotPlayed'])
+ ->orderByRaw("FIELD(status, 'Playing', 'InsertPlayback', 'NotPlayed')") // playing > InsertPlayback > NotPlayed
+ ->orderByRaw("CASE
+ WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 越後排越前
+ ->orderByRaw("CASE
+ WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 越後排越後
+ ->get();
+
+ return ApiResponse::success([
+ 'played' => $played,
+ 'playing' => $playing,
+ 'not_played' => $not_played,
+ ]);
+ }
+ /**
+ * @OA\Post(
+ * path="/api/room/current-song",
+ * summary="取得目前播放中的歌曲",
+ * description="回傳當前房間正在播放的歌曲資訊 (包含部分 song 欄位)",
+ * tags={"Room Control Song"},
+ * security={{"Authorization":{}}},
+ * @OA\RequestBody(
+ * required=true,
+ * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest")
+ * ),
+ * @OA\Response(
+ * response=200,
+ * description="成功回傳目前播放的歌曲",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(
+ * property="success",
+ * type="boolean",
+ * example=true
+ * ),
+ * @OA\Property(
+ * property="data",
+ * type="object",
+ * @OA\Property(
+ * property="current",
+ * ref="#/components/schemas/OrderedSongWithPartialSong"
+ * )
+ * )
+ * )
+ * )
+ * )
+ */
+ public function currentSong(RoomSongRequest $request)
+ {
+ $roomSession = $this->getRoomSession($request->api_token) ;
+ $current = OrderedSong::where('room_session_id', $roomSession->id)
+ ->where('status', 'Playing')
+ ->withPartialSong()
+ ->first();
+ return ApiResponse::success([
+ 'current' => $current ,
+ ]);
+ }
+ /**
+ * @OA\Post(
+ * path="/api/room/next-song",
+ * summary="播放下一首歌曲",
+ * description="將目前播放的歌標記為結束,並將下一首設為播放中,回傳下首與下下首資訊。",
+ * tags={"Room Control Song"},
+ * security={{"Authorization":{}}},
+ * @OA\RequestBody(
+ * required=true,
+ * @OA\JsonContent(ref="#/components/schemas/RoomSongRequest")
+ * ),
+ * @OA\Response(
+ * response=200,
+ * description="成功回傳歌曲資訊",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(
+ * property="current",
+ * type="object",
+ * nullable=true,
+ * ref="#/components/schemas/OrderedSongWithPartialSong"
+ * ),
+ * @OA\Property(
+ * property="next",
+ * type="object",
+ * nullable=true,
+ * ref="#/components/schemas/OrderedSongWithPartialSong"
+ * )
+ * )
+ * ),
+ * @OA\Response(
+ * response=401,
+ * description="未授權或 Token 錯誤"
+ * ),
+ * @OA\Response(
+ * response=404,
+ * description="找不到房間或歌曲"
+ * )
+ * )
+ */
+ public function nextSong(RoomSongRequest $request)
+ {
+ $roomSession = $this->getRoomSession($request->api_token) ;
+ // 找目前正在播
+ $current = OrderedSong::where('room_session_id', $roomSession->id)
+ ->where('status', 'Playing')
+ ->first();
+ if ($current) {
+ // 把當前設為 finished,並記錄結束時間
+ $current->update([
+ 'status' => 'Played',
+ 'finished_at' => now(),
+ ]);
+ $this->sys($roomSession,$current);
+ }
+ // 撈出候播清單(下首 + 下下首)
+ $queue = OrderedSong::where('room_session_id', $roomSession->id)
+ ->whereIn('status', ['InsertPlayback', 'NotPlayed'])
+ ->withPartialSong()
+ ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')") // InsertPlayback > NotPlayed
+ ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC") // InsertPlayback 後插播的先播
+ ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC") // NotPlayed 先點的先播
+ ->limit(2)
+ ->get();
+ $current =$queue->get(0);
+ if ($current) {
+ $current->update([
+ 'status' => 'Playing',
+ 'started_at' => now(),
+ ]);
+ $this->sys($roomSession,$current);
+ }
+ return ApiResponse::success([
+ 'current' => $current,// 下首
+ 'next' => $queue->get(1) ?? null,// 下下首
+ ]);
+ }
+
+ private function nextSongName(RoomSession $roomSession)
+ {
+ // 找下首
+ $next = OrderedSong::where('room_session_id', $roomSession->id)
+ ->whereIn('status', ['InsertPlayback', 'NotPlayed'])
+ ->with('song')
+ ->orderByRaw("FIELD(status, 'InsertPlayback', 'NotPlayed')")
+ ->orderByRaw("CASE WHEN status = 'InsertPlayback' THEN ordered_at END DESC")
+ ->orderByRaw("CASE WHEN status = 'NotPlayed' THEN ordered_at END ASC")
+ ->first();
+ return $next?->song?->name;
+
+ }
+ /**
+ * 取得對應的 RoomSession
+ */
+ private function getRoomSession($api_token): RoomSession
+ {
+ return RoomSession::where('api_token', $api_token)
+ ->whereIn('status', ['active', 'maintain'])
+ ->firstOrFail();
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/RoomControlController.php b/app/Http/Controllers/RoomControlController.php
index d89c12c..00ae616 100644
--- a/app/Http/Controllers/RoomControlController.php
+++ b/app/Http/Controllers/RoomControlController.php
@@ -4,7 +4,8 @@ namespace App\Http\Controllers;
use App\Http\Requests\SendRoomSwitchCommandRequest;
use App\Http\Requests\ReceiveRoomRegisterRequest;
-use App\Http\Requests\ReceiveRoomStatusDataRequest;
+use App\Http\Requests\HeartBeatRequest;
+use App\Http\Requests\SessionRequest;
use App\Services\TcpSocketClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
@@ -102,6 +103,87 @@ class RoomControlController extends Controller
'other_set' => $otherSet
]);
}
+ /**
+ * @OA\Post(
+ * path="/api/room/session",
+ * summary="取得包廂狀態",
+ * description="依據 hostname 與 branch_name 取得或建立房間,並回傳房間資料與最新 session",
+ * tags={"Room Control"},
+ * security={{"Authorization":{}}},
+ * @OA\RequestBody(
+ * required=true,
+ * @OA\JsonContent(ref="#/components/schemas/SessionRequest")
+ * ),
+ * @OA\Response(
+ * response=200,
+ * description="成功取得房間狀態",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(
+ * property="room",
+ * type="object",
+ * ref="#/components/schemas/Room"
+ * )
+ * )
+ * ),
+ * @OA\Response(
+ * response=422,
+ * description="驗證失敗",
+ * @OA\JsonContent(
+ * type="object",
+ * @OA\Property(property="message", type="string", example="The given data was invalid."),
+ * @OA\Property(property="errors", type="object")
+ * )
+ * )
+ * )
+ */
+ public function session(SessionRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+ $roomType = null;
+ $roomName = null;
+ $floor = null;
+ // 從 room_name(例如 PC101, SVR01)中擷取 type 與 name
+ if (preg_match('/^([A-Za-z]+)(\d+)$/', $validated['hostname'], $matches)) {
+ $roomType = strtolower($matches[1]); // 'PC' → 'pc'
+ $roomName = $matches[2]; // '101'
+ if($roomType =='svr'){
+ $floor = '0';
+ } else{
+ $floor = (int) substr($roomName, 0, 1);
+ }
+ }
+ $branch=Branch::where('name',$validated['branch_name'])->first();
+ $room = Room::firstOrNew([
+ 'branch_id' => $branch->id,
+ 'floor' => $floor,
+ 'name' => $roomName,
+ 'type' => $roomType,
+ ]);
+ $room->internal_ip = $validated['ip'];
+ $room->port = 1000;
+ $room->is_online=1;
+ $room->touch(); // 更新 updated_at
+ $room->save();
+ $room->load('latestSession');
+ if ($room->latestSession) {
+ $room->latestSession->api_token = !empty($validated['token'])
+ ? $validated['token']
+ : bin2hex(random_bytes(32));
+ $room->latestSession->save();
+ $validated['token']=$room->latestSession->api_token;
+ }
+
+ $response = (new MachineStatusForwarder(
+ $branch->external_ip,
+ "/api/room/session",
+ $validated
+ ))->forward();
+ return ApiResponse::success([
+ 'room' => $room->latestSession,
+ ]);
+
+ }
/**
* @OA\Post(
@@ -113,7 +195,7 @@ class RoomControlController extends Controller
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
- * @OA\JsonContent(ref="#/components/schemas/ReceiveRoomStatusDataRequest")
+ * @OA\JsonContent(ref="#/components/schemas/HeartBeatRequest")
* ),
* @OA\Response(
* response=200,
@@ -149,7 +231,7 @@ class RoomControlController extends Controller
* )
* )
*/
- public function StatusReport(ReceiveRoomStatusDataRequest $request)
+ public function HeartBeat(HeartBeatRequest $request)
{
$validated = $request->validated();
$roomType = null;
@@ -265,6 +347,8 @@ class RoomControlController extends Controller
return ApiResponse::error('房間未設定 IP 或 Port');
}
$room->status=$validated['command'];
+ $room->log_source='api';
+ $room->log_message='sendSwitch';
$room->started_at=$validated['started_at'];
$room->ended_at=$validated['ended_at'];
$room->save();
@@ -279,7 +363,6 @@ class RoomControlController extends Controller
};
$data = $suffix . "," . $signal;
- //dd($data);
$client = new TcpSocketClient($room->internal_ip, $room->port);
try {
$response = $client->send($data);
diff --git a/app/Http/Requests/ReceiveRoomStatusDataRequest.php b/app/Http/Requests/HeartBeatRequest.php
similarity index 91%
rename from app/Http/Requests/ReceiveRoomStatusDataRequest.php
rename to app/Http/Requests/HeartBeatRequest.php
index 7e62797..caba528 100644
--- a/app/Http/Requests/ReceiveRoomStatusDataRequest.php
+++ b/app/Http/Requests/HeartBeatRequest.php
@@ -6,7 +6,7 @@ use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
- * schema="ReceiveRoomStatusDataRequest",
+ * schema="HeartBeatRequest",
* required={"branch_name","hostname", "ip"},
* @OA\Property(property="branch_name", type="string", example="測試"),
* @OA\Property(property="hostname", type="string", example="PC101"),
@@ -16,7 +16,7 @@ use Illuminate\Foundation\Http\FormRequest;
* @OA\Property(property="disk", type="numeric", example="158266.49"),
* )
*/
-class ReceiveRoomStatusDataRequest extends ApiRequest
+class HeartBeatRequest extends ApiRequest
{
/**
* Get the validation rules that apply to the request.
diff --git a/app/Http/Requests/OrderSongRequest.php b/app/Http/Requests/OrderSongRequest.php
new file mode 100644
index 0000000..b0b3f07
--- /dev/null
+++ b/app/Http/Requests/OrderSongRequest.php
@@ -0,0 +1,33 @@
+ [
+ 'required',
+ Rule::exists('room_sessions', 'api_token')
+ ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain']))
+ ],
+ 'song_id' => 'required|exists:song_library_cache,song_id',
+ 'status' => 'required|in:NotPlayed,InsertPlayback,Skipped',
+ 'from_by' => 'nullable',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/RoomSongRequest.php b/app/Http/Requests/RoomSongRequest.php
new file mode 100644
index 0000000..257afd1
--- /dev/null
+++ b/app/Http/Requests/RoomSongRequest.php
@@ -0,0 +1,43 @@
+ [
+ 'required',
+ Rule::exists('room_sessions', 'api_token')
+ ->where(fn ($q) => $q->whereIn('status', ['active', 'maintain']))
+ ]
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Requests/SessionRequest.php b/app/Http/Requests/SessionRequest.php
new file mode 100644
index 0000000..6ce2097
--- /dev/null
+++ b/app/Http/Requests/SessionRequest.php
@@ -0,0 +1,32 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'branch_name' =>'required|string|exists:branches,name',
+ 'hostname' => 'required|string',
+ 'ip' => 'required|string',
+ 'token' => 'nullable|string',
+ ];
+ }
+}
diff --git a/app/Http/Requests/SyncOrderSongRequest.php b/app/Http/Requests/SyncOrderSongRequest.php
new file mode 100644
index 0000000..580c573
--- /dev/null
+++ b/app/Http/Requests/SyncOrderSongRequest.php
@@ -0,0 +1,25 @@
+ 'required|exists:room_sessions,api_token',
+ 'from_by' => 'nullable',
+ 'order_number' => 'required|integer',
+ 'song_id' => 'required|exists:song_library_cache,song_id',
+ 'song_name' => 'nullable',
+ 'artist_name' => 'nullable',
+ 'status' => 'required|in:NotPlayed,Playing,Played,NoFile,Skipped,InsertPlayback',
+ 'ordered_at' => 'required',
+ 'started_at' => 'nullable',
+ 'finished_at' => 'nullable',
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Livewire/Modals/RoomDetailModal.php b/app/Livewire/Modals/RoomDetailModal.php
index cb73f06..95dfb65 100644
--- a/app/Livewire/Modals/RoomDetailModal.php
+++ b/app/Livewire/Modals/RoomDetailModal.php
@@ -4,7 +4,7 @@ namespace App\Livewire\Modals;
use App\Models\Room;
use App\Models\Branch;
-
+use Illuminate\Support\Carbon;
use Livewire\Component;
use App\Services\ApiClient;
use Illuminate\Support\Facades\Auth;
@@ -36,20 +36,20 @@ class RoomDetailModal extends Component
}
public function startNotify()
{
- $data = $this->buildNotifyData('maintain', null, null);
+ $data = $this->buildNotifyData('maintain', Carbon::now(), null);
$this->send($data);
}
public function stopNotify()
{
- $data = $this->buildNotifyData('closed', null, null);
+ $data = $this->buildNotifyData('closed', null, Carbon::now());
$chk =$this->send($data);
}
public function fireNotify()
{
- $data = $this->buildNotifyData('fire', null, null);
+ $data = $this->buildNotifyData('fire', null, Carbon::now());
$this->send($data);
}
diff --git a/app/Livewire/Tables/RoomStatusLogTable.php b/app/Livewire/Tables/RoomStatusLogTable.php
index 6fb4d13..a4afa4a 100644
--- a/app/Livewire/Tables/RoomStatusLogTable.php
+++ b/app/Livewire/Tables/RoomStatusLogTable.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Tables;
+use App\Models\Branch;
use App\Models\RoomStatusLog;
+use App\Enums\RoomType;
use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Builder;
use PowerComponents\LivewirePowerGrid\Button;
@@ -37,28 +39,47 @@ final class RoomStatusLogTable extends PowerGridComponent
public function datasource(): Builder
{
- return RoomStatusLog::query()->latest();;
+ return RoomStatusLog::with(['room', 'branch'])->latest();
}
public function relationSearch(): array
{
- return [];
+ return [
+ 'branch' => ['name'],
+ 'room' => ['name'],
+ 'user' => ['name'],
+ ];
}
public function fields(): PowerGridFields
{
return PowerGrid::fields()
->add('id')
+ ->add('branch_name', function (RoomStatusLog $model) {
+ return $model->branch?->name;
+ })
+ ->add('room_type', function (RoomStatusLog $model) {
+ return $model->room?->type->labelPowergridFilter();
+ })
->add('room_name', function (RoomStatusLog $model) {
- return $model->room?->type->labelPowergridFilter().$model->room?->name;
+ return $model->room?->name;
})
->add('user_name', function (RoomStatusLog $model){
return $model->user?->name;
})
+ ->add('is_online_img', fn ($model) =>
+ [
+ $model->is_online ? 'check-circle' : 'x-circle' => [
+ 'text-color' => $model->is_online ? 'text-green-600' : 'text-red-600',
+ ],
+ ])
->add('status_str',function (RoomStatusLog $model){
return $model->status->labelPowergridFilter();
})
+ ->add('started_at')
+ ->add('ended_at')
->add('message')
+ ->add('source')
->add('created_at');
}
@@ -66,17 +87,48 @@ final class RoomStatusLogTable extends PowerGridComponent
{
$column=[];
$column[]=Column::make(__('room-status-log.id'), 'id');
- $column[]=Column::make(__('room-status-log.room'), 'room_name');
+ $column[]=Column::make(__('room-status-log.branch'), 'branch_name');
+ $column[]=Column::make(__('room-status-log.room_type'), 'room_type');
+ $column[]=Column::make(__('room-status-log.room_name'), 'room_name');
$column[]=Column::make(__('room-status-log.user'), 'user_name');
+ $column[]=Column::make(__('room-status-log.is_online'), 'is_online_img')->template();
$column[]=Column::make(__('room-status-log.status'), 'status_str');
+ $column[]=Column::make(__('room-status-log.started_at'), 'started_at');
+ $column[]=Column::make(__('room-status-log.ended_at'), 'ended_at');
$column[]=Column::make(__('room-status-log.message'), 'message');
+ $column[]=Column::make(__('room-status-log.source'), 'source');
$column[]=Column::make(__('room-status-log.created_at'), 'created_at');
return $column;
}
+ public function rowTemplates(): array
+ {
+ return [
+ 'check-circle' => '',
+ 'x-circle' => '',
+ ];
+ }
public function filters(): array
{
+ $branches = Branch::query()->orderBy('name')->get()->map(fn($branch) => (object)[
+ 'value' => $branch->id,
+ 'label' => $branch->name,
+ ]);
+ //dd($branches);
return [
+ Filter::enumSelect('branch_name','branch_id')
+ ->datasource($branches)
+ ->optionLabel('label'),
+ Filter::inputText('room_type')
+ ->placeholder('輸入"pc","svr"')
+ ->filterRelation('room','type'),
+ Filter::inputText('room_name')
+ ->placeholder('輸入包廂名稱')
+ ->filterRelation('room','name')
];
}
}
diff --git a/app/Models/OrderedSong.php b/app/Models/OrderedSong.php
new file mode 100644
index 0000000..552088c
--- /dev/null
+++ b/app/Models/OrderedSong.php
@@ -0,0 +1,88 @@
+ 'datetime',
+ 'started_at' => 'datetime',
+ 'finished_at' => 'datetime',
+ 'status' => \App\Enums\OrderedSongStatus::class,
+ ];
+
+
+ public function session()
+ {
+ return $this->belongsTo(RoomSession::class, 'room_session_id');
+ }
+
+ public function song()
+ {
+ return $this->belongsTo(SongLibraryCache::class);
+ }
+ public function scopeWithPartialSong($query)
+ {
+ return $query->with([
+ 'song' => function ($q) {
+ $q->select('id', 'name','filename','db_change','vocal','situation'); // 精簡版
+ }
+ ]);
+ }
+}
diff --git a/app/Models/Room.php b/app/Models/Room.php
index 4721b71..0c690f3 100644
--- a/app/Models/Room.php
+++ b/app/Models/Room.php
@@ -2,7 +2,8 @@
namespace App\Models;
-use Illuminate\Support\Carbon;
+use Illuminate\Database\Eloquent\Relations\HasOne;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\LogsModelActivity;
@@ -22,11 +23,15 @@ use App\Traits\LogsModelActivity;
* @OA\Property(property="ended_at", type="string", format="date-time", example=null),
* )
*/
+
class Room extends Model
{
/** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory, LogsModelActivity;
+ public string $log_message = 'BranchForm-add';
+ public string $log_source = 'manual';
+
protected $fillable = [
'branch_id',
'floor',
@@ -39,6 +44,12 @@ class Room extends Model
'started_at',
'ended_at',
];
+ protected $attributes = [
+ 'type' => \App\Enums\RoomType::Unset,
+ 'floor' => 1,
+ 'is_online' => false,
+ 'status' => \App\Enums\RoomStatus::Error,
+ ];
//protected $hidden = [
// 'internal_ip',
@@ -55,13 +66,12 @@ class Room extends Model
'status' => \App\Enums\RoomStatus::class,
'started_at' => 'datetime',
'ended_at' => 'datetime',
-
];
public function str_started_at(){
$str ="Not Set";
if($this->started_at !=null){
- $str = $this->started_at;
+ $str = $this->started_at;
}
return $str;
}
@@ -77,8 +87,30 @@ class Room extends Model
public function branch() {
return $this->belongsTo(Branch::class);
}
+ public function full_name(){
+ return $this->type->labelPowergridFilter().$this->name;
+ }
public function statusLogs() {
return $this->hasMany(RoomStatusLog::class);
}
+ public function sessions(): HasMany
+ {
+ return $this->hasMany(RoomSession::class);
+ }
+ /**
+ * 最新的一筆 session
+ */
+ public function latestSession(): HasOne
+ {
+ return $this->hasOne(RoomSession::class)->latestOfMany();
+ }
+
+ /**
+ * 方便取得最新狀態
+ */
+ public function getLatestStatusAttribute(): string
+ {
+ return $this->latestSession?->status ?? 'error';
+ }
}
diff --git a/app/Models/RoomSession.php b/app/Models/RoomSession.php
new file mode 100644
index 0000000..7b17096
--- /dev/null
+++ b/app/Models/RoomSession.php
@@ -0,0 +1,42 @@
+ 'datetime',
+ 'ended_at' => 'datetime',
+ ];
+
+ // 狀態常數
+ public const STATUS_ACTIVE = 'active';
+ public const STATUS_CLOSED = 'closed';
+ public const STATUS_FORCE_CLOSED = 'force_closed';
+ public const STATUS_FIRE_CLOSED = 'fire_closed';
+
+ // 模式常數
+ public const MODE_NORMAL = 'normal';
+ public const MODE_VIP = 'vip';
+ public const MODE_TEST = 'test';
+
+ public function room()
+ {
+ return $this->belongsTo(Room::class);
+ }
+}
diff --git a/app/Models/RoomStatusLog.php b/app/Models/RoomStatusLog.php
index 1c7dca5..7c7f7b2 100644
--- a/app/Models/RoomStatusLog.php
+++ b/app/Models/RoomStatusLog.php
@@ -13,18 +13,30 @@ class RoomStatusLog extends Model
protected $fillable =
[
+ 'branch_id',
'room_id',
'user_id',
+ 'is_online',
'status',
+ 'started_at',
+ 'ended_at',
'message',
+ 'source',
];
protected $casts = [
+ 'is_online' => 'boolean',
'status' => \App\Enums\RoomStatus::class,
+ 'started_at' => 'datetime',
+ 'ended_at' => 'datetime',
+
];
public function user(){
return $this->belongsTo(User::class);
}
+ public function branch() {
+ return $this->belongsTo(Branch::class);
+ }
public function room() {
return $this->belongsTo(Room::class);
}
diff --git a/app/Models/SongLibraryCache.php b/app/Models/SongLibraryCache.php
index 16ddd10..4f208c2 100644
--- a/app/Models/SongLibraryCache.php
+++ b/app/Models/SongLibraryCache.php
@@ -39,4 +39,8 @@ class SongLibraryCache extends Model
'updated_at',
];
+ public function str_artists_plus(): string
+ {
+ return ($this->artistB!=null) ? $this->artistA ." + ".$this->artistB :$this->artistA;
+ }
}
diff --git a/app/Observers/RoomObserver.php b/app/Observers/RoomObserver.php
index d8a3649..4ebfefc 100644
--- a/app/Observers/RoomObserver.php
+++ b/app/Observers/RoomObserver.php
@@ -3,6 +3,7 @@
namespace App\Observers;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Arr;
use App\Models\Room;
use App\Models\RoomStatusLog;
@@ -14,7 +15,8 @@ class RoomObserver
*/
public function created(Room $room): void
{
- //
+ // 建立初始 log
+ $this->createStatusLog($room);
}
/**
@@ -23,13 +25,32 @@ class RoomObserver
public function updated(Room $room): void
{
// 檢查是否有變更狀態
- if ($room->wasChanged('status')) {
- RoomStatusLog::create([
- 'room_id' => $room->id,
- 'user_id' => Auth::id(), // 若是 console 或系統自動操作可能為 null
- 'status' => $room->status,
- 'message' => 'started_at:'.$room->started_at.',ended_at:'.$room->ended_at,
- ]);
+ if ($room->wasChanged()) {
+ $this->createStatusLog($room);
+ }
+ if ($room->isDirty('status')) {
+ $now = now();
+
+ // 找到最後一筆未結束 session
+ $lastSession = $room->sessions()->whereNull('ended_at')->latest('started_at')->first();
+
+ if ($lastSession) {
+ // 結束上一筆 session
+ $lastSession->update([
+ 'status' => $room->status->value,
+ 'ended_at' => $now,
+ ]);
+ }
+
+ // 如果狀態是 active 或 maintain,開新 session
+ if (in_array($room->status->value, ['active', 'maintain'])) {
+ $mode = $room->status->value === 'active' ? 'normal' : 'test';
+ $room->sessions()->create([
+ 'mode' => $mode,
+ 'status' => $room->status->value,
+ 'started_at' => $now,
+ ]);
+ }
}
}
@@ -38,22 +59,38 @@ class RoomObserver
*/
public function deleted(Room $room): void
{
- //
+ $message = sprintf(
+ "%s:%s%s (%s:%s) 已刪除",
+ $room->branch->name,
+ $room->type->value,
+ $room->name,
+ $room->internal_ip,
+ $room->port
+ );
+ $this->createStatusLog($room,$message);
+ }
+
+ /**
+ * 建立 RoomStatusLog
+ */
+ private function createStatusLog(Room $room,$log_message =null): void
+ {
+ $message=($log_message !=null)?$log_message:$room->log_message ?? '';
+ RoomStatusLog::create([
+ 'branch_id' => $room->branch->id,
+ 'room_id' => $room->id,
+ 'user_id' => Auth::id() ?? 0,
+ 'is_online' => $room->is_online,
+ 'status' => $room->status,
+ 'started_at' => $room->started_at,
+ 'ended_at' => $room->ended_at,
+ 'message' => $message,
+ 'source' => $this->getSource($room),
+ ]);
}
- /**
- * Handle the Room "restored" event.
- */
- public function restored(Room $room): void
+ private function getSource(Room $room): string
{
- //
- }
-
- /**
- * Handle the Room "force deleted" event.
- */
- public function forceDeleted(Room $room): void
- {
- //
+ return app()->runningInConsole() ? 'system' : ($room->log_source ?? 'manual');
}
}
diff --git a/database/migrations/2025_05_23_055312_create_room_status_logs_table.php b/database/migrations/2025_08_18_055312_create_room_status_logs_table.php
similarity index 55%
rename from database/migrations/2025_05_23_055312_create_room_status_logs_table.php
rename to database/migrations/2025_08_18_055312_create_room_status_logs_table.php
index a9f91b3..37bb00a 100644
--- a/database/migrations/2025_05_23_055312_create_room_status_logs_table.php
+++ b/database/migrations/2025_08_18_055312_create_room_status_logs_table.php
@@ -13,10 +13,19 @@ return new class extends Migration
{
Schema::create('room_status_logs', function (Blueprint $table) {
$table->id();
- $table->foreignId('room_id')->nullable();
- $table->foreignId('user_id')->nullable(); // 操作者,可為 null(系統)
+ $table->unsignedBigInteger('branch_id');
+ $table->unsignedBigInteger('room_id');
+ $table->unsignedBigInteger('user_id');
+ $table->tinyInteger('is_online')->default(0);
$table->enum('status', ['active', 'closed','fire', 'error', 'maintain']);
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('ended_at')->nullable();
$table->text('message')->nullable(); // 可填異常原因或操作說明
+ $table->enum('source', ['system', 'manual', 'api'])->default('manual');
+ $table->index('branch_id');
+ $table->index('room_id');
+ $table->index('user_id');
+ $table->index('started_at');
$table->timestamps();
});
}
diff --git a/database/migrations/2025_08_18_103818_create_room_sessions_table.php b/database/migrations/2025_08_18_103818_create_room_sessions_table.php
new file mode 100644
index 0000000..664ca15
--- /dev/null
+++ b/database/migrations/2025_08_18_103818_create_room_sessions_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('room_id')->constrained()->cascadeOnDelete();
+ $table->enum('status', ['active', 'closed','fire', 'error', 'maintain'])->default('error');
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('ended_at')->nullable();
+ $table->enum('mode', ['normal', 'test'])->default('normal');
+ $table->string('close_reason')->nullable();
+ $table->string('api_token', 64)->unique()->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('room_sessions');
+ }
+};
diff --git a/database/migrations/2025_08_18_104152_create_ordered_songs_table.php b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php
new file mode 100644
index 0000000..0fee13f
--- /dev/null
+++ b/database/migrations/2025_08_18_104152_create_ordered_songs_table.php
@@ -0,0 +1,45 @@
+id();
+ $table->foreignId('room_session_id')->constrained('room_sessions')->cascadeOnDelete();
+ $table->string('from_by')->nullable();
+ $table->integer('order_number')->default(0);
+
+ // 歌曲資訊
+ $table->unsignedBigInteger('song_id');
+ $table->string('song_name')->nullable();
+ $table->string('artist_name')->nullable();
+
+
+ // 狀態:未播 / 播放中 / 已播 / 刪除
+ $table->enum('status', ['NotPlayed', 'Playing', 'Played', 'NoFile', 'Skipped' , 'InsertPlayback'])->default('NotPlayed');
+
+ // 播放流程
+ $table->timestamp('ordered_at')->useCurrent(); // 點歌時間
+ $table->timestamp('started_at')->nullable(); // 開始播放時間
+ $table->timestamp('finished_at')->nullable(); // 播放結束時間
+
+ $table->unique(['room_session_id', 'order_number']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('ordered_songs');
+ }
+};
diff --git a/resources/lang/zh-tw/enums.php b/resources/lang/zh-tw/enums.php
index 2e06d66..9266a12 100644
--- a/resources/lang/zh-tw/enums.php
+++ b/resources/lang/zh-tw/enums.php
@@ -2,6 +2,12 @@
return [
'Unset' => '未定義',
+ 'NotPlayed' =>'未播放',
+ 'Playing' =>'播放中',
+ 'Played' =>'播畢',
+ 'NoFile' =>'無文件',
+ 'Skipped' =>'刪除',
+ 'InsertPlayback' =>'插播',
'user.gender.Male' =>'男',
'user.gender.Female' =>'女',
'user.gender.Other' =>'其他',
diff --git a/resources/lang/zh-tw/room-status-log.php b/resources/lang/zh-tw/room-status-log.php
index 40ae803..b875073 100644
--- a/resources/lang/zh-tw/room-status-log.php
+++ b/resources/lang/zh-tw/room-status-log.php
@@ -4,10 +4,17 @@ return [
'list' => '包廂狀態紀錄',
'id' => '編號',
+ 'branch' => '分店',
'room' => '包廂',
+ 'room_type' => '包廂類別',
+ 'room_name' => '包廂名稱',
'user' => '操成者',
+ 'is_online' => '在線?',
'status' => '狀態',
+ 'started_at' => '開始於',
+ 'ended_at' => '結束於',
'message' => '紀錄',
+ 'source' => '來源',
'created_at' => '建立於'
];
\ No newline at end of file
diff --git a/routes/api.php b/routes/api.php
index daa9010..a5e8d6a 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ArtistController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\RoomControlController;
+use App\Http\Controllers\Api\RoomSongController;
use App\Http\Controllers\SqliteUploadController;
Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']);
@@ -11,6 +12,14 @@ Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegi
Route::middleware('auth:sanctum')->group(function () {
Route::get('/profile', [AuthController::class, 'profile']);
Route::post('/room/sendSwitch', [RoomControlController::class, 'sendSwitch']);
- Route::post('/room/heartbeat', [RoomControlController::class, 'StatusReport']);
+ Route::post('/room/heartbeat', [RoomControlController::class, 'HeartBeat']);
+ Route::post('/room/session',[RoomControlController::class, 'session']);
+
+ Route::post('/room/order-song', [RoomSongController::class, 'orderSong']);
+ Route::get ('/room/ordered-songs', [RoomSongController::class, 'listOrderedSongs']);
+ Route::post ('/room/current-song', [RoomSongController::class, 'currentSong']);
+ Route::post ('/room/next-song', [RoomSongController::class, 'nextSong']);
+ Route::post ('/room/sync-order-song', [RoomSongController::class, 'syncOrderSong']);
+
Route::post('/upload-sqlite', [SqliteUploadController::class, 'upload']);
});
\ No newline at end of file