docs: 整合與優化 Agent Skills 規範及新增技能觸發準則

This commit is contained in:
2026-03-06 10:43:23 +08:00
parent e4c83ebd6d
commit 6b6e840f35
12 changed files with 596 additions and 708 deletions

View File

@@ -0,0 +1,285 @@
---
name: 操作紀錄實作規範 (Activity Logging Skill)
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
---
# 操作紀錄實作規範 (Activity Logging Skill)
本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
---
## 1. 啟用 Activity Log (Model 基本設定)
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
```php
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
->dontSubmitEmptyLogs(); // 若無變動則不記錄
}
}
```
---
## 2. `tapActivity` 實作規範 (Backend 核心)
### 2.1 型別宣告:統一使用 `Contracts\Activity`
```php
// ✅ 正確:使用介面
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
// ❌ 禁止:使用具體類別
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
```
### 2.2 必須 `toArray()` 避免 Indirect modification error
```php
// 🚩 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
// ... 操作 $properties ...
$activity->properties = $properties; // 最後整體回寫
```
### 2.3 Snapshot 快照策略
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊。
```php
$snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->doc_no; // 單號
$snapshot['name'] = $this->name; // 名稱
$snapshot['warehouse_name'] = $this->warehouse?->name; // 關聯名稱
$properties['snapshot'] = $snapshot;
```
### 2.4 全域 ID 轉名稱邏輯 (ID Resolution)
所有的 ID`warehouse_id`, `created_by`)在記錄時應自動解析為名稱。
#### 模組內 Model可直接查詢
```php
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 同模組內的 Model 可以直接查詢
foreach (['created_by', 'updated_by', 'completed_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
```
#### 跨模組 Model必須透過 Service Interface
> [!IMPORTANT]
> 依據跨模組通訊規範,若需解析其他模組的 ID例如在 `Procurement` 模組中解析 `warehouse_id`
> **禁止**直接 `Warehouse::find()`,必須透過 Service Interface。
```php
// ✅ 正確:透過 Service Interface 取得跨模組資料
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
->getWarehouse($data['warehouse_id']);
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
}
```
> [!NOTE]
> `Core` 模組的 `User`, `Role`, `Tenant` 屬於全域例外,其他模組可直接查詢。
> 詳見 [跨模組通訊規範](file:///home/mama/projects/star-erp/.agents/skills/cross-module-communication/SKILL.md)。
### 2.5 完整 `tapActivity` 範例(參考 PurchaseOrder
```php
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
// 🚩 轉換為陣列
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
// 1. Snapshot 快照
$snapshot = $properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->code;
$snapshot['vendor_name'] = $this->vendor?->name;
$properties['snapshot'] = $snapshot;
// 2. ID 轉名稱
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 全域例外User 可直接查
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f];
}
}
// 同模組:可直接查
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id'];
}
// 跨模組:必須透過 Service Interface
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
->getWarehouse($data['warehouse_id']);
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
// 3. 合併 activityProperties (手動傳入的 items_diff 等)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = $properties;
}
```
---
## 3. 複雜操作的日誌合併 (Log Consolidation)
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
### 3.1 手動記錄必須自行過濾差異
```php
// ✅ 正確:自行比對差異,只存變動值
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
if ($value != ($oldAttributes[$key] ?? null)) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
}
}
if (!empty($changedAttributes)) {
activity()
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
->log('updated');
}
```
### 3.2 `saveQuietly()` + 手動日誌 合併策略
```php
DB::transaction(function () use ($doc, $items) {
// 1. 更新品項 (記錄變動細節)
$updatedItems = $this->getUpdatedItems($doc, $items);
// 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
$doc->status = 'completed';
$doc->saveQuietly();
// 3. 手動觸發單一合併日誌
activity()
->performedOn($doc)
->withProperties([
'items_diff' => ['updated' => $updatedItems],
'attributes' => ['status' => 'completed'],
'old' => ['status' => 'counting']
])
->log('updated');
});
```
> [!WARNING]
> 使用 `saveQuietly()` 會繞過 Model Events如自動單號產生
> 若 Model 有 `creating`/`updating` 事件產生單號,需在 Service 中手動處理。
---
## 4. 後端 Controller 映射 (Subject Map)
新增 Model 時,必須同步在 `ActivityLogController::getSubjectMap()` 加入中文映射。
**位置**: `app/Modules/Core/Controllers/ActivityLogController.php`
```php
private function getSubjectMap()
{
return [
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
// ... 新增此行
];
}
```
---
## 5. 前端介面規範 (Frontend)
### 5.1 標籤命名規範 (Field Labels)
前端顯示應完全移除「ID」字眼提供最友善的閱讀體驗。
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
```typescript
const fieldLabels: Record<string, string> = {
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
created_by: '建立者', // ❌ 禁用「建立者 ID」
completed_by: '完成者',
status: '狀態',
// 新增 Model 的欄位翻譯 ...
};
```
### 5.2 `nameParams` 必須在兩處同步更新
> [!IMPORTANT]
> `nameParams``LogTable.tsx``ActivityDetailDialog.tsx` 中各有一份,
> 新增時**必須兩處同步更新**,否則會導致列表與詳情頁顯示不一致。
| 檔案 | 用途 |
|---|---|
| `resources/js/Components/ActivityLog/LogTable.tsx` | 列表頁的描述文字 |
| `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` | 對話框標題 |
### 5.3 特殊結構顯示
* **品項異動**:前端已能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」方式呈現表格。
* **顯示過濾邏輯**(已內建於 `ActivityDetailDialog`
- **Created**: 顯示初始化欄位
- **Updated**: 僅顯示有變動的欄位 (`isChanged` 判斷)
- **Deleted**: 顯示刪除前的完整資料
---
## 6. 開發檢核清單 (Checklist)
- [ ] **Model**: 是否已設定 `logOnlyDirty` + `dontSubmitEmptyLogs`
- [ ] **Model**: `tapActivity` 型別是否使用 `Contracts\Activity`
- [ ] **Model**: `tapActivity` 是否已使用 `toArray()` 處理 Collection
- [ ] **Model**: 是否已實作 Snapshot關鍵識別資訊
- [ ] **Model**: ID 轉名稱是否遵守跨模組規範Core 例外,其餘需透過 Interface
- [ ] **Service**: 是否使用 `saveQuietly()` 搭配手動 `activity()` 避免重複日誌?
- [ ] **Controller**: `ActivityLogController::getSubjectMap()` 是否已新增 Model 中文映射?
- [ ] **UI**: `fieldLabels` 是否已新增欄位中文翻譯?
- [ ] **UI**: `nameParams` 是否已在 `LogTable``ActivityDetailDialog` 兩處同步?

View File

@@ -0,0 +1,137 @@
---
name: 跨模組調用與通訊規範 (Cross-Module Communication)
description: 規範 Laravel Modular Monolith 架構下不同業務模組中如何彼此調用資料與邏輯包含禁止項目、Interface 實作、與 Service 綁定規則。
---
# 跨模組調用與通訊規範 (Cross-Module Communication)
為了確保專案的「模組化單體架構 (Modular Monolith)」的獨立性與可維護性,當遇到**需要跨越不同業務模組存取資料或調用功能**的情境時,請嚴格遵守以下規範。
## 🚫 絕對禁止的行為 (Strict Prohibitions)
* **禁止跨模組 Eloquent 關聯(例外除外)**
* **禁止跨模組直接引入 (use) Model**
* **禁止跨模組直接實例化 (new) Service**
---
## 🌟 允許的全域例外 (Global Exceptions)
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model
1. **`App\Modules\Core\Models\User`**
2. **`App\Modules\Core\Models\Role`**
3. **`App\Modules\Core\Models\Tenant`**
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`**絕對不能**反過來直接 `use` 外部業務模組的 Model仍必須透過外部模組的 Service Interface 來索取資料。
---
## ✅ 正確的跨模組調用流程:合約與依賴反轉
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。
### Step 1: 在被調用的模組定義合約 (Interface)
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
```php
namespace App\Modules\Inventory\Contracts;
use Illuminate\Support\Collection;
interface InventoryServiceInterface
{
public function getActiveWarehouses(): Collection;
}
```
### Step 2: 實作介面並在自己模組的 ServiceProvider 註冊
`Inventory` 模組自己的 Service 來實作上述介面。
```php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Collection;
class InventoryService implements InventoryServiceInterface
{
public function getActiveWarehouses(): Collection
{
return Warehouse::where('is_active', true)
->select(['id', 'name', 'code'])
->get();
}
}
```
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
```php
namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
}
}
```
### Step 3: 調用方透過依賴注入 (DI) 使用服務
`Procurement` 模組需要取得倉庫資料時,必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`
```php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Inertia\Inertia;
class PurchaseOrderController extends Controller
{
public function __construct(
protected InventoryServiceInterface $inventoryService
) {}
public function create()
{
$warehouses = $this->inventoryService->getActiveWarehouses();
return Inertia::render('Procurement/PurchaseOrder/Create', [
'warehouses' => $warehouses
]);
}
}
```
---
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
* **回傳純粹資料**:建議在 Service 中用 `with()` 載入好關聯,或者直接轉為原生的 Array 或有具體結構的 DTO避免依賴 Lazy Loading。
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
### 範例:手動合併資料
```php
// 正確示範:在各自模組取資料,並手動組裝
$orders = $this->orderService->getOrders();
$userIds = $orders->pluck('user_id')->unique()->toArray();
$users = $this->coreUserService->getUsersByIds($userIds)->keyBy('id');
$mergedData = $orders->map(function ($order) use ($users) {
// 將使用者資料手動附加上去
$order->user_name = $users->get($order->user_id)->name ?? 'Unknown';
return $order;
});
```

