Compare commits
4 Commits
5415b14a53
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| daf8b1ebcc | |||
| 8f008ffb61 | |||
| 729890d7c7 | |||
| ad256d3d3b |
@@ -12,8 +12,18 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
||||
|
||||
## 2. 身份認證 (Authentication)
|
||||
- **Bearer Token**:所有 API 必須在 Header 帶入 Authorization: Bearer <api_token>。
|
||||
- **身分綁定**:後端透過 Token 自動識別 machine_id,禁止在 Body 帶入 machine 或 key 欄位。
|
||||
本系統採用兩階段認證模式:
|
||||
|
||||
### 2.1 維運人員認證 (User Authentication)
|
||||
- **核發端點**:B000 (登入)。
|
||||
- **使用端點**:B014 (參數下載)。
|
||||
- **方式**:使用 Laravel Sanctum 核發之 **User Token**。
|
||||
- **Header**:`Authorization: Bearer <user_token>`。
|
||||
|
||||
### 2.2 機台通訊認證 (Machine Authentication)
|
||||
- **適用 API**:B010, B012, B013, B600 等後續通訊。
|
||||
- **方式**:使用機台專屬之 **api_token**。
|
||||
- **Header**:`Authorization: Bearer <api_token>`。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +50,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
|
||||
|
||||
---
|
||||
|
||||
@@ -216,3 +227,34 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| **0415** | Pickup door error | error | 取貨門異常 |
|
||||
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
||||
| **5403** | Elevator failure | error | 昇降系統故障 |
|
||||
|
||||
---
|
||||
|
||||
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
|
||||
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/setting/B014
|
||||
- **Authentication**: **User Token** (Sanctum Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
|
||||
| 欄位 (Key) | 說明 | 備註 |
|
||||
| :--- | :--- | :--- |
|
||||
| **t050v01** | 機台序號 | 即 machine_id |
|
||||
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
|
||||
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
|
||||
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
|
||||
| **t050v43** | 玉山 Hash Key | ESUN Hash |
|
||||
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
|
||||
| **t050v35** | 發票 Hash Key | Invoice Key |
|
||||
| **t050v36** | 發票 Hash IV | Invoice IV |
|
||||
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
|
||||
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
|
||||
|
||||
> [!CAUTION]
|
||||
> **安全性規範**:B014 會回傳敏感金鑰與正式 Token,背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
|
||||
|
||||
@@ -21,7 +21,9 @@ class AdvertisementController extends AdminController
|
||||
|
||||
// Tab 1: 廣告列表
|
||||
$advertisements = Advertisement::with('company')->latest()->paginate(10);
|
||||
$allAds = Advertisement::active()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
|
||||
$allAds = Advertisement::playing()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料)
|
||||
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
|
||||
@@ -54,6 +56,8 @@ class AdvertisementController extends AdminController
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
|
||||
],
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@@ -71,13 +75,15 @@ class AdvertisementController extends AdminController
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
|
||||
Advertisement::create([
|
||||
$advertisement = Advertisement::create([
|
||||
'company_id' => $companyId,
|
||||
'name' => $request->name,
|
||||
'type' => $request->type,
|
||||
'duration' => (int) $request->duration,
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'is_active' => true,
|
||||
'start_at' => $request->start_at,
|
||||
'end_at' => $request->end_at,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
@@ -99,6 +105,8 @@ class AdvertisementController extends AdminController
|
||||
'duration' => 'required|in:15,30,60',
|
||||
'is_active' => 'boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
];
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
@@ -111,7 +119,7 @@ class AdvertisementController extends AdminController
|
||||
|
||||
$request->validate($rules);
|
||||
|
||||
$data = $request->only(['name', 'type', 'duration']);
|
||||
$data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
|
||||
$data['is_active'] = $request->has('is_active');
|
||||
|
||||
$user = auth()->user();
|
||||
@@ -150,7 +158,7 @@ class AdvertisementController extends AdminController
|
||||
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(Advertisement $advertisement)
|
||||
public function destroy(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
// 檢查是否有機台正投放中
|
||||
if ($advertisement->machineAdvertisements()->exists()) {
|
||||
|
||||
@@ -112,7 +112,8 @@ class MachineAuthController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Success'
|
||||
'message' => 'Success',
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ class MachineController extends Controller
|
||||
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
|
||||
->with([
|
||||
'advertisement' => function ($query) {
|
||||
$query->active();
|
||||
$query->playing();
|
||||
}
|
||||
])
|
||||
->get()
|
||||
@@ -456,4 +456,86 @@ class MachineController extends Controller
|
||||
'message' => 'Error report accepted',
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
|
||||
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
|
||||
*/
|
||||
public function getSettings(Request $request)
|
||||
{
|
||||
$serialNo = $request->input('machine');
|
||||
$user = $request->user();
|
||||
|
||||
// 1. 查找機台 (忽略全局範圍以進行認領)
|
||||
$machine = Machine::withoutGlobalScopes()
|
||||
->with(['paymentConfig', 'company'])
|
||||
->where('serial_no', $serialNo)
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => 'Machine not found'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 2. 權限加強驗證 (RBAC)
|
||||
$isAuthorized = false;
|
||||
if ($user->isSystemAdmin()) {
|
||||
$isAuthorized = true;
|
||||
} elseif ($machine->company_id === $user->company_id) {
|
||||
// 公司管理員或已授權員工才能存取
|
||||
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAuthorized) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Forbidden: You do not have permission to configure this machine'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 3. 獲取關聯設定
|
||||
$paymentSettings = $machine->paymentConfig->settings ?? [];
|
||||
$companySettings = $machine->company->settings ?? [];
|
||||
|
||||
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
|
||||
$data = [
|
||||
't050v01' => $machine->serial_no,
|
||||
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
|
||||
|
||||
// 玉山支付
|
||||
't050v41' => $paymentSettings['esun_store_id'] ?? '',
|
||||
't050v42' => $paymentSettings['esun_term_id'] ?? '',
|
||||
't050v43' => $paymentSettings['esun_hash'] ?? '',
|
||||
|
||||
// 電子發票 (綠界)
|
||||
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
|
||||
't050v35' => $companySettings['invoice_hash_key'] ?? '',
|
||||
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
|
||||
't050v38' => $companySettings['invoice_email'] ?? '',
|
||||
|
||||
// 趨勢支付 (TrendPay/Greenpay)
|
||||
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
|
||||
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
|
||||
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
|
||||
|
||||
// 各類行動支付特店 ID
|
||||
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
|
||||
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
|
||||
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
|
||||
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
|
||||
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [$data] // App 預期的是包含單一物件的陣列
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,15 @@ class Advertisement extends Model
|
||||
'duration',
|
||||
'url',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'duration' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,4 +52,21 @@ class Advertisement extends Model
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include advertisements that should be playing now.
|
||||
*/
|
||||
public function scopePlaying($query)
|
||||
{
|
||||
$now = now();
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('start_at')
|
||||
->orWhere('start_at', '<=', $now);
|
||||
})
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('end_at')
|
||||
->orWhere('end_at', '>=', $now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,115 @@ return [
|
||||
[
|
||||
'name' => '機台核心通訊 (IoT Core)',
|
||||
'apis' => [
|
||||
[
|
||||
'name' => 'B000: 維運人員登入認證 (Technician Login)',
|
||||
'slug' => 'b000-tech-login',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/app/admin/login/B000',
|
||||
'description' => '機台啟動引導的第一步。維運人員輸入個人帳密與機台編號進行認證,成功後核發臨時 Sanctum Token 供後續 B014 下載敏感設定使用。',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'username' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '維運人員帳號',
|
||||
'example' => 'admin_test'
|
||||
],
|
||||
'password' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '維運人員密碼',
|
||||
'example' => 'password123'
|
||||
],
|
||||
'machine' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '機台序號 (Serial No)',
|
||||
'example' => 'SN202604130001'
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息',
|
||||
'example' => 'Success'
|
||||
],
|
||||
'token' => [
|
||||
'type' => 'string',
|
||||
'description' => '臨時身份認證 Token (Sanctum)',
|
||||
'example' => '1|abcdefg...'
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'username' => 'admin_test',
|
||||
'password' => 'password123',
|
||||
'machine' => 'SN202604130001'
|
||||
],
|
||||
'response' => [
|
||||
'message' => 'Success',
|
||||
'token' => '1|abcdefg...'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'B014: 機台參數與金鑰下載 (Config Download)',
|
||||
'slug' => 'b014-config-download',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/app/machine/setting/B014',
|
||||
'description' => '機台引導階段的第二步。在人員登入後,透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <user_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'machine' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '機台序號',
|
||||
'example' => 'SN202604130001'
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否成功',
|
||||
'example' => true
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'description' => '配置物件陣列。包含:t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
|
||||
'example' => [
|
||||
[
|
||||
't050v01' => 'SN202604130001',
|
||||
'api_token' => 'mac_token_...',
|
||||
't050v41' => '80812345',
|
||||
't050v34' => '2000132',
|
||||
'TP_APP_ID' => 'GP_001'
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'machine' => 'SN202604130001'
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
[
|
||||
't050v01' => 'SN202604130001',
|
||||
'api_token' => 'mac_token_...',
|
||||
't050v41' => '80812345',
|
||||
't050v42' => '9001',
|
||||
't050v43' => 'hash_key',
|
||||
't050v34' => '2000132',
|
||||
'TP_APP_ID' => 'GP_001'
|
||||
]
|
||||
]
|
||||
],
|
||||
'notes' => '此 API 受 auth:sanctum 保護,必須在 Header 帶上從 B000 取得的 Token。'
|
||||
],
|
||||
[
|
||||
'name' => 'B005: 廣告清單同步 (Ad Sync)',
|
||||
'slug' => 'b005-ad-sync',
|
||||
|
||||
@@ -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::table('advertisements', function (Blueprint $table) {
|
||||
$table->dateTime('start_at')->nullable()->after('url')->comment('發布時間');
|
||||
$table->dateTime('end_at')->nullable()->after('start_at')->comment('下架時間');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('advertisements', function (Blueprint $table) {
|
||||
$table->dropColumn(['start_at', 'end_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,67 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔐 B000: 維運人員登入認證 (Technician Login)
|
||||
機台引導階段 (Provisioning) 的第一步,用於核發臨時身份 Token 以便後續下載敏感設定。
|
||||
|
||||
### 1. API 資訊
|
||||
- **Endpoint**: `POST /api/v1/app/admin/login/B000`
|
||||
- **認證方式**: 無 (需傳入 `username`, `password`, `machine`)
|
||||
- **回應內容**: `token` (Sanctum Token)
|
||||
|
||||
### 2. 回應範例
|
||||
```json
|
||||
{
|
||||
"message": "Success",
|
||||
"token": "3|abcdef1234567890..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 B014: 機台參數與金鑰下載 (Config Download)
|
||||
下載機台運作所需的支付金鑰、電子發票設定與正式通訊 Token。
|
||||
|
||||
### 1. API 資訊
|
||||
- **Endpoint**: `POST /api/v1/app/machine/setting/B014`
|
||||
- **認證方式**: **Bearer Token** (需帶上 B000 取得的 Token)
|
||||
- **Header**: `Authorization: Bearer {token}`
|
||||
|
||||
### 2. 請求參數
|
||||
- `machine`: 機台序號 (Serial No)
|
||||
|
||||
### 3. 回應規格 (欄位映射)
|
||||
| 欄位 | 說明 | 來源範例 |
|
||||
| :--- | :--- | :--- |
|
||||
| `t050v01` | 機台序號 | `SN2026041301` |
|
||||
| `api_token` | **機台正式 Token** | 後續 B010/B600 認證用 |
|
||||
| `t050v41` | 玉山特店編號 | `ESUN_STORE_ID` |
|
||||
| `t050v43` | 玉山 Hash Key | `ESUN_HASH` |
|
||||
| `t050v34` | 發票特店 ID | `INV_MID` |
|
||||
| `TP_APP_ID` | 趨勢支付 AppID | `TP_APP_ID` |
|
||||
|
||||
### 4. 回應範例 (JSON)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"t050v01": "SN2026041301",
|
||||
"api_token": "mac_token_...",
|
||||
"t050v41": "8081234567",
|
||||
"t050v42": "9001",
|
||||
"t050v43": "password123",
|
||||
"t050v34": "2000132",
|
||||
"TP_APP_ID": "GREEN_001",
|
||||
"...": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status)
|
||||
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。
|
||||
|
||||
|
||||
43
docs/future_todo.md
Normal file
43
docs/future_todo.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Star Cloud 近期開發待辦清單 (Target Roadmap)
|
||||
|
||||
本文件列出了 Star Cloud 系統近期優先開發的功能模組,旨在強化系統的營運溝通能力與非同步處理效率。
|
||||
|
||||
---
|
||||
|
||||
## 🟢 核心開發階段:全域工具列與通訊系統
|
||||
*本階段為目前唯一開發重心*
|
||||
|
||||
### 1. 全域工具列升級 (Header Toolbar)
|
||||
| 功能項目 | 具體描述 | 預計開發時間 |
|
||||
| :--- | :--- | :--- |
|
||||
| **☁️ 下載任務中心** | 整合 Redis Queue 處理耗時報表匯出。商戶點擊匯出後背景執行,完成後透過 Header 圖示點擊下載。 | 2 天 |
|
||||
| **🔔 通知中心** | 串接 Laravel Database Notifications,顯示系統消息、機台警告與業務通知,帶有紅點提示。 | 1 天 |
|
||||
| **❓ 幫助/客服中心** | 於 Header 置入問號圖示,點擊觸發側邊抽屜 (Offcanvas),展示 FAQ 與客服聯繫窗口。 | 0.5 天 |
|
||||
| **🎭 帳號切換與身分模擬** | **整合於頭像下拉選單**:支援「系統管理員切換租戶」與「租戶管理員切換子帳號」,提供顯眼的頂部模擬狀態橫幅。 | 1.5 天 |
|
||||
|
||||
### 2. 公告與溝通系統 (Communication System)
|
||||
| 功能項目 | 具體描述 | 預計開發時間 |
|
||||
| :--- | :--- | :--- |
|
||||
| **📢 系統公告管理** | 建立後台發布介面,支援針對全體或特定租戶發布「一般」或「重要」公告。 | 1.5 天 |
|
||||
| **🛡️ 登錄強制公告** | 實作具備「滑動解鎖」功能的彈窗。使用者必須將公告滑到底部,解鎖按鈕後才能進入 Dashboard。 | 1 天 |
|
||||
|
||||
### 3. 儀表板優化 (Dashboard Enhancement)
|
||||
| 功能項目 | 具體描述 | 預計開發時間 |
|
||||
| :--- | :--- | :--- |
|
||||
| **🚀 儀表板快捷入口** | 在儀表板頂部加入一排快捷圖示(如:機台管理、訂單查詢、會員中心),方便商戶快速跳轉核心功能。 | 0.5 天 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 第二階段:進階行銷與營運工具
|
||||
*優先順序:中 | 預計總工時:約 5 個開發日*
|
||||
|
||||
| 功能項目 | 具體描述 | 預計開發時間 |
|
||||
| :--- | :--- | :--- |
|
||||
| **🎁 互動盲盒抽獎** | **後台端**:實作中獎機率配置、獎項庫存管理、活動排程。**終端 API**:提供給機台大螢幕 H5/React 遊戲呼叫的開獎與配置介面。 | 4 天 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 實作標準
|
||||
1. **UI/UX**: 必須符合 `ui-minimal-luxury` 規範(Outfit 字體、青色點綴、柔和投影)。
|
||||
2. **安全性**: 權限控制必須嚴格過濾 `company_id`,公告需支援「已讀紀錄」追蹤。
|
||||
3. **效能**: 下載中心必須使用非同步隊列,嚴禁在 Request 週期內執行耗時匯出。
|
||||
@@ -1145,5 +1145,12 @@
|
||||
"Service Terms": "Service Periods",
|
||||
"Contract": "Contract",
|
||||
"Warranty": "Warranty",
|
||||
"Software": "Software"
|
||||
"Software": "Software",
|
||||
"Schedule": "Schedule",
|
||||
"Immediate": "Immediate",
|
||||
"Indefinite": "Indefinite",
|
||||
"Ongoing": "Ongoing",
|
||||
"Waiting": "Waiting",
|
||||
"Publish Time": "Publish Time",
|
||||
"Expired Time": "Expired Time"
|
||||
}
|
||||
@@ -1144,5 +1144,12 @@
|
||||
"Service Terms": "サービス期間",
|
||||
"Contract": "契約",
|
||||
"Warranty": "保証",
|
||||
"Software": "ソフトウェア"
|
||||
"Software": "ソフトウェア",
|
||||
"Schedule": "スケジュール設定",
|
||||
"Immediate": "即時",
|
||||
"Indefinite": "無期限",
|
||||
"Ongoing": "進行中",
|
||||
"Waiting": "待機中",
|
||||
"Publish Time": "公開時間",
|
||||
"Expired Time": "終了時間"
|
||||
}
|
||||
@@ -1145,5 +1145,12 @@
|
||||
"Service Terms": "服務期程",
|
||||
"Contract": "合約",
|
||||
"Warranty": "保固",
|
||||
"Software": "軟體"
|
||||
"Software": "軟體",
|
||||
"Schedule": "排程區間",
|
||||
"Immediate": "立即",
|
||||
"Indefinite": "無限期",
|
||||
"Ongoing": "進行中",
|
||||
"Waiting": "等待中",
|
||||
"Publish Time": "發布時間",
|
||||
"Expired Time": "下架時間"
|
||||
}
|
||||
@@ -71,6 +71,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
@endif
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Duration') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Schedule') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
@@ -104,15 +105,34 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
{{ __($ad->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-black text-slate-700 dark:text-slate-200">
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-mono font-bold text-slate-700 dark:text-slate-200">
|
||||
{{ $ad->duration }}s
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
|
||||
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@if($ad->is_active)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
||||
@endif
|
||||
@php
|
||||
$now = now();
|
||||
$isStarted = !$ad->start_at || $ad->start_at <= $now;
|
||||
$isExpired = $ad->end_at && $ad->end_at < $now;
|
||||
$isPlaying = $ad->is_active && $isStarted && !$isExpired;
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
@if(!$ad->is_active)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
||||
@elseif($isExpired)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
|
||||
@elseif(!$isStarted)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
@@ -211,7 +231,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
</button>
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
|
||||
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
|
||||
<p class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
|
||||
</div>
|
||||
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
@@ -324,7 +344,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
<span class="text-white/80 font-bold tracking-widest text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
|
||||
<span class="text-white/80 font-mono font-bold tracking-tight text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -385,7 +405,9 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
type: 'image',
|
||||
duration: 15,
|
||||
is_active: true,
|
||||
url: ''
|
||||
url: '',
|
||||
start_at: '',
|
||||
end_at: ''
|
||||
},
|
||||
|
||||
// Assign Modal
|
||||
@@ -604,6 +626,17 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
}
|
||||
},
|
||||
|
||||
formatDateForInput(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
||||
},
|
||||
|
||||
async submitAssignment() {
|
||||
try {
|
||||
const response = await fetch(this.urls.assign, {
|
||||
@@ -650,6 +683,14 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
if (document.querySelector('#ad_company_select')) {
|
||||
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
|
||||
}
|
||||
|
||||
// 確保 Flatpickr 實例同步顯示目前的時間值
|
||||
if (this.$refs.startAtPicker?._flatpickr) {
|
||||
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
|
||||
}
|
||||
if (this.$refs.endAtPicker?._flatpickr) {
|
||||
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -685,7 +726,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openAddModal() {
|
||||
this.adFormMode = 'add';
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' };
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '', start_at: '', end_at: '' };
|
||||
this.fileName = '';
|
||||
this.mediaPreview = null;
|
||||
this.isAdModalOpen = true;
|
||||
@@ -693,7 +734,11 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openEditModal(ad) {
|
||||
this.adFormMode = 'edit';
|
||||
this.adForm = { ...ad };
|
||||
this.adForm = {
|
||||
...ad,
|
||||
start_at: this.formatDateForInput(ad.start_at),
|
||||
end_at: this.formatDateForInput(ad.end_at)
|
||||
};
|
||||
this.fileName = '';
|
||||
this.mediaPreview = ad.url; // Use existing URL as preview
|
||||
this.isAdModalOpen = true;
|
||||
|
||||
@@ -94,6 +94,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Publish Time') }}
|
||||
</label>
|
||||
<div class="relative group/input">
|
||||
<input type="text" name="start_at" x-ref="startAtPicker" x-model="adForm.start_at"
|
||||
x-init="flatpickr($refs.startAtPicker, {
|
||||
enableTime: true,
|
||||
dateFormat: 'Y/m/d H:i',
|
||||
time_24hr: true,
|
||||
locale: window.flatpickrLocale,
|
||||
onClose: (selectedDates, dateStr) => { adForm.start_at = dateStr; }
|
||||
})"
|
||||
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
|
||||
placeholder="YYYY/MM/DD HH:MM">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Expired Time') }}
|
||||
</label>
|
||||
<div class="relative group/input">
|
||||
<input type="text" name="end_at" x-ref="endAtPicker" x-model="adForm.end_at"
|
||||
x-init="flatpickr($refs.endAtPicker, {
|
||||
enableTime: true,
|
||||
dateFormat: 'Y/m/d H:i',
|
||||
time_24hr: true,
|
||||
locale: window.flatpickrLocale,
|
||||
onClose: (selectedDates, dateStr) => { adForm.end_at = dateStr; }
|
||||
})"
|
||||
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
|
||||
placeholder="YYYY/MM/DD HH:MM">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload (Luxury UI Pattern) -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
|
||||
@@ -51,6 +51,9 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
|
||||
Route::prefix('app')->group(function () {
|
||||
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
|
||||
|
||||
// 機台啟動引導與參數下載 (需人員登入 Token)
|
||||
Route::middleware('auth:sanctum')->post('machine/setting/B014', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSettings']);
|
||||
});
|
||||
|
||||
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user