Compare commits

...

2 Commits

Author SHA1 Message Date
43d7cada34 fix: tenancy middleware order and ui consistency for user profile
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 44s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 11:56:44 +08:00
5b15ca2cd6 docs: 重新撰寫客戶端後台 UI 統一規範技能 2026-01-16 10:33:39 +08:00
17 changed files with 819 additions and 225 deletions

View File

@@ -1,69 +1,130 @@
--- ---
name: UI 統一規範 name: 客戶端後台 UI 統一規範
description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統一的樣式與行為 description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
--- ---
# UI 統一規範 # 客戶端後台 UI 統一規範
## 概述 ## 概述
skill 提供 koori-erp ERP 系統的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。 技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
## 核心原則 ## 核心原則
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的元件 1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別 2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標 3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構 4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
--- ---
## 1. 按鈕規範 ## 1. 專案結構
### 1.1 按鈕樣式類別 ### 1.1 關鍵目錄
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式類別,**必須**使用這些類別而非自定義樣式: ```
resources/
├── css/
│ └── app.css # 全域樣式與設計 Token
├── js/
│ ├── Components/
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
│ ├── Layouts/
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
│ └── Pages/ # 頁面元件
```
#### Filled 按鈕(實心按鈕) ### 1.2 可用 UI 元件清單
```
accordion, alert, alert-dialog, avatar, badge, breadcrumb, button,
calendar, card, carousel, chart, checkbox, collapsible, command,
context-menu, dialog, drawer, dropdown-menu, form, hover-card,
input, input-otp, label, menubar, navigation-menu, pagination,
popover, progress, radio-group, resizable, scroll-area,
searchable-select, select, separator, sheet, sidebar, skeleton,
slider, sonner, switch, table, tabs, textarea, toggle, toggle-group,
tooltip
```
---
## 2. 色彩系統
### 2.1 主題色 (Primary)
```css
--primary-main: #01ab83; /* 主題綠色 - 主要操作、連結 */
--primary-dark: #018a6a; /* 深綠色 - Hover 狀態 */
--primary-light: #33bc9a; /* 淺綠色 - 次要強調 */
--primary-lightest: #e6f7f3; /* 最淺綠色 - 背景、Active 狀態 */
```
### 2.2 灰階 (Grey Scale)
```css
--grey-0: #1a1a1a; /* 深黑 - 標題文字 */
--grey-1: #4a4a4a; /* 深灰 - 主要內文 */
--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */
--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */
--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */
--grey-5: #fff; /* 白色 - 背景、按鈕文字 */
```
### 2.3 狀態色 (State Colors)
```css
--other-success: #01ab83; /* 成功 - 同主題色 */
--other-error: #dc2626; /* 錯誤 - 刪除、警示 */
--other-warning: #f59e0b; /* 警告 - 提醒、注意 */
--other-info: #3b82f6; /* 資訊 - 說明、提示 */
```
---
## 3. 按鈕規範
### 3.1 按鈕樣式類別
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
#### Filled 按鈕(實心按鈕)— 用於主要操作
```tsx ```tsx
// 主要操作按鈕(綠色主題色) // 主要操作按鈕(綠色主題色)- 新增、儲存、確認
<Button className="button-filled-primary"> <Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
新增項目 新增項目
</Button> </Button>
// 成功操作 // 成功操作
<Button className="button-filled-success">確認</Button> <Button className="button-filled-success">確認</Button>
// 資訊操作 // 資訊操作
<Button className="button-filled-info">查看詳情</Button> <Button className="button-filled-info">查看詳情</Button>
// 警告操作 // 警告操作
<Button className="button-filled-warning">警告</Button> <Button className="button-filled-warning">警告</Button>
// 錯誤/刪除操作 // 錯誤/刪除操作AlertDialog 內確認按鈕)
<Button className="button-filled-error">刪除</Button> <Button className="button-filled-error">刪除</Button>
``` ```
#### Outlined 按鈕(邊框按鈕) #### Outlined 按鈕(邊框按鈕)— 用於次要操作
```tsx ```tsx
// 次要操作(主題色邊框 // ✅ 編輯按鈕(表格操作列
<Button variant="outline" size="sm" className="button-outlined-primary"> <Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
// 成功樣式邊框 // ✅ 刪除按鈕(表格操作列)
<Button className="button-outlined-success">成功</Button>
// 資訊樣式邊框
<Button className="button-outlined-info">資訊</Button>
// 警告樣式邊框
<Button className="button-outlined-warning">警告</Button>
// 錯誤/刪除樣式邊框
<Button variant="outline" size="sm" className="button-outlined-error"> <Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -72,22 +133,21 @@ description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統
#### Text 按鈕(文字按鈕) #### Text 按鈕(文字按鈕)
```tsx ```tsx
// 文字按鈕
<Button className="button-text-primary">查看更多</Button> <Button className="button-text-primary">查看更多</Button>
``` ```
### 1.2 按鈕大小 ### 3.2 按鈕大小
使用 shadcn/ui Button 組件的標準尺寸: | Size | 高度 | 使用情境 |
|------|------|----------|
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
| `size="default"` | h-9 | 一般操作、表單提交 |
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
| `size="icon"` | 9×9 | 純圖標按鈕 |
- `size="sm"`小型按鈕h-8用於表格操作列 ### 3.3 常見操作按鈕模式
- `size="default"`預設按鈕h-9用於一般操作
- `size="lg"`大型按鈕h-10用於主要 CTA
- `size="icon"`圖標按鈕size-9僅含圖標的正方形按鈕
### 1.3 常見操作按鈕模式 #### 頁面頂部新增按鈕
#### 新增按鈕(頁面頂部)
```tsx ```tsx
<Can permission="resource.create"> <Can permission="resource.create">
@@ -100,7 +160,7 @@ description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統
</Can> </Can>
``` ```
#### 編輯按鈕(表格操作列 #### 表格操作列編輯按鈕
```tsx ```tsx
<Can permission="resource.edit"> <Can permission="resource.edit">
@@ -117,7 +177,7 @@ description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統
</Can> </Can>
``` ```
#### 刪除按鈕(表格操作列,帶確認對話框) #### 表格操作列刪除按鈕(帶確認對話框)
```tsx ```tsx
<Can permission="resource.delete"> <Can permission="resource.delete">
@@ -155,19 +215,22 @@ description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統
--- ---
## 2. 圖標規範 ## 4. 圖標規範
### 2.1 圖標庫 ### 4.1 統一使用 lucide-react
**統一使用 `lucide-react`**使用其他圖標庫(如 FontAwesome、Material Icons 等)。 **統一使用 `lucide-react`**禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。
### 2.2 圖標尺寸標準 ### 4.2 圖標尺寸標準
- **小型圖標**`h-3 w-3`(用於 Badge、小文字旁 | 尺寸 | 類別 | 使用情境 |
- **標準圖標**`h-4 w-4`(用於按鈕、表格操作) |------|------|----------|
- **標題圖標**`h-6 w-6`(用於頁面標題) | 小型 | `h-3 w-3` | Badge 內、小文字旁 |
| 標準 | `h-4 w-4` | 按鈕內、表格操作 |
| 標題 | `h-5 w-5` | 側邊欄選單 |
| 大型 | `h-6 w-6` | 頁面標題 |
### 2.3 常用操作圖標映射 ### 4.3 常用操作圖標映射
| 操作 | 圖標組件 | 使用情境 | | 操作 | 圖標組件 | 使用情境 |
|------|----------|----------| |------|----------|----------|
@@ -185,11 +248,16 @@ description: 確保 koori-erp ERP 系統後台所有頁面的 UI 元件保持統
| 使用者 | `<Users />`, `<User />` | 使用者管理 | | 使用者 | `<Users />`, `<User />` | 使用者管理 |
| 權限 | `<Shield />` | 角色/權限 | | 權限 | `<Shield />` | 角色/權限 |
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 | | 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
| 商品 | `<Package />` | 商品管理 |
| 倉庫 | `<Warehouse />` | 倉庫管理 |
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
| 採購 | `<ShoppingCart />` | 採購管理 |
### 2.4 圖標使用範例 ### 4.4 圖標使用範例
```tsx ```tsx
import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react'; import { Plus, Pencil, Trash2, Users } from 'lucide-react';
// 頁面標題 // 頁面標題
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
@@ -197,7 +265,7 @@ import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
使用者管理 使用者管理
</h1> </h1>
// 按鈕內圖標 // 按鈕內圖標(圖標在左,帶文字)
<Button className="button-filled-primary"> <Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
新增使用者 新增使用者
@@ -211,21 +279,9 @@ import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
--- ---
## 3. 表格規範 ## 5. 表格規範
### 3.1 表格容器 ### 5.1 表格容器
使用統一的表格包裝樣式:
```tsx
<div className="bg-white rounded-lg shadow-sm border">
<Table>
{/* 表格內容 */}
</Table>
</div>
```
或使用更精緻的樣式(用於管理頁面):
```tsx ```tsx
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
@@ -235,20 +291,13 @@ import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
</div> </div>
``` ```
### 3.2 表格標題列 ### 5.2 表格標題列
```tsx ```tsx
<TableHeader className="bg-gray-50"> <TableHeader className="bg-gray-50">
<TableRow> <TableRow>
<TableHead className="w-[50px] text-center">#</TableHead> <TableHead className="w-[50px] text-center">#</TableHead>
<TableHead> <TableHead>名稱</TableHead>
<button
onClick={() => onSort("name")}
className="flex items-center hover:text-gray-900 font-semibold"
>
名稱 <SortIcon field="name" />
</button>
</TableHead>
<TableHead className="text-center">操作</TableHead> <TableHead className="text-center">操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -257,33 +306,15 @@ import { Plus, Pencil, Trash2, Eye, Search } from 'lucide-react';
**關鍵要點** **關鍵要點**
- 使用 `bg-gray-50` 背景色 - 使用 `bg-gray-50` 背景色
- 序號欄位固定寬度 `w-[50px]` 並置中 - 序號欄位固定寬度 `w-[50px]` 並置中
- 可排序欄位使用 `<button>` 包裹,加上 hover 效果
- 操作欄位置中顯示 - 操作欄位置中顯示
### 3.3 排序圖標元件 ### 5.3 表格主體
```tsx
const SortIcon = ({ field }: { field: string }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
```
### 3.4 表格主體
```tsx ```tsx
<TableBody> <TableBody>
{items.length === 0 ? ( {items.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-gray-500"> <TableCell colSpan={5} className="text-center py-8 text-gray-500">
無符合條件的資料 無符合條件的資料
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -295,7 +326,7 @@ const SortIcon = ({ field }: { field: string }) => {
</TableCell> </TableCell>
{/* 其他欄位 */} {/* 其他欄位 */}
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{/* 操作按鈕 */} {/* 操作按鈕 */}
</div> </div>
</TableCell> </TableCell>
@@ -308,44 +339,19 @@ const SortIcon = ({ field }: { field: string }) => {
**關鍵要點** **關鍵要點**
- 空狀態訊息使用置中、灰色文字 - 空狀態訊息使用置中、灰色文字
- 序號欄使用 `text-gray-500 font-medium text-center` - 序號欄使用 `text-gray-500 font-medium text-center`
- 操作欄使用 `flex justify-center gap-2` 排列按鈕 - 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
### 3.5 操作欄按鈕組
```tsx
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Can permission="resource.edit">
<Link href={route('resource.edit', item.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="resource.delete">
<AlertDialog>
{/* 刪除確認對話框 */}
</AlertDialog>
</Can>
</div>
</TableCell>
```
--- ---
## 4. 分頁規範 ## 6. 分頁規範
### 4.1 統一分頁元件 ### 6.1 統一分頁元件
使用 `@/Components/shared/Pagination` 元件: 使用 `@/Components/shared/Pagination` 元件:
```tsx ```tsx
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
// 在表格下方 // 在表格下方
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
@@ -369,7 +375,7 @@ import Pagination from "@/Components/shared/Pagination";
</div> </div>
``` ```
### 4.2 每頁筆數狀態管理 ### 6.2 每頁筆數狀態管理
```tsx ```tsx
const [perPage, setPerPage] = useState<string>(filters.per_page || "10"); const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
@@ -386,19 +392,15 @@ const handlePerPageChange = (value: string) => {
--- ---
## 5. Badge 與狀態顯示 ## 7. Badge 與狀態顯示
### 5.1 基本 Badge ### 7.1 基本 Badge
使用 `@/Components/ui/badge`
```tsx ```tsx
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
// Outline 樣式(最常用) // Outline 樣式(最常用)
<Badge variant="outline"> <Badge variant="outline">{item.category?.name || '-'}</Badge>
{item.category?.name || '-'}
</Badge>
// 預設樣式(主題色背景) // 預設樣式(主題色背景)
<Badge variant="default">啟用中</Badge> <Badge variant="default">啟用中</Badge>
@@ -407,9 +409,7 @@ import { Badge } from "@/Components/ui/badge";
<Badge variant="destructive">停用</Badge> <Badge variant="destructive">停用</Badge>
``` ```
### 5.2 角色顯示(特殊樣式) ### 7.2 角色顯示(特殊樣式)
參考使用者管理的角色顯示模式:
```tsx ```tsx
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -439,9 +439,32 @@ import { Badge } from "@/Components/ui/badge";
--- ---
## 6. 頁面佈局規範 ## 8. 頁面佈局規範
### 6.1 標準頁面頭部 ### 8.1 頁面結構
```tsx
export default function ResourceIndex() {
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '分類名稱', href: '#' },
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
]}
>
<Head title="頁面標題" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面頭部 */}
{/* 主要內容 */}
{/* 分頁元件 */}
</div>
</AuthenticatedLayout>
);
}
```
### 8.2 標準頁面頭部
```tsx ```tsx
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -465,19 +488,11 @@ import { Badge } from "@/Components/ui/badge";
</div> </div>
``` ```
### 6.2 容器寬度
```tsx
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面內容 */}
</div>
```
--- ---
## 7. 權限控制規範 ## 9. 權限控制規範
### 7.1 使用 Can 元件 ### 9.1 使用 Can 元件
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹: **所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
@@ -497,21 +512,36 @@ import { Can } from "@/Components/Permission/Can";
</Can> </Can>
``` ```
### 7.2 權限命名規範 ### 9.2 權限命名規範
遵循 `resource.action` 格式: 遵循 `resource.action` 格式:
- `resource.index`:查看列表 - `resource.view`:查看列表/詳情
- `resource.show`:查看詳情
- `resource.create`:新增 - `resource.create`:新增
- `resource.edit`:編輯 - `resource.edit`:編輯
- `resource.delete`:刪除 - `resource.delete`:刪除
### 9.3 多權限判斷
```tsx
// 滿足任一權限即可
<Can permission={['products.edit', 'products.delete']}>
<div>管理操作</div>
</Can>
// 必須滿足所有權限
import { CanAll } from "@/Components/Permission/Can";
<CanAll permissions={['products.edit', 'products.delete']}>
<button>完整管理</button>
</CanAll>
```
--- ---
## 8. 通知訊息規範 ## 10. 通知訊息規範
### 8.1 使用 Toast 通知 ### 10.1 使用 Toast 通知
使用 `sonner``toast` 進行通知: 使用 `sonner``toast` 進行通知:
@@ -531,7 +561,7 @@ toast.info('提示訊息');
toast.warning('警告訊息'); toast.warning('警告訊息');
``` ```
### 8.2 常見操作的 Toast 訊息 ### 10.2 常見操作的 Toast 訊息
```tsx ```tsx
// 新增成功 // 新增成功
@@ -555,38 +585,55 @@ router.delete(route('resource.destroy', id), {
--- ---
## 9. 顏色系統 ## 11. 表單規範
### 9.1 主題色 ### 11.1 表單容器
參考 `resources/css/app.css` 中的色彩定義: ```tsx
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
```css <form onSubmit={handleSubmit}>
--primary-main: #01ab83; /* 主題綠色 */ {/* 表單欄位 */}
--primary-dark: #018a6a; /* 深綠色 */ </form>
--primary-light: #33bc9a; /* 淺綠色 */ </div>
--primary-lightest: #e6f7f3; /* 最淺綠色背景 */
--grey-0: #1a1a1a; /* 深黑色文字 */
--grey-1: #4a4a4a; /* 深灰色文字 */
--grey-2: #6b6b6b; /* 中灰色文字 */
--grey-3: #9e9e9e; /* 淺灰色文字 */
--grey-4: #e0e0e0; /* 邊框灰色 */
--grey-5: #fff; /* 白色 */
``` ```
### 9.2 狀態色 ### 11.2 表單欄位
```css ```tsx
--other-success: #01ab83; /* 成功(同主題色)*/ <div>
--other-error: #dc2626; /* 錯誤紅色 */ <label className="block text-sm font-medium text-slate-700 mb-2">
--other-warning: #f59e0b; /* 警告橙色 */ 欄位名稱 <span className="text-red-500">*</span>
--other-info: #3b82f6; /* 資訊藍色 */ </label>
<input
type="text"
value={data.field}
onChange={(e) => setData("field", e.target.value)}
placeholder="請輸入..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{errors.field && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
</div>
```
### 11.3 下拉選單
使用 `SearchableSelect` 元件:
```tsx
import { SearchableSelect } from "@/Components/ui/searchable-select";
<SearchableSelect
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))}
placeholder="請選擇分類"
searchThreshold={10} // 超過 10 個選項才顯示搜尋框
/>
``` ```
--- ---
## 10. 檢查清單 ## 12. 檢查清單
在開發或審查頁面時,請確認以下項目: 在開發或審查頁面時,請確認以下項目:
@@ -596,27 +643,24 @@ router.delete(route('resource.destroy', id), {
- [ ] 編輯操作使用 `button-outlined-primary` - [ ] 編輯操作使用 `button-outlined-primary`
- [ ] 刪除操作使用 `button-outlined-error` - [ ] 刪除操作使用 `button-outlined-error`
- [ ] 按鈕尺寸正確sm/default/lg - [ ] 按鈕尺寸正確sm/default/lg
- [ ] 包含適當的圖標(左側或單一圖標) - [ ] 包含適當的圖標
### ✅ 圖標 ### ✅ 圖標
- [ ] 全部使用 `lucide-react` - [ ] 全部使用 `lucide-react`
- [ ] 尺寸正確h-3/h-4/h-6 w-3/w-4/w-6 - [ ] 尺寸正確h-3/h-4/h-5/h-6
- [ ] 顏色與上下文一致 - [ ] 顏色與上下文一致
- [ ] 有明確的語義(編輯=Pencil、刪除=Trash2 等)
### ✅ 表格 ### ✅ 表格
- [ ] 使用 `@/Components/ui/table` 元件 - [ ] 使用 `@/Components/ui/table` 元件
- [ ] 有 `bg-white rounded-lg shadow-sm border` 容器 - [ ] 有 `bg-white rounded-xl border` 容器
- [ ] 標題列有 `bg-gray-50` 背景 - [ ] 標題列有 `bg-gray-50` 背景
- [ ] 序號欄固定寬度並置中 - [ ] 序號欄固定寬度並置中
- [ ] 操作欄使用 `flex justify-center gap-2` - [ ] 操作欄使用 `flex justify-center gap-2`
- [ ] 空狀態訊息置中顯示 - [ ] 空狀態訊息置中顯示
- [ ] 可排序欄位有排序圖標
### ✅ 分頁 ### ✅ 分頁
- [ ] 使用 `@/Components/shared/Pagination` - [ ] 使用 `@/Components/shared/Pagination`
- [ ] 有每頁筆數選擇器10/20/50/100 - [ ] 有每頁筆數選擇器10/20/50/100
- [ ] 佈局為 `flex justify-between`
### ✅ 權限 ### ✅ 權限
- [ ] 所有操作按鈕都用 `<Can>` 包裹 - [ ] 所有操作按鈕都用 `<Can>` 包裹
@@ -629,23 +673,21 @@ router.delete(route('resource.destroy', id), {
### ✅ 整體 ### ✅ 整體
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕) - [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
- [ ] 容器寬度使用 `max-w-7xl` - [ ] 容器寬度使用 `max-w-7xl`
- [ ] 色彩使用符合主題 - [ ] 使用正確的佈局(`AuthenticatedLayout`
--- ---
## 11. 常見錯誤與修正 ## 13. 常見錯誤與修正
### ❌ 錯誤:自定義按鈕樣式 ### ❌ 錯誤:自定義按鈕樣式
```tsx ```tsx
// 錯誤 // 錯誤
<Button className="bg-green-500 text-white hover:bg-green-600"> <Button className="bg-green-500 text-white hover:bg-green-600">
新增 新增
</Button> </Button>
```
```tsx // ✅ 正確
// 正確
<Button className="button-filled-primary"> <Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
新增 新增
@@ -655,33 +697,27 @@ router.delete(route('resource.destroy', id), {
### ❌ 錯誤:混用圖標庫 ### ❌ 錯誤:混用圖標庫
```tsx ```tsx
// 錯誤 // 錯誤
import { FaEdit } from 'react-icons/fa'; // ❌ 不使用 react-icons import { FaEdit } from 'react-icons/fa';
<FaEdit /> <FaEdit />
```
```tsx
// 正確
import { Pencil } from 'lucide-react'; // ✅ 使用 lucide-react
// ✅ 正確
import { Pencil } from 'lucide-react';
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
``` ```
### ❌ 錯誤:操作欄未置中 ### ❌ 錯誤:操作欄未置中
```tsx ```tsx
// 錯誤 // 錯誤
<TableCell> <TableCell>
<Button>編輯</Button> <Button>編輯</Button>
<Button>刪除</Button> <Button>刪除</Button>
</TableCell> </TableCell>
```
```tsx // ✅ 正確
// 正確
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" className="button-outlined-primary"> <Button variant="outline" size="sm" className="button-outlined-primary">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -695,12 +731,10 @@ import { Pencil } from 'lucide-react'; // ✅ 使用 lucide-react
### ❌ 錯誤:缺少權限控制 ### ❌ 錯誤:缺少權限控制
```tsx ```tsx
// 錯誤 // 錯誤
<Button onClick={handleDelete}>刪除</Button> <Button onClick={handleDelete}>刪除</Button>
```
```tsx // ✅ 正確
// 正確
<Can permission="resource.delete"> <Can permission="resource.delete">
<Button <Button
variant="outline" variant="outline"
@@ -715,15 +749,14 @@ import { Pencil } from 'lucide-react'; // ✅ 使用 lucide-react
--- ---
## 12. 實際範例 ## 14. 參考範例
參考以下頁面作為標準實作 以下頁面展示了完整的 UI 統一性實踐
- **使用者管理**`resources/js/Pages/Admin/User/Index.tsx` - **使用者管理**`resources/js/Pages/Admin/User/Index.tsx`
- **角色管理**`resources/js/Pages/Admin/Role/Index.tsx`
- **產品管理**`resources/js/Pages/Product/Index.tsx` - **產品管理**`resources/js/Pages/Product/Index.tsx`
- **產品表格**`resources/js/Components/Product/ProductTable.tsx` - **倉庫管理**`resources/js/Pages/Warehouse/Index.tsx`
這些頁面展示了完整的 UI 統一性實踐,包括按鈕、圖標、表格、分頁、權限控制等所有元素。
--- ---
@@ -735,5 +768,6 @@ import { Pencil } from 'lucide-react'; // ✅ 使用 lucide-react
2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局 2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局
3. ✅ **開發速度**:有明確的模式可循,減少決策時間 3. ✅ **開發速度**:有明確的模式可循,減少決策時間
4. ✅ **使用者體驗**:一致的互動模式降低學習成本 4. ✅ **使用者體驗**:一致的互動模式降低學習成本
5. ✅ **安全性**:統一的權限控制確保資料安全
當你在開發或審查 koori-erp 的 UI 時,請務必參考此規範,確保每個元件都符合既定的標準。 當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
class ProfileController extends Controller
{
/**
* 顯示使用者設定頁面
*/
public function edit(Request $request)
{
return Inertia::render('Landlord/Profile/Edit', [
'user' => $request->user(),
]);
}
/**
* 更新使用者基本資料
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('success', '個人資料已更新');
}
/**
* 更新密碼
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('success', '密碼已更新');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
class ProfileController extends Controller
{
/**
* 顯示使用者設定頁面
*/
public function edit(Request $request)
{
return Inertia::render('Profile/Edit', [
'user' => $request->user(),
]);
}
/**
* 更新使用者基本資料
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('success', '個人資料已更新');
}
/**
* 更新密碼
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('success', '密碼已更新');
}
}

View File

@@ -47,6 +47,7 @@ class HandleInertiaRequests extends Middleware
'username' => $user->username ?? null, 'username' => $user->username ?? null,
// 權限資料 // 權限資料
'roles' => $user->getRoleNames(), 'roles' => $user->getRoleNames(),
'role_labels' => $user->roles->pluck('display_name'),
'permissions' => $user->getAllPermissions()->pluck('name')->toArray(), 'permissions' => $user->getAllPermissions()->pluck('name')->toArray(),
] : null, ] : null,
], ],

View File

@@ -18,8 +18,11 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [ // Tenancy 必須最先執行,確保資料庫連線在 Session 讀取之前建立
$middleware->web(prepend: [
\App\Http\Middleware\UniversalTenancy::class, \App\Http\Middleware\UniversalTenancy::class,
]);
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
]); ]);

View File

@@ -351,7 +351,7 @@ export default function AuthenticatedLayout({
{user.name} {user.name}
</span> </span>
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
{user.username || 'Administrator'} {user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span> </span>
</div> </div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all"> <div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
@@ -359,7 +359,17 @@ export default function AuthenticatedLayout({
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}> <DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link

View File

@@ -5,6 +5,9 @@ import {
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
User, User,
Menu,
X,
Settings,
} from "lucide-react"; } from "lucide-react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { import {
@@ -16,7 +19,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu"; } from "@/Components/ui/dropdown-menu";
import { useState } from "react"; import { useState } from "react";
import { Menu, X } from "lucide-react";
interface LandlordLayoutProps { interface LandlordLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -114,24 +116,36 @@ export default function LandlordLayout({ children, title }: LandlordLayoutProps)
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group"> <DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="flex flex-col items-end mr-1"> <div className="flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700"> <span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} {user.name}
</span> </span>
<span className="text-xs text-slate-500"></span> <span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '系統管理員'}
</span>
</div> </div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center"> <div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5 text-slate-600" /> <User className="h-5 w-5" />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]"> <DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel></DropdownMenuLabel> <DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('landlord.profile.edit')}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
href={route('logout')} href={route('logout')}
method="post" method="post"
as="button" as="button"
className="w-full flex items-center cursor-pointer text-red-600" className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span></span> <span></span>

View File

@@ -39,7 +39,9 @@ export default function Dashboard({ totalTenants, activeTenants, recentTenants }
]; ];
return ( return (
<LandlordLayout title="儀表板"> <LandlordLayout
title="儀表板"
>
<div className="space-y-6"> <div className="space-y-6">
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">

View File

@@ -0,0 +1,195 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { Head, useForm } from "@inertiajs/react";
import { User, Lock, Mail } from "lucide-react";
import { FormEvent } from "react";
import { toast } from "sonner";
interface User {
id: number;
name: string;
email: string;
username: string;
}
interface Props {
user: User;
}
export default function Edit({ user }: Props) {
// 個人資料表單
const { data: profileData, setData: setProfileData, patch: patchProfile, processing: profileProcessing, errors: profileErrors } = useForm({
name: user.name,
username: user.username || "",
email: user.email || "",
});
// 密碼表單
const { data: passwordData, setData: setPasswordData, put: putPassword, processing: passwordProcessing, errors: passwordErrors, reset: resetPassword } = useForm({
current_password: "",
password: "",
password_confirmation: "",
});
const handleProfileSubmit = (e: FormEvent) => {
e.preventDefault();
patchProfile(route('landlord.profile.update'), {
onSuccess: () => toast.success('個人資料已更新'),
onError: () => toast.error('更新失敗,請檢查輸入內容'),
});
};
const handlePasswordSubmit = (e: FormEvent) => {
e.preventDefault();
putPassword(route('landlord.profile.password'), {
onSuccess: () => {
toast.success('密碼已更新');
resetPassword();
},
onError: () => toast.error('密碼更新失敗'),
});
};
return (
<LandlordLayout
title="使用者設定"
>
<Head title="使用者設定" />
<div className="max-w-4xl mx-auto space-y-6">
{/* 頁面標題 */}
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<User className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-slate-500 mt-1"></p>
</div>
{/* 個人資料區塊 */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<User className="h-5 w-5 text-slate-600" />
</h2>
</div>
<form onSubmit={handleProfileSubmit} className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
() <span className="text-red-500">*</span>
</label>
<input
type="text"
value={profileData.username}
onChange={(e) => setProfileData("username", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
placeholder="請輸入登入帳號"
/>
{profileErrors.username && <p className="mt-1 text-sm text-red-500">{profileErrors.username}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
使 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={profileData.name}
onChange={(e) => setProfileData("name", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{profileErrors.name && <p className="mt-1 text-sm text-red-500">{profileErrors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Email ()
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<input
type="email"
value={profileData.email}
onChange={(e) => setProfileData("email", e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
placeholder="example@mail.com"
/>
</div>
{profileErrors.email && <p className="mt-1 text-sm text-red-500">{profileErrors.email}</p>}
</div>
<div className="flex items-center gap-4 pt-4 border-t border-slate-200">
<button
type="submit"
disabled={profileProcessing}
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
>
{profileProcessing ? "儲存中..." : "儲存變更"}
</button>
</div>
</form>
</div>
{/* 密碼變更區塊 */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Lock className="h-5 w-5 text-slate-600" />
</h2>
</div>
<form onSubmit={handlePasswordSubmit} className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.current_password}
onChange={(e) => setPasswordData("current_password", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{passwordErrors.current_password && <p className="mt-1 text-sm text-red-500">{passwordErrors.current_password}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.password}
onChange={(e) => setPasswordData("password", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
{passwordErrors.password && <p className="mt-1 text-sm text-red-500">{passwordErrors.password}</p>}
<p className="mt-1 text-sm text-slate-500"> 8 </p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.password_confirmation}
onChange={(e) => setPasswordData("password_confirmation", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
/>
</div>
<div className="flex items-center gap-4 pt-4 border-t border-slate-200">
<button
type="submit"
disabled={passwordProcessing}
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
>
{passwordProcessing ? "更新中..." : "更新密碼"}
</button>
</div>
</form>
</div>
</div>
</LandlordLayout>
);
}

View File

@@ -16,7 +16,9 @@ export default function TenantCreate() {
}; };
return ( return (
<LandlordLayout title="新增客戶"> <LandlordLayout
title="新增客戶"
>
<div className="max-w-2xl"> <div className="max-w-2xl">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"></h1> <h1 className="text-2xl font-bold text-slate-900"></h1>

View File

@@ -26,7 +26,9 @@ export default function TenantEdit({ tenant }: Props) {
}; };
return ( return (
<LandlordLayout title="編輯客戶"> <LandlordLayout
title="編輯客戶"
>
<div className="max-w-2xl"> <div className="max-w-2xl">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"></h1> <h1 className="text-2xl font-bold text-slate-900"></h1>

View File

@@ -37,7 +37,9 @@ export default function TenantIndex({ tenants }: Props) {
}; };
return ( return (
<LandlordLayout title="客戶管理"> <LandlordLayout
title="客戶管理"
>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -45,7 +45,9 @@ export default function TenantShow({ tenant }: Props) {
}; };
return ( return (
<LandlordLayout title="客戶詳情"> <LandlordLayout
title="客戶詳情"
>
<div className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
{/* Back Link */} {/* Back Link */}
<Link <Link

View File

@@ -0,0 +1,205 @@
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, useForm } from "@inertiajs/react";
import { User, Lock, Mail } from "lucide-react";
import { FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/Components/ui/button";
interface User {
id: number;
name: string;
email: string | null;
username: string;
}
interface Props {
user: User;
}
export default function Edit({ user }: Props) {
// 個人資料表單
const { data: profileData, setData: setProfileData, patch: patchProfile, processing: profileProcessing, errors: profileErrors } = useForm({
name: user.name,
username: user.username,
email: user.email || "",
});
// 密碼表單
const { data: passwordData, setData: setPasswordData, put: putPassword, processing: passwordProcessing, errors: passwordErrors, reset: resetPassword } = useForm({
current_password: "",
password: "",
password_confirmation: "",
});
const handleProfileSubmit = (e: FormEvent) => {
e.preventDefault();
patchProfile(route('profile.update'), {
onSuccess: () => toast.success('個人資料已更新'),
onError: () => toast.error('更新失敗,請檢查輸入內容'),
});
};
const handlePasswordSubmit = (e: FormEvent) => {
e.preventDefault();
putPassword(route('profile.password'), {
onSuccess: () => {
toast.success('密碼已更新');
resetPassword();
},
onError: () => toast.error('密碼更新失敗'),
});
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '使用者設定', href: route('profile.edit'), isPage: true },
]}
>
<Head title="使用者設定" />
<div className="container mx-auto p-6 max-w-7xl space-y-6">
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<User className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="grid grid-cols-1 gap-6">
{/* 個人資料區塊 */}
<section className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
<h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
<User className="h-5 w-5 text-slate-400" />
</h2>
</div>
<form onSubmit={handleProfileSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={profileData.username}
onChange={(e) => setProfileData("username", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
placeholder="請輸入登入帳號"
/>
{profileErrors.username && <p className="mt-1 text-sm text-red-500">{profileErrors.username}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
使 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={profileData.name}
onChange={(e) => setProfileData("name", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
placeholder="請輸入姓名"
/>
{profileErrors.name && <p className="mt-1 text-sm text-red-500">{profileErrors.name}</p>}
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
()
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<input
type="email"
value={profileData.email}
onChange={(e) => setProfileData("email", e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
placeholder="example@mail.com"
/>
</div>
{profileErrors.email && <p className="mt-1 text-sm text-red-500">{profileErrors.email}</p>}
</div>
</div>
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={profileProcessing}
className="button-filled-primary"
>
{profileProcessing ? "儲存中..." : "儲存變更"}
</Button>
</div>
</form>
</section>
{/* 密碼變更區塊 */}
<section className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
<h2 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
<Lock className="h-5 w-5 text-slate-400" />
</h2>
</div>
<form onSubmit={handlePasswordSubmit} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.current_password}
onChange={(e) => setPasswordData("current_password", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
/>
{passwordErrors.current_password && <p className="mt-1 text-sm text-red-500">{passwordErrors.current_password}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.password}
onChange={(e) => setPasswordData("password", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
/>
{passwordErrors.password && <p className="mt-1 text-sm text-red-500">{passwordErrors.password}</p>}
<p className="mt-1 text-xs text-slate-500">使 8 </p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
value={passwordData.password_confirmation}
onChange={(e) => setPasswordData("password_confirmation", e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main outline-none transition-all"
/>
</div>
</div>
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={passwordProcessing}
className="button-filled-primary"
>
{passwordProcessing ? "更新中..." : "更新密碼"}
</Button>
</div>
</form>
</section>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -7,6 +7,7 @@ export interface AuthUser {
email: string; email: string;
username?: string; username?: string;
roles: string[]; roles: string[];
role_labels: string[];
permissions: string[]; permissions: string[];
} }

View File

@@ -3,6 +3,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Landlord\DashboardController; use App\Http\Controllers\Landlord\DashboardController;
use App\Http\Controllers\Landlord\TenantController; use App\Http\Controllers\Landlord\TenantController;
use App\Http\Controllers\Landlord\ProfileController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -21,6 +22,11 @@ Route::prefix('landlord')->name('landlord.')->middleware(['web', 'auth', \App\Ht
// 租戶管理 CRUD // 租戶管理 CRUD
Route::resource('tenants', TenantController::class); Route::resource('tenants', TenantController::class);
// 使用者設定
Route::get('profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('profile', [ProfileController::class, 'update'])->name('profile.update');
Route::put('profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
// 租戶域名管理 // 租戶域名管理
Route::post('tenants/{tenant}/domains', [TenantController::class, 'addDomain'])->name('tenants.domains.store'); Route::post('tenants/{tenant}/domains', [TenantController::class, 'addDomain'])->name('tenants.domains.store');
Route::delete('tenants/{tenant}/domains/{domain}', [TenantController::class, 'removeDomain'])->name('tenants.domains.destroy'); Route::delete('tenants/{tenant}/domains/{domain}', [TenantController::class, 'removeDomain'])->name('tenants.domains.destroy');

View File

@@ -16,6 +16,7 @@ use App\Http\Controllers\TransferOrderController;
use App\Http\Controllers\UnitController; use App\Http\Controllers\UnitController;
use App\Http\Controllers\Admin\RoleController; use App\Http\Controllers\Admin\RoleController;
use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\ProfileController;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
// 登入/登出路由 // 登入/登出路由
@@ -27,6 +28,11 @@ Route::middleware('auth')->group(function () {
// 儀表板 - 所有登入使用者皆可存取 // 儀表板 - 所有登入使用者皆可存取
Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// 使用者帳號設定
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
// 類別管理 (用於商品對話框) - 需要商品權限 // 類別管理 (用於商品對話框) - 需要商品權限
Route::middleware('permission:products.view')->group(function () { Route::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');