DB 滙入

User 介面 只能看資料不能做修改
Role 介面移除
Branch介面 只能看資料不能做修改
Room 介面 可操控 包廂開關台
Sqgger API 可操控API
Room 有操控異動記錄
machine_statuses 需做資料留存需留7 天
20250528
This commit is contained in:
allen.yan 2025-05-28 09:24:03 +08:00
parent dcb27b8c9c
commit 7c8c3fe69b
73 changed files with 2282 additions and 1323 deletions

View File

@ -3,8 +3,8 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=Asia/Taipei APP_TIMEZONE=Asia/Taipei
APP_URL=https://shop_12_wireui.test APP_URL=https://KTVCentral.test
L5_SWAGGER_CONST_HOST=https://shop_12_wireui.test/ L5_SWAGGER_CONST_HOST=https://KTVCentral.test/
APP_LOCALE=zh-tw APP_LOCALE=zh-tw
APP_FALLBACK_LOCALE=zh-tw APP_FALLBACK_LOCALE=zh-tw
@ -22,12 +22,13 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite #DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1 DB_CONNECTION=mariadb
# DB_PORT=3306 DB_HOST=127.0.0.1
# DB_DATABASE=laravel DB_PORT=3307
# DB_USERNAME=root DB_DATABASE=Karaoke-Kingpin_Central
# DB_PASSWORD= DB_USERNAME=Karaoke-Kingpin
DB_PASSWORD=ESM7yTPMnavFmbBH
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ClearMachineStatuses extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:clear-machine-statuses';
/**
* The console command description.
*
* @var string
*/
protected $description = '備份並清空 machine_statuses 表,每週保留一次';
/**
* Execute the console command.
*/
public function handle()
{
$day = now()->format('l'); // e.g. "Monday"
$targetTable = "machine_statuses_" . $day;
DB::statement("CREATE TABLE IF NOT EXISTS _machine_statuses LIKE machine_statuses");
// 先刪除舊表(如存在)
DB::statement("DROP TABLE IF EXISTS {$targetTable}");
// 改名備份
DB::statement("RENAME TABLE machine_statuses TO {$targetTable}");
// 空表回命名
DB::statement("RENAME TABLE _machine_statuses TO machine_statuses");
$this->info("Machine statuses backed up to {$targetTable} and table cleared.");
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use App\Jobs\TransferSqliteTableJob;
class TransferSqliteToMysql extends Command
{
protected $signature = 'transfer:sqlite
{path : SQLite 相對路徑sqlite/song.sqlite}
{--sync : 同步執行}';
protected $description = '將 SQLite 中的資料表轉移到 MySQL 資料庫中';
public function handle(): int
{
$start = now();
$path = ltrim($this->argument('path'), '/');
$fullPath = Storage::disk('local')->path($path);
$this->info("[Transfer] 開始轉移 SQLite 資料:{$fullPath}");
if (!file_exists($fullPath)) {
$this->error("[Transfer] 找不到 SQLite 檔案:{$fullPath}");
return 1;
}
try {
if ($this->option('sync')) {
$this->warn('[Transfer] 使用同步模式執行...');
(new TransferSqliteTableJob($fullPath))->handle();
$this->info('[Transfer] 匯出完成(同步)');
} else {
TransferSqliteTableJob::dispatch($fullPath);
$this->info('[Transfer] 匯出任務已派送至 queue');
}
$duration = now()->diffInSeconds($start);
$this->info("[Transfer] 執行完成,用時 {$duration}");
} catch (\Throwable $e) {
$this->error('[Transfer] 發生錯誤:' . $e->getMessage());
return 1;
}
return 0;
}
}

33
app/Enums/RoomStatus.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
use App\Enums\Traits\HasLabels;
/**
* @OA\Schema(
* schema="RoomStatus",
* type="string",
* enum={"active", "closed", "fire", "maintenance"},
* example="error"
* )
*/
enum RoomStatus: string {
use HasLabels;
case Active = 'active';
case Closed = 'closed';
case Fire ='fire';
case Error = 'error';
// 返回對應的顯示文字
public function labels(): string
{
return match($this) {
self::Active => __('enums.room.status.Active'),
self::Closed => __('enums.room.status.Closed'),
self::Fire => __('enums.room.status.Fire'),
self::Error => __('enums.room.status.Error'),
};
}
}

31
app/Enums/RoomType.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use App\Enums\Traits\HasLabels;
/**
* @OA\Schema(
* schema="RoomType",
* type="string",
* enum={"unset", "pc", "svr"},
* example="error"
* )
*/
enum RoomType: string {
use HasLabels;
case Unset = 'unset';
case PC = 'pc';
case SVR ='svr';
// 返回對應的顯示文字
public function labels(): string
{
return match($this) {
self::Unset => __('enums.room.status.Unset'),
self::PC => "PC",
self::SVR => "SVR",
};
}
}

View File

@ -4,6 +4,14 @@ namespace App\Enums;
use App\Enums\Traits\HasLabels; use App\Enums\Traits\HasLabels;
/**
* @OA\Schema(
* schema="UserGender",
* type="string",
* enum={"male", "female", "other", "unset"},
* example="male"
* )
*/
enum UserGender: string enum UserGender: string
{ {
use HasLabels; use HasLabels;

View File

@ -4,6 +4,15 @@ namespace App\Enums;
use App\Enums\Traits\HasLabels; use App\Enums\Traits\HasLabels;
/**
* @OA\Schema(
* schema="UserStatus",
* type="string",
* enum={"0", "1", "2"},
* description="User status: 0=Active, 1=Suspended, 2=Deleting",
* example="0"
* )
*/
enum UserStatus: int enum UserStatus: int
{ {
use HasLabels; use HasLabels;

View File

@ -0,0 +1,305 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SendRoomSwitchCommandRequest;
use App\Http\Requests\ReceiveRoomRegisterRequest;
use App\Http\Requests\ReceiveRoomStatusDataRequest;
use App\Services\TcpSocketClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use App\Models\Branch;
use App\Models\Room;
use App\Models\MachineStatus;
use App\Enums\RoomStatus;
use App\Http\Responses\ApiResponse;
use Illuminate\Support\Facades\Log;
/**
* @OA\Tag(
* name="Auth",
* description="包廂控制"
* )
*/
class RoomControlController extends Controller
{
/**
* @OA\Post(
* path="/api/room/receiveRegister",
* summary="包廂註冊控制指令",
* description="依據傳入的 branch_id 與 room_name知道過來的設備來之於那個IP設備。",
* operationId="registerRoomCommand",
* tags={"Room Control"},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ReceiveRoomRegisterRequest")
* ),
* @OA\Response(
* response=200,
* description="成功傳送指令並回傳 TCP 回應",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/Room")
* )
* }
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="code", type="string", example="UNAUTHORIZED"),
* @OA\Property(property="message", type="string", example="Unauthorized"),
* @OA\Property(property="data", type="null")
* )
* }
* )
* ),
* @OA\Parameter(
* name="Accept",
* in="header",
* required=true,
* @OA\Schema(type="string", default="application/json")
* )
* )
*/
public function receiveRegister(ReceiveRoomRegisterRequest $request): JsonResponse
{
$data = $request->only(['branch', 'room_name', 'room_ip', 'email']); // 不記錄密碼
Log::info('Token Request Payload:', $data);
// 1. 驗證帳密(登入用)
$credentials = $request->only('email', 'password');
if (!Auth::attempt($credentials)) {
return ApiResponse::unauthorized();
}
// 2. 取得登入使用者
$user = Auth::user();
// 3. 產生或取得 Token
if (empty($user->api_plain_token)) {
$token = $user->createToken('pc-heartbeat')->plainTextToken;
$user->api_plain_token = $token;
$user->save();
} else {
$token = $user->api_plain_token;
}
// 4. 驗證其他註冊欄位
$validated = $request->validated(); // branch_id, room_name, room_ip
// 5. 找出對應包廂
$roomType = null;
$roomName = null;
// 從 room_name例如 PC101, SVR01中擷取 type 與 name
if (preg_match('/^([A-Za-z]+)(\d+)$/', $validated['room_name'], $matches)) {
$roomType = strtolower($matches[1]); // 'PC' → 'pc'
$roomName = $matches[2]; // '101'
}
$branch=Branch::where('name',$validated['branch_name'])->first();
$room = Room::where('branch_id', $branch->id)
->where('name', $roomName)
->where('type', $roomType)
->first();
if (!$room) {
return ApiResponse::error('找不到對應包廂');
}
// 6. 更新包廂資訊
$room->internal_ip = $validated['room_ip'];
$room->port = 1000; // 預設值
$room->is_online =1;
$room->status = RoomStatus::Closed;
$room->touch(); // 更新 updated_at
$room->save();
// 7. 回傳 token 與包廂資料
return ApiResponse::success([
'token' => $token,
'room' => $room,
]);
}
/**
* @OA\Post(
* path="/api/room/heartbeat",
* summary="包廂心跳封包指令",
* description="記錄設備連線狀況",
* operationId="heartbeatRoomCommand",
* tags={"Room Control"},
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ReceiveRoomStatusDataRequest")
* ),
* @OA\Response(
* response=200,
* description="成功傳送指令並回傳 TCP 回應",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/MachineStatus")
* )
* }
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="code", type="string", example="UNAUTHORIZED"),
* @OA\Property(property="message", type="string", example="Unauthorized"),
* @OA\Property(property="data", type="null")
* )
* }
* )
* ),
* @OA\Parameter(
* name="Accept",
* in="header",
* required=true,
* @OA\Schema(type="string", default="application/json")
* )
* )
*/
public function StatusReport(ReceiveRoomStatusDataRequest $request)
{
$validated = $request->validated();
$roomType = null;
$roomName = 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'
}
$branch=Branch::where('name',$validated['branch_name'])->first();
$room = Room::where('branch_id', $branch->id)
->where('name', $roomName)
->where('type', $roomType)
->first();
// 決定 status 欄位值
$validated['status']= 'error';
if($room){
$validated['status']= 'online';
if($room->internal_ip != $validated['ip']){
$room->internal_ip = $validated['ip'];
$validated['status']='error';
}
$room->is_online=1;
$room->touch(); // 更新 updated_at
$room->save();
}
return ApiResponse::success([
'data' => MachineStatus::create($validated),
]);
}
/**
* @OA\Post(
* path="/api/room/sendSwitch",
* summary="送出包廂控制指令",
* description="依據傳入的 room_id 與 command透過 TCP 傳送對應指令給包廂電腦。",
* operationId="sendRoomSwitchCommand",
* tags={"Room Control"},
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/SendRoomSwitchCommandRequest")
* ),
* @OA\Response(
* response=200,
* description="成功傳送指令並回傳 TCP 回應",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/Room")
* )
* }
* )
* ),
* @OA\Response(
* response=401,
* description="Unauthorized",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="code", type="string", example="UNAUTHORIZED"),
* @OA\Property(property="message", type="string", example="Unauthorized"),
* @OA\Property(property="data", type="null")
* )
* }
* )
* ),
* @OA\Parameter(
* name="Accept",
* in="header",
* required=true,
* @OA\Schema(type="string", default="application/json")
* )
* )
*/
public function sendSwitch(SendRoomSwitchCommandRequest $request): JsonResponse
{
$validated = $request->validated();
$room = Room::where([
['branch_id', $validated['branch_id']],
['name', $validated['room_name']],
])->first();
if (!$room) {
return ApiResponse::error('房間不存在');
}
// 檢查必要欄位是否缺失或狀態為錯誤
if (empty($room->internal_ip) || empty($room->port)) {
return ApiResponse::error('房間未設定 IP 或 Port');
}
if ($room->status === RoomStatus::Error) {
return ApiResponse::error('房間目前處於錯誤狀態,無法操作');
}
$suffix = substr($room->name, -3) ?: $room->name;
$signal = match ($validated['command']) {
'active' => 'O',
'closed' => 'X',
'fire' => 'F',
default => 'X', // fallback 保險起見
};
$data = $suffix . "," . $signal;
//dd($data);
$client = new TcpSocketClient($room->internal_ip, $room->port);
try {
$response = $client->send($data);
$room->status=$validated['command'];
$room->started_at=$validated['started_at'];
$room->ended_at=$validated['ended_at'];
$room->save();
return ApiResponse::success($room);
} catch (\Throwable $e) {
$room->status=RoomStatus::Error;
$room->started_at=null;
$room->ended_at=null;
$room->save();
return ApiResponse::error($e->getMessage());
}
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage;
use App\Jobs\TransferSqliteTableJob;
/**
* @OA\Tag(
* name="SqliteUpload",
* description="Sqlite 檔案上傳"
* )
*/
class SqliteUploadController extends Controller
{
/**
* 上傳 Sqlite 檔案
*
* @OA\Post(
* path="/api/upload-sqlite",
* tags={"SqliteUpload"},
* summary="上傳 SQLite 檔案",
* description="接收一個 SQLite 檔案並儲存至 storage/app/sqlite/,並派送資料轉移任務",
* operationId="uploadSqlite",
* security={{"Authorization":{}}},
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"file"},
* @OA\Property(
* property="file",
* type="string",
* format="binary",
* description="要上傳的 SQLite 檔案"
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="上傳成功"),
* @OA\Property(property="path", type="string", example="sqlite/tempUser.sqlite")
* )
* ),
* @OA\Response(
* response=422,
* description="驗證錯誤"
* )
* )
*/
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file',
]);
if ($request->file('file')->getClientOriginalExtension() !== 'sqlite') {
return response()->json(['message' => '只允許上傳 .sqlite 檔案'], 422);
}
$filename = $request->file('file')->getClientOriginalName();
$path = $request->file('file')->storeAs('sqlite', $filename, 'local');
TransferSqliteTableJob::dispatch(Storage::disk('local')->path($path));
return response()->json([
'message' => '上傳成功,已派送資料處理任務',
'path' => $path,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\Traits\FailedValidationJsonResponse;
class ApiRequest extends FormRequest
{
use FailedValidationJsonResponse;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array{
return [];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="ReceiveRoomRegisterRequest",
* required={"branch", "room_name", "email" ,"password"},
* @OA\Property(property="branch_name", type="string", example="測試"),
* @OA\Property(property="room_name", type="string", example="PC101"),
* @OA\Property(property="room_ip", type="string", example="192.168.1.1"),
* @OA\Property(property="email", type="string", example="XX@gmail.com"),
* @OA\Property(property="password", type="string", example="XXX"),
* )
*/
class ReceiveRoomRegisterRequest extends ApiRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'branch_name' =>'required|string|exists:branches,name',
'room_name' => 'required|string',
'room_ip' => 'required|string',
'email' => 'required|email',
'password' => 'required',
];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="ReceiveRoomStatusDataRequest",
* required={"branch_name","hostname", "ip", "status"},
* @OA\Property(property="branch_name", type="string", example="測試"),
* @OA\Property(property="hostname", type="string", example="PC101"),
* @OA\Property(property="ip", type="string", example="192.168.XX.XX"),
* @OA\Property(property="cpu", type="numeric", example="0.00"),
* @OA\Property(property="memory", type="numeric", example="25603"),
* @OA\Property(property="disk", type="numeric", example="158266.49"),
* )
*/
class ReceiveRoomStatusDataRequest extends ApiRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'branch_name' =>'required|string|exists:branches,name',
'hostname' => 'required|string',
'ip' => 'required|string',
'cpu' => 'nullable|numeric',
'memory' => 'nullable|numeric',
'disk' => 'nullable|numeric',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @OA\Schema(
* schema="SendRoomSwitchCommandRequest",
* required={"branch_id", "room_name", "command"},
* @OA\Property(property="branch_id", type="integer", example="5"),
* @OA\Property(property="room_name", type="string", example="pc102"),
* @OA\Property(property="command", type="string", enum={"active", "closed", "fire", "maintenance"}, example="active"),
* @OA\Property(property="started_at", type="string", nullable=true, example="2025-05-19 09:31:00"),
* @OA\Property(property="ended_at", type="string", nullable=true, example="2025-05-19 09:31:00")
* )
*/
class SendRoomSwitchCommandRequest extends ApiRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'branch_id' => 'required|integer',
'room_name' => 'required|string',
'command' => 'required|string',
'started_at' => 'nullable|date_format:Y-m-d H:i:s',
'ended_at' => 'nullable|date_format:Y-m-d H:i:s',
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Traits;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Response;
trait FailedValidationJsonResponse
{
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
'code' => 'ERROR',
'token' => ''
], Response::HTTP_UNPROCESSABLE_ENTITY));
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* @OA\Schema(
* schema="ApiResponse",
* type="object",
* @OA\Property(property="code", type="string", example="OK"),
* @OA\Property(property="message", type="string", example="Success"),
* @OA\Property(property="data", type="object", nullable=true)
* )
*/
class ApiResponse
{
public static function success($data = null, string $message = 'Success', string $code = 'OK', int $status = Response::HTTP_OK): JsonResponse
{
return self::respond($code, $message, $data, $status);
}
public static function error(string $message = 'Error', string $code = 'ERROR', int $status = Response::HTTP_BAD_REQUEST): JsonResponse
{
return self::respond($code, $message, null, $status);
}
public static function unauthorized(string $message = 'Unauthorized'): JsonResponse
{
return self::error($message, 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED);
}
private static function respond(string $code, string $message, $data, int $status): JsonResponse
{
return response()->json(['code' => $code,'message' => $message,'data' => $data,], $status);
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace App\Imports;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
use Illuminate\Support\Facades\Log;
use App\Jobs\ImportUserChunkJob;
class DataImport implements ToCollection, WithHeadingRow, WithChunkReading
{
protected int $con=0;
protected string $modelName;
public function __construct(string $modelName)
{
HeadingRowFormatter::default('none');
$this->modelName= $modelName;
}
public function collection(Collection $rows)
{
Log::warning('匯入啟動', [
'model' => $this->modelName,
'rows_id' =>++$this->con,
'rows_count' => $rows->count()
]);
if($this->modelName=='User'){
ImportUserChunkJob::dispatch($rows,$this->con);
}else{
Log::warning('未知的 modelName', ['model' => $this->modelName]);
}
}
public function chunkSize(): int
{
return 1000;
}
public function headingRow(): int
{
return 1;
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Log;
use App\Imports\DataImport;
class ImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected string $modelName;
protected string $filePath;
public $timeout = 3600;
public $tries = 1;
/**
* Create a new job instance.
*/
public function __construct(string $filePath,string $modelName)
{
$this->filePath = $filePath;
$this->modelName= $modelName;
}
/**
* Execute the job.
*/
public function handle(): void
{
ini_set('memory_limit', '512M'); // ✅ 增加記憶體限制
Log::info('[ImportJob] 開始處理檔案:' . $this->filePath);
try {
if (!Storage::exists($this->filePath)) {
Log::warning('[ImportJob] 檔案不存在:' . $this->filePath);
return;
}
Excel::import(new DataImport($this->modelName), $this->filePath);
Log::info('[ImportJob] 已提交所有 chunk 匯入任務。');
Storage::delete($this->filePath);
Log::info('[ImportJob] 已刪除檔案:' . $this->filePath);
} catch (\Throwable $e) {
Log::error("[ImportJob] 匯入失敗:{$e->getMessage()}", [
'file' => $this->filePath,
'trace' => $e->getTraceAsString(),
]);
}
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class ImportUserChunkJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected Collection $rows;
protected String $id;
public function __construct(Collection $rows,String $id)
{
$this->rows = $rows;
$this->id = $id;
}
public function handle(): void
{
Log::warning('匯入啟動', [
'model' => "ImportUserChunkJob",
'rows_id' =>$this->id,
]);
$now = now();
foreach ($this->rows as $index => $row) {
try {
$name = $this->normalizeName($row['歌手姓名'] ?? '');
if (empty($name) || User::where('name', $name)->exists()) {
continue;
}
// 準備 song 資料
$toInsert[] = [
'name' => $name,
'category' => ArtistCategory::tryFrom(trim($row['歌手分類'] ?? '未定義')) ?? ArtistCategory::Unset,
'simplified' => $simplified,
'phonetic_abbr' => $phoneticAbbr,
'pinyin_abbr' => $pinyinAbbr,
'strokes_abbr' => $strokesAbbr,
'enable' =>trim($row['狀態'] ?? 1),
'created_at' => $now,
'updated_at' => $now,
];
} catch (\Throwable $e) {
\Log::error("Row {$index} failed: {$e->getMessage()}", [
'row' => $row,
'trace' => $e->getTraceAsString()
]);
}
}
User::insert($toInsert);
}
public function normalizeName(?string $str): string
{
return strtoupper(mb_convert_kana(trim($str ?? ''), 'as'));
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class TransferSqliteTableJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected string $sqlitePath;
public function __construct(string $sqlitePath){
$this->sqlitePath=$sqlitePath;
}
public function handle(): void
{
if (!file_exists($this->sqlitePath)) {
logger()->error("❌ SQLite file not found: {$this->sqlitePath}");
return;
}
// ✅ 動態產生唯一 connection 名稱
$connectionName = 'tempsqlite_' . md5($this->sqlitePath . microtime());
config(["database.connections.{$connectionName}" => [
'driver' => 'sqlite',
'database' => $this->sqlitePath,
'prefix' => '',
]]);
$mysqlConnection = config('database.default');
$sqliteTables = DB::connection($connectionName)->select("
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%';
");
if (empty($sqliteTables)) {
logger()->error("❌ No tables found in SQLite database.");
return;
}
foreach ($sqliteTables as $tableObj) {
$table = $tableObj->name;
if ($table === 'migrations') {
continue;
}
try {
DB::connection($mysqlConnection)->statement('SET FOREIGN_KEY_CHECKS=0;');
DB::statement("CREATE TABLE IF NOT EXISTS _{$table} LIKE {$table}");
$rows = DB::connection($connectionName)->table($table)->cursor();
$buffer = [];
$count = 0;
foreach ($rows as $row) {
$buffer[] = (array) $row;
$count++;
if ($count % 500 === 0) {
DB::connection($mysqlConnection)->table("_" . $table)->insert($buffer);
$buffer = [];
}
}
if (!empty($buffer)) {
DB::connection($mysqlConnection)->table("_" . $table)->insert($buffer);
}
DB::statement("RENAME TABLE {$table} TO {$table}_");
DB::statement("RENAME TABLE _{$table} TO {$table}");
DB::statement("DROP TABLE IF EXISTS {$table}_");
logger()->info("✅ Done: {$table} ({$count} records)");
} catch (\Exception $e) {
logger()->error("❌ Failed to transfer {$table}: " . $e->getMessage());
} finally {
DB::connection($mysqlConnection)->statement('SET FOREIGN_KEY_CHECKS=1;');
}
}
// 🔥 結束後刪檔與釋放 connection
DB::purge($connectionName);
if (file_exists($this->sqlitePath)) {
sleep(1);
unlink($this->sqlitePath);
logger()->info("🧹 Temp SQLite file deleted: {$this->sqlitePath}");
}
logger()->info("🎉 All tables transferred.");
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Branch;
use App\Jobs\ExportSqliteBranchJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
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 BranchTable extends PowerGridComponent
{
use WithExport, WireUiActions;
public string $tableName = 'branch-table';
public bool $showFilters = false;
public function boot(): void
{
config(['livewire-powergrid.filter' => 'outside']);
}
public function setUp(): array
{
$actions = [];
$header = PowerGrid::header()
->withoutLoading()
->showToggleColumns();
$header->includeViewOnTop('livewire.admin.branch-header') ;
$actions[]=$header;
$actions[]=PowerGrid::footer()->showPerPage()->showRecordCount();
return $actions;
}
public function datasource(): Builder
{
return Branch::query();
}
public function relationSearch(): array
{
return [];
}
public function fields(): PowerGridFields
{
return PowerGrid::fields()
->add('id')
->add('name')
->add('external_ip')
->add('enable')
->add('created_at_formatted', fn (Branch $model) => Carbon::parse($model->created_at)->format('d/m/Y H:i:s'));
}
public function columns(): array
{
$column=[];
$column[]=Column::make(__('branches.no'), 'id');
$column[]=Column::make(__('branches.name'), 'name')->sortable()->searchable();
$column[]=Column::make(__('branches.external_ip'), 'external_ip')->sortable()->searchable();
$column[]=Column::make(__('branches.enable'), 'enable');
$column[]=Column::make('Created at', 'created_at_formatted', 'created_at')->sortable()->hidden(true, false);
return $column;
}
public function filters(): array
{
return [
Filter::inputText('name')->placeholder(__('branches.name')),
Filter::inputText('external_ip')->placeholder(__('branches.external_ip')),
Filter::boolean('enable')->label('✅', '❌'),
Filter::datetimepicker('created_at'),
];
}
}

View File

@ -1,114 +0,0 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RoleForm extends Component
{
use WireUiActions;
protected $listeners = ['openCreateRoleModal','openEditRoleModal', 'deleteRole'];
public bool $canCreate;
public bool $canEdit;
public bool $canDelect;
public $showCreateModal=false;
public ?int $roleId = null;
public $name = '';
public $permissions = []; // 所有權限清單
public $selectedPermissions = []; // 表單中選到的權限
public function mount()
{
$this->permissions = Permission::all();
$this->canCreate = Auth::user()?->can('role-edit') ?? false;
$this->canEdit = Auth::user()?->can('role-edit') ?? false;
$this->canDelect = Auth::user()?->can('role-delete') ?? false;
}
public function openCreateRoleModal()
{
$this->resetFields();
$this->showCreateModal = true;
}
public function openEditRoleModal($id)
{
$role = Role::findOrFail($id);
$this->roleId = $role->id;
$this->name = $role->name;
$this->selectedPermissions = $role->permissions()->pluck('id')->toArray();
$this->showCreateModal = true;
}
public function save()
{
$this->validate([
'name' => 'required|string|max:255',
'selectedPermissions' => 'array',
]);
if ($this->roleId) {
if ($this->canEdit) {
$role = Role::findOrFail($this->roleId);
$role->update(['name' => $this->name]);
$role->syncPermissions($this->selectedPermissions);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '角色已更新',
]);
}
} else {
if ($this->canCreate) {
$role = Role::create(['name' => $this->name]);
$role->syncPermissions($this->selectedPermissions);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '角色已新增',
]);
}
}
$this->resetFields();
$this->showCreateModal = false;
$this->dispatch('pg:eventRefresh-role-table');
}
public function deleteRole($id)
{
if ($this->canDelect) {
Role::findOrFail($id)->delete();
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '角色已刪除',
]);
$this->dispatch('pg:eventRefresh-role-table');
}
}
public function resetFields()
{
$this->name = '';
$this->selectedPermissions = [];
$this->roleId = null;
}
public function render()
{
return view('livewire.admin.role-form');
}
}

View File

@ -1,181 +0,0 @@
<?php
namespace App\Livewire\Admin;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
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 Livewire\Attributes\On;
use WireUi\Traits\WireUiActions;
final class RoleTable extends PowerGridComponent
{
use WireUiActions;
public string $tableName = 'role-table';
public bool $canCreate;
public bool $canEdit;
public bool $canDelect;
public function boot(): void
{
config(['livewire-powergrid.filter' => 'outside']);
//權限設定
$this->canCreate = Auth::user()?->can('role-edit') ?? false;
$this->canEdit = Auth::user()?->can('role-edit') ?? false;
$this->canDelect = Auth::user()?->can('role-delete') ?? false;
}
public function setUp(): array
{
if($this->canDelect){
$this->showCheckBox();
}
$actions = [];
$header =PowerGrid::header();
if($this->canCreate){
$header->includeViewOnTop('livewire.admin.role-header');
}
$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
{
//dd(Role::with('permissions'));
return Role::with('permissions');
}
public function relationSearch(): array
{
return [];
}
public function fields(): PowerGridFields
{
$allPermissions = Permission::pluck('name')->sort()->values();
return PowerGrid::fields()
->add('id')
->add('name')
->add('permissions_list', function (Role $model) use ($allPermissions) {
$rolePermissions = $model->permissions->pluck('name')->sort()->values();
if ($rolePermissions->count() === $allPermissions->count() && $rolePermissions->values()->all() === $allPermissions->values()->all()) {
return 'all';
}
return $rolePermissions->implode(', ');
})
->add('created_at_formatted', fn (Role $model) => Carbon::parse($model->created_at)->format('Y-m-d H:i:s'));
}
public function columns(): array
{
$column=[];
$column[]=Column::make(__('roles.no'), 'id')->sortable()->searchable();
$column[]=Column::make(__('roles.name'), 'name')->sortable()->searchable()
->editOnClick(
hasPermission: $this->canEdit,
dataField: 'name',
fallback: 'N/A',
saveOnMouseOut: true
);
$column[]=Column::make(__('roles.permissions'), 'permissions_list');
$column[]=Column::make('Created at', 'created_at_formatted', 'created_at')->sortable();
$column[]=Column::action('Action');
return $column;
}
#[On('bulkDelete.{tableName}')]
public function bulkDelete(): void
{
if ($this->canDelect) {
$this->js('alert(window.pgBulkActions.get(\'' . $this->tableName . '\'))');
if($this->checkboxValues){
Role::destroy($this->checkboxValues);
$this->js('window.pgBulkActions.clearAll()'); // clear the count on the interface.
}
}
}
#[On('onUpdatedEditable')]
public function onUpdatedEditable($id, $field, $value): void
{
if ($field === 'name' && $this->canEdit) {
$this->noUpdated($id,$field,$value);
}
}
private function noUpdated($id,$field,$value){
$role = Role::find($id);
if ($role) {
$role->{$field} = $value;
$role->save(); // 明確觸發 saving
}
$this->notification()->send([
'icon' => 'success',
'title' => $id.'.'.__('roles.'.$field).':'.$value,
'description' => '已經寫入',
]);
}
public function filters(): array
{
return [
Filter::inputText('name')->placeholder(__('roles.name')),
Filter::datetimepicker('created_at'),
];
}
public function actions(Role $row): array
{
$actions = [];
if ($this->canEdit) {
$actions[] =Button::add('edit')
->slot(__('roles.edit'))
->icon('solid-pencil-square')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('admin.role-form', 'openEditRoleModal', ['id' => $row->id]);
}
if($this->canDelect){
$actions[] =Button::add('delete')
->slot(__('roles.delete'))
->icon('solid-trash')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('admin.role-form', 'deleteRole', ['id' => $row->id]);
}
return $actions;
}
/*
public function actionRules($row): array
{
return [
// Hide button edit for ID 1
Rule::button('edit')
->when(fn($row) => $row->id === 1)
->hide(),
];
}
*/
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Room;
use Livewire\Component;
use App\Services\ApiClient;
use Illuminate\Support\Facades\Auth;
use WireUi\Traits\WireUiActions;
class RoomDetailModal extends Component
{
use WireUiActions;
protected $listeners = [
'openModal', 'closeModal',
'startNotify', 'stopNotify', 'fireNotify',
'openAccountNotify','closeAccountNotify'
];
public $room;
public bool $showModal = false;
public function openModal($roomId)
{
$this->room = Room::find($roomId);
$this->showModal = true;
}
public function closeModal()
{
$this->showModal = false;
}
public function startNotify()
{
$data = $this->buildNotifyData('active', now(), null);
$this->send($data);
}
public function stopNotify()
{
$data = $this->buildNotifyData('closed', null, null);
$chk =$this->send($data);
}
public function fireNotify()
{
$data = $this->buildNotifyData('fire', null, null);
$this->send($data);
}
public function openAccountNotify()
{
$data = $this->buildNotifyData('active', now(), null);
$this->send($data);
}
public function closeAccountNotify()
{
$data = $this->buildNotifyData('closed', now(), null);
$this->send($data);
}
protected function buildNotifyData(string $command, $startedAt = null, $endedAt = null): array
{
return [
'branch_id' => $this->room->branch_id ?? 0,
'room_name' => $this->room->name ?? '',
'command' => $command,
'started_at' => $startedAt ? $startedAt->toDateTimeString() : null,
'ended_at' => $endedAt ? $endedAt->toDateTimeString() : null,
];
}
function send(array $data){
$user = Auth::user();
$token = $user->api_plain_token ?? null;
if (!$token) {
$this->sendErrorNotification('api', 'API token is missing.');
return false;
}
$apiClient = new ApiClient();
$response = $apiClient->setToken($token)->post('/room/sendSwitch', $data);
if ($response->failed()) {
$this->sendErrorNotification('api', 'API request failed: ' . $response->body());
return false;
}
// ✅ 成功提示
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '命令已成功發送:' . $data['command'],
]);
// ✅ 關閉 modal
$this->showModal = false;
return true;
}
public function sendErrorNotification(string $title = '錯誤', string $description = '發生未知錯誤')
{
$this->notification()->send([
'icon' => 'error',
'title' => $title,
'description' =>$description,
]);
}
public function render()
{
return view('livewire.admin.room-detail-modal');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Room;
use App\Models\Branch;
use App\Enums\RoomType;
use Livewire\Component;
use Illuminate\Database\Eloquent\Collection;
class RoomGrid extends Component
{
public $branchName="";
public array $roomTypes;
public function mount()
{
$this->roomTypes = ['all' => '全部'] + collect(RoomType::cases())->mapWithKeys(fn($e) => [$e->value => $e->labels()])->toArray();
}
public function render()
{
$branch = Branch::first();
$this->branchName = $branch->name ?? '';
$rooms = collect(); // 預設為空集合
$floors = [];
if ($branch) {
$rooms = Room::where('branch_id', $branch->id)->get();
$floors = $rooms->pluck('floor')->unique()->sort()->values()->toArray();
}
return view('livewire.admin.room-grid', [
'rooms' => $rooms,
'floors' => $floors,
]);
}
}

View File

@ -1,153 +0,0 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
use App\Models\User;
use App\Enums\UserGender;
use App\Enums\UserStatus;
use Spatie\Permission\Models\Role;
class UserForm extends Component
{
use WireUiActions;
protected $listeners = ['openModal','closeModal', 'deleteUser'];
public bool $canCreate;
public bool $canEdit;
public bool $canDelect;
public bool $showModal = false;
public array $genderOptions =[];
public array $statusOptions =[];
public $rolesOptions = []; // 所有角色清單
public $selectedRoles = []; // 表單中選到的權限
public ?int $userId = null;
public array $fields = [
'name' =>'',
'email' => '',
'phone' => '',
'birthday' => '',
'gender' => 'unset',
'status' => 0,
];
protected $rules = [
'fields.name' => 'required|string|max:255',
'fields.email' => 'required|string|email|max:255',
'fields.phone' => 'nullable|regex:/^09\d{8}$/',
'fields.birthday' =>'nullable|date',
'fields.gender' => 'required|in:male,female,other,unset',
'fields.status' => 'required|integer|in:0,1,2',
];
public function mount()
{
$this->fields['birthday'] = now()->toDateString();
$this->genderOptions = collect(UserGender::cases())->map(fn ($gender) => [
'name' => $gender->labels(),
'value' => $gender->value,
])->toArray();
$this->statusOptions = collect(UserStatus::cases())->map(fn ($status) => [
'name' => $status->labels(),
'value' => $status->value,
])->toArray();
$this->rolesOptions = Role::all();
$this->canCreate = Auth::user()?->can('user-edit') ?? false;
$this->canEdit = Auth::user()?->can('user-edit') ?? false;
$this->canDelect = Auth::user()?->can('user-delete') ?? false;
}
public function openModal($id = null)
{
$this->resetFields();
if($id){
$obj = User::findOrFail($id);
$this->userId = $obj->id;
$this->fields = $obj->only(array_keys($this->fields));
$this->selectedRoles = $obj->roles()->pluck('id')->toArray();
}
$this->showModal = true;
}
public function closeModal()
{
$this->resetFields();
$this->showModal = false;
}
public function save()
{
//$this->validate();
if ($this->userId) {
if ($this->canEdit) {
$obj = User::findOrFail($this->userId);
$obj->update($this->fields);
$obj->syncRoles($this->selectedRoles);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '使用者已更新',
]);
}
} else {
if ($this->canCreate) {
$obj = User::create($this->fields);
$obj->syncRoles($this->selectedRoles);
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '使用者已新增',
]);
}
}
$this->resetFields();
$this->showModal = false;
$this->dispatch('pg:eventRefresh-user-table');
}
public function deleteUser($id)
{
if ($this->canDelect) {
User::findOrFail($id)->delete();
$this->notification()->send([
'icon' => 'success',
'title' => '成功',
'description' => '使用者已刪除',
]);
$this->dispatch('pg:eventRefresh-user-table');
}
}
public function resetFields()
{
foreach ($this->fields as $key => $value) {
if ($key == 'gender') {
$this->fields[$key] = 'unset';
} elseif ($key == 'status') {
$this->fields[$key] = 0;
} elseif ($key == 'birthday') {
$this->fields[$key] = now()->toDateString();
} else {
$this->fields[$key] = '';
}
}
$this->userId = null;
$this->selectedRoles = [];
}
public function render()
{
return view('livewire.admin.user-form');
}
}

View File

@ -1,114 +0,0 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
use WireUi\Traits\WireUiActions;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Jobs\ImportJob;
class UserImportData extends Component
{
use WithFileUploads, WireUiActions;
protected $listeners = ['openModal','closeModal'];
public bool $canCreate;
public bool $showModal = false;
public $file;
public string $maxUploadSize;
public function mount()
{
$this->canCreate = Auth::user()?->can('user-edit') ?? false;
$this->maxUploadSize = $this->getMaxUploadSize();
}
public function openModal()
{
$this->showModal = true;
}
public function closeModal()
{
$this->deleteTmpFile(); // 關閉 modal 時刪除暫存檔案
$this->reset(['file']);
$this->showModal = false;
}
public function import()
{
// 檢查檔案是否有上傳
$this->validate([
'file' => 'required|file|mimes:csv,xlsx,xls'
]);
if ($this->canCreate) {
// 儲存檔案至 storage
$path = $this->file->storeAs('imports', uniqid() . '_' . $this->file->getClientOriginalName());
// 丟到 queue 執行
ImportJob::dispatch($path,'User');
$this->notification()->send([
'icon' => 'info',
'title' => $this->file->getClientOriginalName(),
'description' => '已排入背景匯入作業,請稍候查看結果',
]);
$this->deleteTmpFile(); // 匯入後也順便刪除 tmp 檔
$this->reset(['file']);
$this->showModal = false;
}
}
protected function deleteTmpFile()
{
if($this->file!=null){
$Path = $this->file->getRealPath();
if ($Path && File::exists($Path)) {
File::delete($Path);
}
}
}
private function getMaxUploadSize(): string
{
$uploadMax = $this->convertPHPSizeToBytes(ini_get('upload_max_filesize'));
$postMax = $this->convertPHPSizeToBytes(ini_get('post_max_size'));
$max = min($uploadMax, $postMax);
return $this->humanFileSize($max);
}
private function convertPHPSizeToBytes(string $s): int
{
$s = trim($s);
$unit = strtolower($s[strlen($s) - 1]);
$bytes = (int) $s;
switch ($unit) {
case 'g':
$bytes *= 1024;
case 'm':
$bytes *= 1024;
case 'k':
$bytes *= 1024;
}
return $bytes;
}
private function humanFileSize(int $bytes, int $decimals = 2): string
{
$sizes = ['B', 'KB', 'MB', 'GB'];
$factor = floor((strlen((string) $bytes) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . $sizes[$factor];
}
public function render()
{
return view('livewire.admin.user-import-data');
}
}

View File

@ -6,73 +6,35 @@ use App\Models\User;
use App\Enums\UserGender; use App\Enums\UserGender;
use App\Enums\UserStatus; use App\Enums\UserStatus;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use PowerComponents\LivewirePowerGrid\Button;
use PowerComponents\LivewirePowerGrid\Column; use PowerComponents\LivewirePowerGrid\Column;
use PowerComponents\LivewirePowerGrid\Facades\Filter; use PowerComponents\LivewirePowerGrid\Facades\Filter;
use PowerComponents\LivewirePowerGrid\Facades\PowerGrid; use PowerComponents\LivewirePowerGrid\Facades\PowerGrid;
use PowerComponents\LivewirePowerGrid\PowerGridFields; use PowerComponents\LivewirePowerGrid\PowerGridFields;
use PowerComponents\LivewirePowerGrid\PowerGridComponent; 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 UserTable extends PowerGridComponent final class UserTable extends PowerGridComponent
{ {
use WithExport, WireUiActions;
public string $tableName = 'user-table'; public string $tableName = 'user-table';
public bool $showFilters = false; public bool $showFilters = false;
public bool $canCreate;
public bool $canEdit;
public bool $canDownload;
public bool $canDelect;
public function boot(): void public function boot(): void
{ {
config(['livewire-powergrid.filter' => 'outside']); config(['livewire-powergrid.filter' => 'outside']);
//權限設定
$this->canCreate = Auth::user()?->can('user-edit') ?? false;
$this->canEdit = Auth::user()?->can('user-edit') ?? false;
$this->canDownload=Auth::user()?->can('user-delete') ?? false;
$this->canDelect = Auth::user()?->can('user-delete') ?? false;
} }
public function setUp(): array public function setUp(): array
{ {
if($this->canDownload || $this->canDelect){
$this->showCheckBox();
}
$actions = []; $actions = [];
$actions[] =PowerGrid::exportable(fileName: $this->tableName.'-file')
->type(Exportable::TYPE_XLS, Exportable::TYPE_CSV);
$header = PowerGrid::header() $header = PowerGrid::header()
->showToggleColumns(); ->showToggleColumns();
if($this->canCreate){
$header->includeViewOnTop('livewire.admin.user-header'); $header->includeViewOnTop('livewire.admin.user-header');
}
$actions[]=$header; $actions[]=$header;
$actions[]=PowerGrid::footer()->showPerPage()->showRecordCount(); $actions[]=PowerGrid::footer()->showPerPage()->showRecordCount();
return $actions; 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 public function datasource(): Builder
{ {
@ -93,36 +55,10 @@ final class UserTable extends PowerGridComponent
->add('phone') ->add('phone')
->add('birthday_formatted',fn (User $model) => Carbon::parse($model->birthday)->format('Y-m-d')) ->add('birthday_formatted',fn (User $model) => Carbon::parse($model->birthday)->format('Y-m-d'))
->add('gender_str', function (User $model) { ->add('gender_str', function (User $model) {
if ($this->canEdit) { return $model->gender->labelPowergridFilter();
return Blade::render(
'<x-select-category type="occurrence" :options=$options :modelId=$modelId :fieldName=$fieldName :selected=$selected/>',
[
'options' => UserGender::options(),
'modelId' => intval($model->id),
'fieldName'=>'gender',
'selected' => $model->gender->value
]
);
}
// 沒有權限就顯示對應的文字
return $model->gender->labelPowergridFilter(); // 假設 label() 會回傳顯示文字
} ) } )
->add('status_str', function (User $model) { ->add('status_str', function (User $model) {
if ($this->canEdit) { return $model->status->labelPowergridFilter();
return Blade::render(
'<x-select-category type="occurrence" :options=$options :modelId=$modelId :fieldName=$fieldName :selected=$selected/>',
[
'options' => UserStatus::options(),
'modelId' => intval($model->id),
'fieldName'=>'status',
'selected' => $model->status->value
]
);
}
// 沒有權限就顯示對應的文字
return $model->status->labelPowergridFilter(); // 假設 label() 會回傳顯示文字
} ) } )
->add('roles' ,fn(User $model)=> $model->roles->pluck('name')->implode(', ')) ->add('roles' ,fn(User $model)=> $model->roles->pluck('name')->implode(', '))
->add('created_at_formatted', fn (User $model) => Carbon::parse($model->created_at)->format('Y-m-d H:i:s')); ->add('created_at_formatted', fn (User $model) => Carbon::parse($model->created_at)->format('Y-m-d H:i:s'));
@ -134,85 +70,22 @@ final class UserTable extends PowerGridComponent
Column::make('ID', 'id'), Column::make('ID', 'id'),
Column::make(__('users.name'), 'name') Column::make(__('users.name'), 'name')
->sortable() ->sortable()
->searchable() ->searchable(),
->editOnClick(
hasPermission: $this->canEdit,
dataField: 'name',
fallback: 'N/A',
saveOnMouseOut: true
),
Column::make('Email', 'email') Column::make('Email', 'email')
->sortable() ->sortable()
->searchable() ->searchable(),
->editOnClick(
hasPermission: $this->canEdit,
dataField: 'email',
fallback: 'N/A',
saveOnMouseOut: true
),
Column::make(__('users.phone'), 'phone') Column::make(__('users.phone'), 'phone')
->sortable() ->sortable()
->searchable() ->searchable(),
->editOnClick(
hasPermission: $this->canEdit,
dataField: 'phone',
fallback: 'N/A',
saveOnMouseOut: true
),
Column::make(__('users.gender'), 'gender_str','users.gender'), Column::make(__('users.gender'), 'gender_str','users.gender'),
Column::make(__('users.birthday'), 'birthday_formatted')->sortable()->searchable(), Column::make(__('users.birthday'), 'birthday_formatted')->sortable()->searchable(),
Column::make(__('users.status'), 'status_str','users.status'), Column::make(__('users.status'), 'status_str','users.status'),
Column::make(__('users.role'), 'roles'), Column::make(__('users.role'), 'roles'),
Column::make('建立時間', 'created_at_formatted', 'created_at')->sortable(), Column::make('建立時間', 'created_at_formatted', 'created_at')->sortable(),
Column::action('操作')
]; ];
} }
#[On('bulkDelete.{tableName}')]
public function bulkDelete(): void
{
if ($this->canDelect) {
$this->js('alert(window.pgBulkActions.get(\'' . $this->tableName . '\'))');
if($this->checkboxValues){
User::destroy($this->checkboxValues);
$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,['gender','status']) && $this->canEdit) {
$this->noUpdated($modelId,$fieldName,$value);
}
}
#[On('onUpdatedEditable')]
public function onUpdatedEditable($id, $field, $value): void
{
if (in_array($field,['name','email','phone']) && $this->canEdit) {
$this->noUpdated($id,$field,$value);
}
}
#[On('onUpdatedToggleable')]
public function onUpdatedToggleable($id, $field, $value): void
{
if (in_array($field,[]) && $this->canEdit) {
$this->noUpdated($id,$field,$value);
}
}
private function noUpdated($id,$field,$value){
$user = User::find($id);
if ($user) {
$user->{$field} = $value;
$user->save(); // 明確觸發 saving
}
$this->notification()->send([
'icon' => 'success',
'title' => $id.'.'.__('users.'.$field).':'.$value,
'description' => '已經寫入',
]);
}
public function filters(): array public function filters(): array
{ {
@ -231,35 +104,4 @@ final class UserTable extends PowerGridComponent
]; ];
} }
public function actions(User $row): array
{
$actions = [];
if ($this->canEdit) {
$actions[]=Button::add('edit')
->slot(__('users.edit'))
->icon('solid-pencil-square')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('admin.user-form', 'openModal', ['id' => $row->id]);
}
if($this->canDelect){
$actions[]=Button::add('delete')
->slot(__('users.delete'))
->icon('solid-trash')
->class('inline-flex items-center gap-1 px-3 py-1 rounded ')
->dispatchTo('admin.user-form', 'deleteUser', ['id' => $row->id]);
}
return $actions;
}
/* public function actionRules($row): array
{
return [
// Hide button edit for ID 1
Rule::button('edit')
->when(fn($row) => $row->id === 1)
->hide(),
];
} */
} }

30
app/Models/Artist.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\LogsModelActivity;
class Artist extends Model
{
/** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory, LogsModelActivity;
protected $fillable = [
'category',
'name',
'simplified',
'phonetic_abbr',
'pinyin_abbr',
'strokes_abbr',
'enable',
];
protected function casts(): array
{
return [
'category' => \App\Enums\ArtistCategory::class,
];
}
}

34
app/Models/Branch.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\LogsModelActivity;
class Branch extends Model
{
/** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory, LogsModelActivity;
protected $fillable = [
'name',
'external_ip',
'enable',
];
public function rooms() {
return $this->hasMany(Room::class);
}
public function songs(){
return $this->belongsToMany(Song::class)
->withPivot('counts')
->withTimestamps();
}
protected static function booted()
{
static::deleting(function (Branch $branch) {
$branch->rooms()->delete();
});
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @OA\Schema(
* schema="MachineStatus",
* type="object",
* @OA\Property(property="branch_name", type="string", example="測試"),
* @OA\Property(property="hostname", type="string", example="PC101"),
* @OA\Property(property="ip", type="string", example="192.168.XX.XX"),
* @OA\Property(property="cpu", type="numeric", example="0.00"),
* @OA\Property(property="memory", type="numeric", example="25603"),
* @OA\Property(property="disk", type="numeric", example="158266.49"),
* @OA\Property(property="status", type="string", example="online,error"),
* )
*/
class MachineStatus extends Model
{
protected $fillable = [
'branch_name',
'hostname',
'ip',
'cpu',
'memory',
'disk',
'status',
];
}

82
app/Models/Room.php Normal file
View File

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\LogsModelActivity;
/**
* @OA\Schema(
* schema="Room",
* type="object",
* @OA\Property(property="id", type="integer", example=16),
* @OA\Property(property="floor", type="integer", example="1"),
* @OA\Property(property="type", ref="#/components/schemas/RoomType"),
* @OA\Property(property="name", type="string", example="pc102"),
* @OA\Property(property="internal_ip", type="string", example="192.168.11.7"),
* @OA\Property(property="port", type="int", example="9000"),
* @OA\Property(property="status", ref="#/components/schemas/RoomStatus"),
* @OA\Property(property="started_at", type="string", format="date-time", example="2025-05-11T16:00:00.000000Z"),
* @OA\Property(property="ended_at", type="string", format="date-time", example=null),
* )
*/
class Room extends Model
{
/** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory, LogsModelActivity;
protected $fillable = [
'floor',
'type',
'name',
'internal_ip',
'port',
'is_online',
'status',
'started_at',
'ended_at',
];
protected $hidden = [
'internal_ip',
'port',
];
protected $casts = [
'floor' => 'int',
'type' => \App\Enums\RoomType::class,
'name' => 'string',
'internal_ip' =>'string',
'port' => 'int',
'is_online' => 'boolean',
'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;
}
return $str;
}
public function str_ended_at(){
$str ="Not Set";
if($this->ended_at !=null){
$str = $this->ended_at;
}
return $str;
}
public function branch() {
return $this->belongsTo(Branch::class);
}
public function statusLogs() {
return $this->hasMany(RoomStatusLog::class);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RoomStatusLog extends Model
{
/** @use HasFactory<\Database\Factories\ArtistFactory> */
use HasFactory;
public $timestamps = true;
protected $fillable =
[
'room_id',
'user_id',
'status',
'message',
];
protected $casts = [
'status' => \App\Enums\RoomStatus::class,
];
public function room() {
return $this->belongsTo(Room::class);
}
}

View File

@ -9,11 +9,25 @@ use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
use App\Traits\LogsModelActivity; use App\Traits\LogsModelActivity;
use Spatie\Activitylog\Traits\CausesActivity; use Spatie\Activitylog\Traits\CausesActivity;
use Laravel\Sanctum\HasApiTokens;
/**
* @OA\Schema(
* schema="User",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="John Doe"),
* @OA\Property(property="email", type="string", example="john@example.com"),
* @OA\Property(property="phone", type="string", example="0900000000"),
* @OA\Property(property="birthday", type="string", format="date-time", example="2025-05-11T16:00:00.000000Z"),
* @OA\Property(property="gender", ref="#/components/schemas/UserGender"),
* @OA\Property(property="status", ref="#/components/schemas/UserStatus"),
* )
*/
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasRoles, LogsModelActivity,CausesActivity; use HasApiTokens, HasFactory, Notifiable, HasRoles, LogsModelActivity,CausesActivity;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -38,6 +52,7 @@ class User extends Authenticatable
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'api_plain_token',
]; ];
/** /**

View File

@ -0,0 +1,59 @@
<?php
namespace App\Observers;
use Illuminate\Support\Facades\Auth;
use App\Models\Room;
use App\Models\RoomStatusLog;
class RoomObserver
{
/**
* Handle the Room "created" event.
*/
public function created(Room $room): void
{
//
}
/**
* Handle the Room "updated" event.
*/
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,
]);
}
}
/**
* Handle the Room "deleted" event.
*/
public function deleted(Room $room): void
{
//
}
/**
* Handle the Room "restored" event.
*/
public function restored(Room $room): void
{
//
}
/**
* Handle the Room "force deleted" event.
*/
public function forceDeleted(Room $room): void
{
//
}
}