View File

@@ -0,0 +1,50 @@
---
name: Git 分支管理與開發規範 (Git Workflow)
description: 規範開發過程中的 Git 分支架構、合併限制、環境部署流程以及提交訊息格式。
---
# Git 分支管理與開發規範 (Git Workflow)
為了確保系統穩定性與發布紀律,所有開發者與 AI 助手必須嚴格遵守以下環境發布流程與時段限制。
## 1. 分支架構與環境定義
| 分支 | 環境 | 用途描述 | 合併來源 |
| :--- | :--- | :--- | :--- |
| **`dev`** | 本機開發 | 日常開發與功能實作。 | `feature/*` |
| **`demo`** | 測試/預佈署 | 鏡像生產環境。用於正式上線前的最終驗證。 | `dev` |
| **`main`** | 生產環境 | 正式版本分支。僅存放透過 `demo` 驗證後的代碼。 | `demo` |
## 2. 發布時段與約束 (Release Window)
### Main 分支發布限制 (Mandatory)
1. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
2. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
3. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
## 3. 開發與修復流程 (SOP)
### 標準開發流程
1. `feature/*` -> `dev` (隨時合併,主要測試點)。
2. `dev` -> `demo` (隨時合併,進行類生產環境測試)。
3. `demo` -> `main` (僅限允許時段進行,正式上線)。
### 緊急修復流程 (Hotfix)
1. 直接從 `main` 建立 `hotfix/*` 分支進行修復。
2. 修復完成並通過測試後合併回 `main`
3. **重要同步**:修復後的程式碼必須立即合併回 `demo``dev`,確保各環境修復同步。
## 4. 提交訊息規範 (Commit Messages)
提交訊息必須包含以下前綴:
- `[FIX]`:修復 Bug。
- `[FEAT]`:新增功能。
- `[DOCS]`:文件更新。
- `[STYLE]`UI/格式調整。
- `[REFACTOR]`:重構。
---
> [!IMPORTANT]
> 身為 AI 助手 (Antigravity),我會監控合併對象與當前時間。若您的命令涉及合併至 `main` 且不在允許時段內,我會優先進行安全提醒。