View File

@ -4,6 +4,9 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use App\Models\Room;
use App\Observers\RoomObserver;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/** /**
@ -19,6 +22,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Room::observe(RoomObserver::class);
} }
} }

View File

@ -0,0 +1,41 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class ApiClient
{
protected string $baseUrl;
protected string $token;
public function __construct(string $token = null)
{
$this->baseUrl = config('services.room_api.base_url', 'https://ktv.test/api');
$this->token = $token ?? config('services.room_api.token','');
}
public function setToken(string $token): self
{
$this->token = $token;
return $this;
}
public function withDefaultHeaders(): \Illuminate\Http\Client\PendingRequest
{
return Http::withHeaders([
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $this->token,
'Content-Type' => 'application/json',
]);
}
public function post(string $endpoint, array $data = [])
{
return $this->withDefaultHeaders()->post($this->baseUrl . $endpoint, $data);
}
public function get(string $endpoint, array $query = [])
{
return $this->withDefaultHeaders()->get($this->baseUrl . $endpoint, $query);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services;
class TcpSocketClient
{
protected $ip;
protected $port;
protected $timeout;
public function __construct(string $ip, int $port, int $timeout = 5)
{
$this->ip = $ip;
$this->port = $port;
$this->timeout = $timeout;
}
public function send(string $data): string
{
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($socket === false) {
throw new \Exception("Socket create failed: " . socket_strerror(socket_last_error()));
}
$result = socket_connect($socket, $this->ip, $this->port);
if ($result === false) {
throw new \Exception("Socket connect failed: " . socket_strerror(socket_last_error($socket)));
}
socket_write($socket, $data, strlen($data));
$response = '';
while ($out = socket_read($socket, 2048)) {
$response .= $out;
// 根據協議判斷是否結束接收,可以自行調整
if (strpos($response, "\n") !== false) {
break;
}
}
socket_close($socket);
return $response;
}
}

View File

@ -6,6 +6,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
api: __DIR__.'/../routes/api.php',
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
@ -18,5 +19,11 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// $exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, $request) {
if ($request->expectsJson()) {
return \App\Http\Responses\ApiResponse::unauthorized();
}
// 其他非 JSON 請求的處理方式(可選)
return redirect()->guest(route('login'));
});
})->create(); })->create();

493
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,9 @@ return [
| a conventional file to locate the various service credentials. | a conventional file to locate the various service credentials.
| |
*/ */
'room_api' => [
'base_url' => env('ROOM_API_BASE_URL', env('APP_URL') . '/api'),
],
'postmark' => [ 'postmark' => [
'token' => env('POSTMARK_TOKEN'), 'token' => env('POSTMARK_TOKEN'),
], ],