View File

@@ -0,0 +1,206 @@
---
name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
---
# 權限管理與實作規範
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
---
## 1. 定義權限 (Backend Seeder)
所有權限皆定義於 `database/seeders/PermissionSeeder.php`
### 步驟:
1. 開啟 `database/seeders/PermissionSeeder.php`
2. 在 `$permissions` 關聯陣列中新增功能對應的權限。
* **命名慣例**`{resource}.{action}`(例如:`system.view_logs`, `products.create`
* **格式**`'權限字串' => '中文動作名稱'`
* 常用動作:`view`, `create`, `edit`, `delete`, `approve`, `cancel`, `export`
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
### 範例:
```php
// 1. 新增權限(注意:是 key => value 格式)
$permissions = [
// ... 現有權限
'utility_fees.view' => '檢視',
'utility_fees.create' => '建立',
'utility_fees.edit' => '編輯',
'utility_fees.delete' => '刪除',
];
// 2. 分配給角色
$admin->givePermissionTo([
// ... 現有權限
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
]);
```
### 現有角色定義:
| 角色 | 說明 | 權限範圍 |
|---|---|---|
| `super-admin` | 系統管理員 | 自動擁有所有權限(`Permission::all()` |
| `admin` | 一般管理員 | 大部分權限(除角色管理外) |
| `warehouse-manager` | 倉庫管理員 | 庫存、盤點、調撥、進貨、門市叫貨 |
| `purchaser` | 採購人員 | 商品檢視、採購單、退貨、供應商、進貨 |
| `viewer` | 檢視人員 | 僅限各模組的 `.view` 權限 |
---
## 2. 套用資料庫變更 (Multi-tenancy)
修改 Seeder 後,必須在**中央與所有租戶**同步執行。
```bash
# 對所有租戶執行 Seeder
./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder
```
> [!WARNING]
> 僅執行 `db:seed` 只會更新中央資料庫。務必使用 `tenants:seed` 確保所有租戶同步。
---
## 3. 路由保護 (Backend Middleware)
路由保護定義在各模組自己的 `app/Modules/{ModuleName}/Routes/web.php` 中。
> [!IMPORTANT]
> 路由檔在各模組內(如 `app/Modules/Finance/Routes/web.php`**不是**全域的 `routes/web.php`
### 範例:
```php
// 單一權限保護
Route::middleware('permission:utility_fees.view')->group(function () {
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
Route::get('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'show'])->name('utility-fees.show');
});
// 巢狀權限群組
Route::middleware('permission:utility_fees.create')->group(function () {
Route::get('/utility-fees/create', [UtilityFeeController::class, 'create'])->name('utility-fees.create');
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
});
// 單行 middleware
Route::delete('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'destroy'])
->middleware('permission:utility_fees.delete')
->name('utility-fees.destroy');
```
---
## 4. 配置權限群組名稱 (Backend UI Config)
為了讓新權限在「角色與權限」管理介面中正確分組並顯示中文標題,需修改 Controller。
**位置**: `app/Modules/Core/Controllers/RoleController.php``getGroupedPermissions()`
```php
$groupDefinitions = [
'products' => '商品資料管理',
'warehouses' => '倉庫管理',
'inventory' => '庫存資料管理',
// ...
'utility_fees' => '公共事業費管理', // ✅ 新增此行
];
```
> [!NOTE]
> 未加入 `$groupDefinitions` 的權限群組仍會顯示,但標題會以原始 key英文呈現。
---
## 5. 前端權限判斷 (React)
### 5.1 方式一:`usePermission` Hook在邏輯中判斷
**位置**: `resources/js/hooks/usePermission.ts`
```tsx
import { usePermission } from "@/hooks/usePermission";
export default function ProductIndex() {
const { can, canAny, isSuperAdmin } = usePermission();
return (
<div>
{can('products.create') && <Button>新增商品</Button>}
{canAny(['products.edit', 'products.delete']) && <ManageDropdown />}
</div>
);
}
```
#### Hook 完整介面:
| 方法 | 說明 |
|---|---|
| `can(permission)` | 檢查是否擁有**指定**權限 |
| `canAny(permissions[])` | 檢查是否擁有**任一**權限 |
| `canAll(permissions[])` | 檢查是否擁有**所有**權限 |
| `hasRole(role)` | 檢查是否擁有**指定**角色 |
| `hasAnyRole(roles[])` | 檢查是否擁有**任一**角色 |
| `hasAllRoles(roles[])` | 檢查是否擁有**所有**角色 |
| `isSuperAdmin()` | 是否為超級管理員 |
> 所有方法對 `super-admin` 角色自動回傳 `true`
### 5.2 方式二:`<Can>` / `<HasRole>` / `<CanAll>` 元件(在 JSX 中包裹)
**位置**: `resources/js/Components/Permission/Can.tsx`
```tsx
import { Can, HasRole, CanAll } from '@/Components/Permission/Can';
// 單一權限
<Can permission="products.create">
<Button>新增商品</Button>
</Can>
// 任一權限OR 邏輯)
<Can permission={['products.edit', 'products.delete']}>
<ManageDropdown />
</Can>
// 所有權限都必須有AND 邏輯)
<CanAll permissions={['products.edit', 'products.delete']}>
<Button>完整管理</Button>
</CanAll>
// 角色判斷
<HasRole role="admin">
<Link href="/admin">管理後台</Link>
</HasRole>
// Fallback 支援
<Can permission="products.delete" fallback={<span className="text-gray-400">無權限</span>}>
<Button variant="destructive">刪除</Button>
</Can>
```
> [!IMPORTANT]
> UI 規範要求:所有可操作按鈕(新增、編輯、刪除)**必須**包裹 `<Can>` 元件或使用 `can()` 判斷。
> 詳見 [UI 統一規範](file:///home/mama/projects/star-erp/.agents/skills/ui-consistency/SKILL.md)。
---
## 6. 開發檢核清單 (Checklist)
### 後端
- [ ] `PermissionSeeder.php` 已新增權限字串(`'key' => '中文動作名稱'` 格式)。
- [ ] `PermissionSeeder.php` 已將新權限分配給 `admin` 及其他適用角色。
- [ ] 已執行 `./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder` 同步所有租戶。
- [ ] `RoleController.php``$groupDefinitions` 已新增權限群組中文名稱。
- [ ] 模組路由 (`app/Modules/{ModuleName}/Routes/web.php`) 已加上 `middleware('permission:...')` 保護。
### 前端
- [ ] 頁面按鈕已使用 `usePermission` Hook 或 `<Can>` 元件進行權限控制。
- [ ] 所有可操作按鈕都包裹於權限判斷中(符合 UI 統一規範)。

File diff suppressed because it is too large Load Diff