View File

@ -22,6 +22,7 @@ return new class extends Migration
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password'); $table->string('password');
$table->rememberToken(); $table->rememberToken();
$table->text('api_plain_token')->nullable();
$table->timestamps(); $table->timestamps();
}); });

View File

@ -0,0 +1,34 @@
<?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('artists', function (Blueprint $table) {
$table->bigIncrements('id');
$table->enum('category', ['未定義','男', '女','團','外', '其他'])->default('未定義')->index()->comment('歌星類別');
$table->string('name')->unique()->comment('歌星名稱');
$table->string('simplified')->index()->comment('歌星簡體');
$table->string('phonetic_abbr')->index()->comment('歌星注音');
$table->string('pinyin_abbr')->index()->comment('歌星拼音');
$table->integer('strokes_abbr')->index()->comment('歌星筆劃');
$table->tinyInteger('enable')->default(1)->comment('狀態'); // 1,可看0,不可看
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('artists');
}
};

View File

@ -0,0 +1,48 @@
<?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('song_library_cache', function (Blueprint $table) {
$table->bigIncrements('song_id')->comment('歌曲編號');
$table->string('song_name')->nullable()->index()->comment('歌曲檔名');
$table->string('song_simplified')->nullable()->index()->comment('歌曲簡體');
$table->string('phonetic_abbr')->nullable()->index()->comment('歌曲注音');
$table->string('pinyin_abbr')->nullable()->index()->comment('歌曲拼音');
$table->integer('strokes_abbr')->default(0)->index()->comment('歌曲筆劃');
$table->integer('song_number')->default(0)->index()->comment('歌曲字數');
$table->string('artistA')->nullable()->index()->comment('歌星名稱A');
$table->string('artistB')->nullable()->index()->comment('歌星名稱B');
$table->string('artistA_simplified')->nullable()->index()->comment('歌星簡體名稱A');
$table->string('artistB_simplified')->nullable()->index()->comment('歌星簡體名稱B');
$table->enum('artistA_category', ['未定義','男', '女','團','外', '其他'])->default('未定義')->index()->comment('歌星類別A');
$table->enum('artistB_category', ['未定義','男', '女','團','外', '其他'])->default('未定義')->index()->comment('歌星類別B');
$table->enum('artist_category', ['未定義','團'])->default('未定義')->index()->comment('歌星類別');
$table->string('song_filename')->nullable()->comment('歌曲檔名');
$table->string('song_category')->nullable()->comment('歌曲分類');
$table->enum('language_name', ['未定義','國語','台語','英語','日語','粵語','韓語','越語','客語','其他'])->default('未定義')->index()->comment('語別');
$table->date('add_date')->nullable()->index()->comment('新增日期');
$table->enum('situation', ['未定義','浪漫', '柔和','動感','明亮'])->default('未定義')->index()->comment('情境');
$table->tinyInteger('vocal')->index()->comment('人聲'); // 0,1
$table->integer('db_change')->default(0)->index()->comment('DB加減');
$table->integer('song_counts')->default(0)->index()->comment('點播次數');
$table->dateTime('updated_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('song_library_cache');
}
};

View File

@ -0,0 +1,29 @@
<?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('FavoriteSongs', function (Blueprint $table) {
$table->id();
$table->string('songNumber',20);
$table->string('userPhone', 10);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('FavoriteSongs');
}
};

View File

@ -0,0 +1,30 @@
<?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('branches', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('店名');
$table->ipAddress('external_ip')->comment('對外IP'); // 對外 IP
$table->tinyInteger('enable')->default(1)->comment('狀態');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('branches');
}
};

View File

@ -0,0 +1,37 @@
<?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('rooms', function (Blueprint $table) {
$table->id();
$table->foreignId('branch_id')->constrained()->onDelete('cascade')->comment('關聯分店');
$table->unsignedTinyInteger('floor')->default(1)->comment('樓層'); // 可根據實際狀況決定預設值
$table->enum('type',['unset', 'pc','svr'])->default('unset')->comment('包廂類別');
$table->string('name')->comment('包廂名稱');
$table->string('internal_ip')->nullable()->comment('內部 IP');
$table->unsignedSmallInteger('port')->nullable()->comment('通訊 Port');
$table->tinyInteger('is_online')->default(0)->comment('連線狀態');
$table->enum('status', ['active', 'closed','fire', 'error'])->default('error')->comment('狀態'); // :啟用中 / 已結束
$table->dateTime('started_at')->nullable()->comment('開始時間'); //
$table->dateTime('ended_at')->nullable()->comment('結束時間'); //
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('rooms');
}
};

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('room_status_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('room_id')->nullable();
$table->foreignId('user_id')->nullable(); // 操作者,可為 null系統
$table->enum('status', ['active', 'closed','fire', 'error', 'maintenance']);
$table->text('message')->nullable(); // 可填異常原因或操作說明
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('room_status_logs');
}
};

View File

@ -0,0 +1,34 @@
<?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('machine_statuses', function (Blueprint $table) {
$table->id();
$table->string('branch_name');
$table->string('hostname');
$table->string('ip')->nullable();
$table->decimal('cpu', 5, 2)->nullable();
$table->unsignedInteger('memory')->nullable();
$table->decimal('disk', 10, 2)->nullable();
$table->string('status');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('machine_statuses');
}
};

View File

@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
$this->call([ $this->call([
PermissionTableSeeder::class, PermissionTableSeeder::class,
CreateAdminUserSeeder::class, CreateAdminUserSeeder::class,
FavoriteSongsSeeder::class,
]); ]);
} }
} }

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class FavoriteSongsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
DB::table('FavoriteSongs')->insert([
'songNumber' => 999996,
'userPhone' => '0912345678',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
}

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{ {
"name": "shop_12_wireui", "name": "KTVCentral",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@ -0,0 +1,23 @@
<?php
return [
'management' => '分店管理',
'list' => '分店列表',
'CreateNew' => '新增分店',
'EditBranch' => '編輯分店',
'ImportData' => '滙入分店',
'create_edit' => '新增 / 編輯',
'create' => '新增',
'edit' => '編輯',
'delete' => '刪除',
'no' => '編號',
'name' => '名稱',
'external_ip' => '對外IP',
'enable' => '狀態',
'actions' => '操作',
'view' => '查看',
'submit' => '提交',
'cancel' => '取消',
];

View File

@ -8,4 +8,9 @@ return [
'user.status.Active' => '正常', 'user.status.Active' => '正常',
'user.status.Suspended' => '停權', 'user.status.Suspended' => '停權',
'user.status.Deleting' => '刪除中', 'user.status.Deleting' => '刪除中',
'room.status.Active' => '已占用',
'room.status.Closed' => '可用',
'room.status.Fire' => '火災',
'room.status.Error' => '維修',
]; ];

View File

@ -0,0 +1,22 @@
@php
use App\Enums\RoomStatus;
$statusColors = [
RoomStatus::Active->value => 'green-600',
RoomStatus::Closed->value => 'gray-600',
RoomStatus::Error->value => 'red-600',
];
@endphp
<div class="border p-2 rounded shadow-md h-32 relative cursor-pointer bg-amber-50">
{{-- 房間名稱 + 線上狀態圓點 --}}
<div class="font-bold flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full inline-block
{{ $room->is_online ? 'bg-green-500' : 'bg-red-500' }}">&nbsp;&nbsp;
</span>
<span>{{ $room->type->labels().".".$room->name }}</span>
</div>
<div class="text-sm text-{{ $statusColors[$room->status->value] ?? 'gray-500' }} text-center">
{{ $room->status->labels() }}
</div>
</div>

View File

@ -0,0 +1,26 @@
@php
use App\Enums\RoomStatus;
$statusColors = [
RoomStatus::Active->value => 'green-600',
RoomStatus::Closed->value => 'gray-600',
RoomStatus::Error->value => 'red-600',
];
@endphp
<div class="border p-2 rounded shadow-md h-32 relative cursor-pointer bg-amber-50"
wire:click="$dispatchTo('admin.room-detail-modal','openModal', { roomId: {{ $room->id }} })">
{{-- 房間名稱 + 線上狀態圓點 --}}
<div class="font-bold flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full inline-block
{{ $room->is_online ? 'bg-green-500' : 'bg-red-500' }}">&nbsp;&nbsp;
</span>
<span>{{ $room->type->labels().".".$room->name }}</span>
</div>
<div class="text-sm text-{{ $statusColors[$room->status->value] ?? 'gray-500' }} text-center">
{{ $room->status->labels() }}
</div>
<div class="text-xs text-center whitespace-nowrap ">{{ $room->str_started_at() }}</div>
<div class="text-xs text-center whitespace-nowrap ">{{ $room->str_ended_at() }}</div>
</div>

View File

@ -0,0 +1,2 @@
<x-admin.section-header title="{{ __('branches.list') }}">
</x-admin.section-header>

View File

@ -0,0 +1,4 @@
<x-layouts.admin>
<livewire:admin.branch-table />
</x-layouts.admin>

View File

@ -1,21 +0,0 @@
<x-wireui:modal-card title="{{ $roleId ? __('roles.EditRole') : __('roles.CreateNew') }}" blur wire:model.defer="showCreateModal">
<div class="space-y-4">
<x-wireui:input label="{{__('roles.role_name')}}" wire:model.defer="name" />
<x-wireui:select
label="{{__('roles.permissions')}}"
wire:model.defer="selectedPermissions"
placeholder="{{__('roles.select_permissions')}}"
multiselect
option-label="label"
option-value="value"
:options="$permissions->map(fn($p) => ['value' => $p->id, 'label' => $p->name])->toArray()"
/>
</div>
<x-slot name="footer">
<div class="flex justify-between w-full">
<x-wireui:button flat label="{{__('roles.cancel')}}" @click="$wire.showCreateModal = false" />
<x-wireui:button primary label="{{__('roles.submit')}}" wire:click="save" />
</div>
</x-slot>
</x-wireui:modal-card>

View File

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

View File

@ -1,5 +0,0 @@
<x-layouts.admin>
<x-wireui:notifications/>
<livewire:admin.role-table />
<livewire:admin.role-form />
</x-layouts.admin>

View File

@ -0,0 +1,29 @@
<x-wireui:modal id="room-detail-modal" wire:model.defer="showModal" persistent>
<x-wireui:card class="border border-gray-200">
<x-slot name="action">
<button class="cursor-pointer p-1 rounded-full focus:outline-none focus:outline-hidden focus:ring-2 focus:ring-secondary-200 text-secondary-300"
wire:click="closeModal"
tabindex="-1"
>
<x-dynamic-component
:component="WireUi::component('icon')"
name="x-mark"
class="w-5 h-5"
/>
</button>
</x-slot>
<x-slot name="title">
{{ $room->name ?? '未選擇' }}包廂設定
</x-slot>
<div class="grid grid-cols-3 gap-4 mb-4">
<x-wireui:button wire:click="startNotify" >開機</x-wireui:button>
<x-wireui:button wire:click="stopNotify" >關機</x-wireui:button>
<x-wireui:button wire:click="fireNotify" >火災</x-wireui:button>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<x-wireui:button wire:click="openAccountNotify" >包廂開帳</x-wireui:button>
<x-wireui:button wire:click="closeAccountNotify" >包廂關帳</x-wireui:button>
</div>
</x-wireui:card>
</x-wireui:modal>

View File

@ -0,0 +1,50 @@
<x-wireui:card title="{{ $branchName }} - 包廂設定" shadow="none">
<div x-data="{ floor: '{{ $floors[0] ?? 1 }}', type: 'all' }">
{{-- 樓層 Tab --}}
<div class="flex gap-2 mb-2">
@foreach($floors as $fl)
<button
class="px-3 py-1 rounded border"
:class="floor === '{{ $fl }}' ? 'bg-blue-500 text-white' : 'bg-white text-gray-700'"
x-on:click="floor = '{{ $fl }}'"
>
{{ $fl }}F
</button>
@endforeach
</div>
{{-- 類別 Tab --}}
<div class="flex gap-2 mb-4">
@foreach(['all' => '全部', 'pc' => 'PC', 'svr' => 'SVR'] as $value => $label)
<button
class="px-3 py-1 rounded border"
:class="type === '{{ $value }}' ? 'bg-green-500 text-white' : 'bg-white text-gray-700'"
x-on:click="type = '{{ $value }}'"
>
{{ $label }}
</button>
@endforeach
</div>
{{-- 房間卡片列表 --}}
<div wire:poll.5s>
<div class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
@forelse($rooms as $room)
<template x-if="floor == '{{ $room->floor }}' && (type == 'all' || type == '{{ $room->type }}')">
<div>
@if($room->type->value === \App\Enums\RoomType::SVR->value)
<x-room-card-svr :room="$room" />
@else
<x-room-card :room="$room" />
@endif
</div>
</template>
@empty
<div class="col-span-full text-center text-gray-500">尚無包廂資料</div>
@endforelse
</div>
</div>
</div>
<x-wireui:notifications/>
<livewire:admin.room-detail-modal />
</x-wireui:card>

View File

@ -0,0 +1,4 @@
<x-layouts.admin>
<livewire:admin.room-grid />
</x-layouts.admin>

View File

@ -1,40 +0,0 @@
<x-wireui:modal-card title="{{ $userId ? __('users.EditUser') : __('users.CreateNew') }}" blur wire:model.defer="showModal">
<div class="space-y-4">
<x-wireui:input label="{{__('users.name')}}" wire:model.defer="fields.name" required />
<x-wireui:input label="Email" wire:model.defer="fields.email" required />
<x-wireui:input label="Phone" wire:model.defer="fields.phone" />
<x-wireui:select
label="{{__('users.gender')}}"
wire:model.defer="fields.gender"
placeholder="{{__('users.select_gender')}}"
:options="$genderOptions"
option-label="name"
option-value="value"
/>
<x-wireui:select
label="{{__('users.status')}}"
wire:model.defer="fields.status"
placeholder="{{__('users.select_status')}}"
:options="$statusOptions"
option-label="name"
option-value="value"
/>
<x-wireui:select
label="{{__('users.role')}}"
wire:model.defer="selectedRoles"
placeholder="{{__('users.select_role')}}"
multiselect
option-label="label"
option-value="value"
:options="$rolesOptions->map(fn($p) => ['value' => $p->id, 'label' => $p->name])->toArray()"
/>
</div>
<x-slot name="footer">
<div class="flex justify-between w-full">
<x-wireui:button flat label="{{__('users.cancel')}}" wire:click="closeModal" />
<x-wireui:button primary label="{{__('users.submit')}}" wire:click="save" />
</div>
</x-slot>
</x-wireui:modal-card>

View File

@ -1,15 +1,2 @@
<x-admin.section-header title="{{ __('users.list') }}"> <x-admin.section-header title="{{ __('users.list') }}">
<x-wireui:button
wire:click="$dispatchTo('admin.user-form', 'openModal')"
icon="plus"
label="{{ __('users.CreateNew') }}"
class="bg-blue-600 text-white"
/>
<x-wireui:button
wire:click="$dispatchTo('admin.user-import-data','openModal')"
icon="document-plus"
label="{{ __('users.ImportData') }}"
class="bg-green-600 text-white"
/>
</x-admin.section-header> </x-admin.section-header>

View File

@ -1,63 +0,0 @@
<x-wireui:modal-card title="{{ __('users.ImportData') }}" blur wire:model.defer="showModal" hide-close>
{{-- 說明區塊 --}}
<div class="mb-4 p-4 bg-gray-100 border border-gray-300 rounded text-sm text-gray-700">
<p class="font-semibold mb-2">匯入格式說明</p>
<p class="mb-2">請依下列表格格式準備 Excel CSV 檔案:</p>
<div class="overflow-x-auto mb-2">
<table class="min-w-full text-sm text-left border border-collapse border-gray-300">
<thead class="bg-gray-200">
<tr>
<th class="border border-gray-300 px-3 py-1">欄位名稱</th>
<th class="border border-gray-300 px-3 py-1">說明</th>
<th class="border border-gray-300 px-3 py-1">範例</th>
</tr>
</thead>
<tbody>
<tr>
<td class="border border-gray-300 px-3 py-1">???</td>
<td class="border border-gray-300 px-3 py-1">???</td>
<td class="border border-gray-300 px-3 py-1">???</td>
</tr>
</tbody>
</table>
</div>
</div>
{{-- 檔案上傳 --}}
<div x-data="{
fileName: '',
updateFileInfo(event) {
const file = event.target.files[0];
if (file) this.fileName = file.name+ '('+(file.size / 1024 / 1024).toFixed(2) + ' MB'+')';
}
}"
>
<div x-show="$wire.file === null" >
<input type="file" wire:model="file" accept=".csv, .xls, .xlsx" class="mb-2 w-full" @change="updateFileInfo" />
<p class="text-xs text-gray-500 mb-2" >
系統限制:最大上傳 {{ $maxUploadSize }}
</p>
</div>
<!-- 檔案資訊顯示 -->
<div wire:loading.remove wire:target="file" class="text-sm text-green-600 flex items-center space-x-1" x-show="$wire.file != null">
<x-wireui:icon name="check-circle" class="w-5 h-5 text-green-500" />
<strong x-text="fileName"></strong>
</div>
<!-- 上傳中提示 -->
<div wire:loading wire:target="file" class="text-sm text-blue-500">
檔案上傳中,請稍候...
</div>
</div>
<x-slot name="footer">
<div class="flex justify-between w-full">
<x-wireui:button flat label="{{ __('users.cancel') }}" wire:click="closeModal" />
<x-wireui:button primary label="{{ __('users.submit') }}" wire:click="import" />
</div>
</x-slot>
</x-wireui:modal-card>

View File

@ -1,6 +1,3 @@
<x-layouts.admin> <x-layouts.admin>
<x-wireui:notifications/>
<livewire:admin.user-table /> <livewire:admin.user-table />
<livewire:admin.user-form />
<livewire:admin.user-import-data />
</x-layouts.admin> </x-layouts.admin>

View File

@ -8,8 +8,9 @@ new class extends Component
public array $menus=[ public array $menus=[
['label' => 'Dashboard', 'route' => 'admin.dashboard', 'icon' => 'home', 'permission' => null], ['label' => 'Dashboard', 'route' => 'admin.dashboard', 'icon' => 'home', 'permission' => null],
['label' => 'ActivityLog', 'route' => 'admin.activity-log', 'icon' => 'clock', 'permission' => null], ['label' => 'ActivityLog', 'route' => 'admin.activity-log', 'icon' => 'clock', 'permission' => null],
['label' => 'Role', 'route' => 'admin.roles', 'icon' => 'user-circle', 'permission' => 'role-list'],
['label' => 'User', 'route' => 'admin.users', 'icon' => 'user-circle', 'permission' => 'user-list'], ['label' => 'User', 'route' => 'admin.users', 'icon' => 'user-circle', 'permission' => 'user-list'],
['label' => 'Branche', 'route' => 'admin.branches', 'icon' => 'building-library', 'permission' => 'room-list'],
['label' => 'Room', 'route' => 'admin.rooms', 'icon' => 'film', 'permission' => 'room-list'],
]; ];
/** /**
@ -29,25 +30,12 @@ new class extends Component
<div class="p-4 border-b"> <div class="p-4 border-b">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-bold text-lg">管理後台</div> <div class="font-bold text-lg">管理後台</div>
<div
<x-dropdown align="right" width="48"> class="inline-flex items-center px-2 py-1 text-sm font-medium text-gray-700"
<x-slot name="trigger"> x-data="{ name: '{{ auth()->user()->name }}' }"
<button class="inline-flex items-center px-2 py-1 text-sm font-medium text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition">
<div x-data="{{ json_encode(['name' => auth()->user()->name]) }}"
x-text="name" x-text="name"
x-on:profile-updated.window="name = $event.detail.name"></div> x-on:profile-updated.window="name = $event.detail.name">
<svg class="ml-1 w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> </div>
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile')" wire:navigate>
{{ __('Profile') }}
</x-dropdown-link>
</x-slot>
</x-dropdown>
</div> </div>
</div> </div>

16
routes/api.php Normal file
View File

@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ArtistController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\RoomControlController;
use App\Http\Controllers\SqliteUploadController;
Route::post('/room/receiveRegister', [RoomControlController::class, 'receiveRegister']);
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('/upload-sqlite', [SqliteUploadController::class, 'upload']);
});

View File

@ -6,3 +6,9 @@ use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('machine_statuses:clear')->dailyAt('12:00'); // 每天凌晨 12:10 執行
//首次部署或有新增命令時)建立或更新任務排程 Crontab
// 檢查是否已有下列 crontab 設定crontab -e
//分鐘 小時 日 月 星期 指令
// * * * * * cd /Users/allen.yan/work/KTV && php artisan schedule:run >> /dev/null 2>&1

View File

@ -21,7 +21,7 @@ require __DIR__.'/auth.php';
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () { Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', AdminDashboard::class)->name('dashboard'); Route::get('/dashboard', AdminDashboard::class)->name('dashboard');
Route::get('/activity-log', function () {return view('livewire.admin.activity-log');})->name('activity-log'); Route::get('/activity-log', function () {return view('livewire.admin.activity-log');})->name('activity-log');
Route::get('/roles', function () {return view('livewire.admin.roles');})->name('roles');
Route::get('/users', function () {return view('livewire.admin.users');})->name('users'); Route::get('/users', function () {return view('livewire.admin.users');})->name('users');
Route::get('/branches', function () {return view('livewire.admin.branches');})->name('branches');
Route::get('/rooms', function () {return view('livewire.admin.rooms');})->name('rooms');
}); });

View File

@ -0,0 +1,52 @@
✅ Laravel 更新後部署流程(建議步驟)
1. 拉取新版程式碼
git pull origin main
2. 安裝依賴套件
composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
npm install && npm run build
3. 執行資料庫 migration如有 schema 變更)
php artisan migrate
4. 清除並重新快取設定與路由
php artisan config:clear
php artisan config:cache
php artisan route:clear
php artisan route:cache
php artisan view:clear
php artisan view:cache
5. (首次部署或有新增命令時)建立或更新任務排程 Crontab
檢查是否已有下列 crontab 設定crontab -e
分鐘 小時 日 月 星期 指令
* * * * * cd /path/to/your/project && php artisan schedule:run >> /dev/null 2>&1
這樣 Laravel 才能自動執行你在 routes/console.php 中定義的排程任務。
6. (選擇性)部署完立即執行某些 Artisan 指令
例如你可能希望部署後立即重建一次機器狀態資料表,可以執行:
php artisan machine_statuses:clear
7. 權限與快取設定(根據伺服器環境)
確認 storage 和 bootstrap/cache 目錄權限正確:
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
✅ 完整部署腳本範例(可寫成 deploy.sh
#!/bin/bash
cd /var/www/your-project
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan machine_statuses:clear
echo "✅ Laravel 專案已更新並執行完成。"

View File

@ -80,3 +80,11 @@ php artisan vendor:publish --tag=livewire-powergrid-config
建立分頁table 建立分頁table
php artisan powergrid:create php artisan powergrid:create
php artisan make:job TransferSqliteTableJob
php artisan migrate:rollback
php artisan migrate
php artisan transfer:sqlite sqlite/tempUser.sqlite --sync