文、意如
管理者登入頁

登入後到管理者端首頁

管理者端
一、專案根目錄 .env
檔加入:
JWT_SECRET=你的超強密鑰
JWT_SECRET
通常是固定的機密值,放在 .env
環境變數中,並具備以下特性與注意事項:
它是做什麼用的?
這是一段機密字串,用來讓伺服器簽名(sign
)與驗證(verify
)JWT:
🔐 簽名:當你產生一個 JWT 時,會用這個密鑰來加密簽章部分。
✅ 驗證:當你收到一個 JWT,要用同一組密鑰來驗證這個 token 是不是你自己產生的,並沒有被竄改。
.env
檔應該像這樣:
DATABASE_URL="mysql://..."
JWT_SECRET="9e6f1f3cc7a2b15b8d2a85ea5bb6d00dbb5c80e7b4b379cefc0c2878eeff9132"
JWT_SECRET=b63c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f7a2b9e0c5d7f1a3e6b9c8d1e4f
二、管理者端目錄:src/app/admin
下的頁面,都需要登入才能瀏覽
建立src/app/admin/layout.tsx
// src/app/admin/layout.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import jwt, { JwtPayload } from "jsonwebtoken";
// 確保 JWT_SECRET 是從環境變數中加載的。
// 在 Server Components 中,process.env 會自動從 .env.local 等文件加載。
// 如果 JWT_SECRET 未設定,這將導致運行時錯誤,這是期望的行為,因為它是應用程式安全的核心。
const SECRET = process.env.JWT_SECRET;
// 定義 JWT payload 的類型,以獲得更好的類型安全
interface AdminTokenPayload extends JwtPayload {
pk: number;
username: string;
role: string;
// 根據你的 JWT 簽發時包含的聲明添加其他屬性
}
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// 確保 SECRET 已經被設定。在生產環境中,如果這個變數不存在,應該會是一個配置問題。
if (!SECRET) {
console.error("JWT_SECRET 環境變數未設定!請檢查您的 .env.local 或部署環境。");
// 在實際應用中,你可能希望這裡導向一個錯誤頁面,而不是直接到登入頁,
// 因為這表示伺服器端配置有問題。
redirect("/login?error=server_config_error");
}
const cookieStore = cookies();
const token = (await cookieStore).get("admin_token")?.value;
// 如果沒有 token,直接重定向到登入頁面
if (!token) {
redirect("/login");
}
try {
// 驗證 token。如果驗證失敗(例如過期、簽章不符),會拋出錯誤。
const payload = jwt.verify(token, SECRET) as AdminTokenPayload;
// **重要:檢查角色** - 確保只有 'admin' 角色能訪問此佈局下的內容
if (payload.role !== "admin") {
console.warn(`用戶 ${payload.username} (PK: ${payload.pk}) 嘗試以非管理員角色訪問管理後台。`);
redirect("/login?error=permission_denied"); // 導向登入頁並帶錯誤訊息
}
// 可選:你可以在這裡檢查 token 是否即將過期,以決定是否需要一個刷新機制。
// 但這通常在客户端進行更為合適,或者在後端 API 響應時提供新的 access token。
// const currentTime = Math.floor(Date.now() / 1000);
// if (payload.exp && payload.exp - currentTime < 300) { // 如果 token 在 5 分鐘內過期
// console.log("Admin token 即將過期,考慮實作刷新機制。");
// }
} catch (err: any) {
// 處理 JWT 驗證錯誤
console.error("JWT 驗證失敗:", err.message);
// 根據錯誤類型提供更具體的重定向訊息
if (err.name === 'TokenExpiredError') {
redirect("/login?error=token_expired");
} else if (err.name === 'JsonWebTokenError') {
redirect("/login?error=invalid_token");
} else {
// 處理其他未預期的 JWT 相關錯誤
redirect("/login?error=auth_failed");
}
}
// 如果 token 驗證成功且角色正確,則渲染子組件
return <>{children}</>;
}
初次頁面載入或直接 URL 訪問 (Hard Navigation):
- 當用戶第一次直接在瀏覽器中輸入像
/admin
、/admin/dashboard
或/admin/products
這樣的 URL 時,或在其他網站點擊連結導向這些頁面時。 - Next.js 伺服器會接收到這個請求,然後會執行
src/app/admin/layout.tsx
中的程式碼。 - 它會在此時檢查
JWT_SECRET
是否已設定,並嘗試從請求中讀取admin_token
這個 Cookie。 - 如果
admin_token
無效或不存在,redirect("/login")
會被觸發,伺服器會發送一個 HTTP 重定向響應,指示瀏覽器跳轉到登入頁面。 - 如果
admin_token
有效且用戶角色正確,伺服器會繼續渲染該佈局中的子頁面 ({children}
,也就是對應的page.tsx
),並將最終的 HTML 發送給瀏覽器。
客戶端導航 (Soft Navigation / Client-side Transitions):
- 當用戶已經在
/admin
路由下的某個頁面 (例如/admin/dashboard
),然後點擊頁面上的 Next.js<Link>
元件,或使用router.push()
方法導航到/admin
下的其他頁面 (例如/admin/products
) 時。 - 即使是客戶端導航,由於
admin/layout.tsx
是 Server Component,Next.js 仍然會在伺服器上執行其邏輯,以確保用戶在每次導航到管理後台的不同頁面時,其身份驗證和權限都會被重新驗證。 - 這確保了即使 JWT 在用戶瀏覽期間過期,下次點擊導航到管理後台的其他頁面時也能即時發現並重定向到登入頁面。
在 Next.js 的 App Router 中,layout.tsx
檔案是特殊的存在。當任何路由(例如 page.tsx
)位於該 layout.tsx
定義的目錄層級或其子目錄中時,這個 layout.tsx
都會自動被執行,以渲染其 children
內容。
所以,當你請求任何 /admin
路徑下的頁面時,Next.js 會自動找到並執行 src/app/admin/layout.tsx
中的程式碼,然後才處理該佈局下的具體頁面內容。
三、建立管理員帳號資料表
prisma/schema.prisma
model Admin {
pk Int @id @default(autoincrement()) // 主鍵,自動遞增
username String @unique // 管理員帳號
user_pwd String // 密碼(建議雜湊儲存)
email String? @unique // 可選信箱
state Int @default(1) // 0: 禁用, 1: 啟用
del Int @default(0) // 0: 正常, 1: 軟刪除(未必需要,但已保留)
createdBy String? // 建立者(可對應其他表)
createdAt DateTime @default(now()) // 建立時間
updatedBy String? // 更新者
updatedAt DateTime @updatedAt // 更新時間
}

補充說明:
@id @default(autoincrement())
:設定主鍵為自動遞增。
@unique
:確保 username
與 email
不重複。
@updatedAt
:Prisma 會在每次更新自動寫入目前時間。
欄位型別為 ?
表示「可為 null」(例如 email
、createdBy
等)。
在 Terminal 執行:
npx prisma db push
插入管理員帳號資料
INSERT INTO Admin (
username, user_pwd, email, state, del, createdBy, createdAt, updatedBy, updatedAt
) VALUES
('admin1', '$2b$10$/HYB1hovvyvJfuwu7MwkXe/AtM807hkV2Co3yjBPX/.urTm52buIO', 'admin1@example.com', 1, 0, 'system', NOW(), 'system', NOW()),
('editor1', 'abcdef', 'editor1@example.com', 1, 0, 'system', NOW(), 'system', NOW()),
('staff1', 'pass789', NULL, 1, 0, 'system', NOW(), 'system', NOW());

將密碼改成bcryptjs

四、建立 API /api/admin/login
來驗證登入
安裝 JWT 套件
npm install jsonwebtoken

src/app/api/admin/login/route.ts
// src/app/api/admin/login/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs"; // 確保已安裝 bcryptjs
// 從環境變數中安全地加載 JWT_SECRET。
// 在 API Routes 中,process.env 會自動從 .env.local 等文件加載。
// 如果 JWT_SECRET 未設定,這裡將為 undefined,並在後續的簽名步驟中拋出錯誤,
// 這是正確的行為,因為缺失密鑰表示配置不當。
const SECRET = process.env.JWT_SECRET;
export async function POST(req: Request) {
const body = await req.json();
const { username, user_pwd } = body; // user_pwd 是使用者輸入的明文密碼
if (!username || !user_pwd) {
return NextResponse.json({ success: false, message: "缺少帳號或密碼" }, { status: 400 });
}
// 檢查 SECRET 是否已經設置。如果未設置,則直接返回伺服器錯誤。
// 這是一個重要的安全檢查,防止在缺少配置的情況下運行。
if (!SECRET) {
console.error("JWT_SECRET 環境變數未設定!請檢查您的 .env.local 或部署環境。");
return NextResponse.json({ success: false, message: "伺服器配置錯誤,請聯繫管理員。" }, { status: 500 });
}
// (可選,僅用於開發調試) 印出使用者輸入密碼的 bcrypt 雜湊值
// 在生產環境中務必刪除或註釋掉以下幾行
try {
const tempHashedPassword = await bcrypt.hash(user_pwd, 10);
console.log(`[DEV] 用戶 ${username} 輸入密碼的 bcrypt 雜湊值為:`);
console.log(tempHashedPassword);
} catch (hashError) {
console.error("[DEV] 生成 bcrypt 雜湊時發生錯誤:", hashError);
}
// (可選) 結束開發調試日誌
// 查詢資料庫,找到對應的用戶
const admin = await prisma.admin.findFirst({
where: {
username,
state: 1, // 確保用戶是啟用的
del: 0, // 確保用戶未被刪除
},
});
if (!admin) {
// 如果找不到用戶或用戶狀態不符,統一返回「帳號或密碼錯誤」以避免枚舉用戶名
return NextResponse.json({ success: false, message: "帳號或密碼錯誤" }, { status: 401 });
}
// 比較傳入的明文密碼和資料庫中已雜湊的密碼
// 假設資料庫中的 admin.user_pwd 已經是 bcrypt 雜湊後的密碼
const passwordMatch = await bcrypt.compare(user_pwd, admin.user_pwd);
if (!passwordMatch) {
// 如果密碼不匹配,統一返回「帳號或密碼錯誤」
return NextResponse.json({ success: false, message: "帳號或密碼錯誤" }, { status: 401 });
}
// 登入成功,簽發 JWT
const token = jwt.sign(
{
pk: admin.pk,
username: admin.username,
role: "admin", // 在這裡明確指定角色
},
SECRET, // 使用從環境變數加載的密鑰
{ expiresIn: "2h" } // JWT 有效期 2 小時
);
const res = NextResponse.json({ success: true, message: "登入成功" });
// 將 JWT 設定為 HttpOnly Cookie,提高安全性
res.cookies.set("admin_token", token, {
httpOnly: true, // 防止客戶端 JavaScript 訪問
secure: process.env.NODE_ENV === "production", // 僅在生產環境(HTTPS)下發送
path: "/", // Cookie 在整個網站路徑下可用
maxAge: 60 * 60 * 2, // Cookie 的有效期,應與 JWT 的 expiresIn 一致 (2 小時)
sameSite: "lax", // 推薦使用 "Lax" 或 "Strict" 防止 CSRF 攻擊
});
return res;
}
測試

如果出現錯誤
先關閉 npm run dev
重新生成npx prisma generate
重啟npm run dev
五、處理登入畫面
src/app/login/page.tsx
// src/app/login/page.tsx
'use client'; // 這是一個客戶端組件
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; // 導入 useRouter 和 useSearchParams
import Link from 'next/link'; // 用於可能的「忘記密碼」或其他連結
export default function AdminLoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); // 控制登入按鈕的加載狀態
const [error, setError] = useState<string | null>(null); // 儲存登入錯誤訊息
const router = useRouter(); // 初始化 Next.js 路由器
const searchParams = useSearchParams(); // 獲取 URL 查詢參數
// 處理 URL 查詢參數中的錯誤訊息
useEffect(() => {
const urlError = searchParams.get('error');
if (urlError) {
switch (urlError) {
case 'token_expired':
setError('您的登入會話已過期,請重新登入。');
break;
case 'invalid_token':
setError('無效的登入憑證,請重新登入。');
break;
case 'permission_denied':
setError('您沒有足夠的權限訪問該頁面。');
break;
case 'auth_failed':
setError('認證失敗,請檢查您的憑證。');
break;
case 'server_config_error':
setError('伺服器配置錯誤,請聯繫管理員。');
break;
default:
setError('發生未知錯誤,請重新登入。');
}
}
}, [searchParams]); // 依賴 searchParams,當它變化時重新執行
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); // 阻止表單的默認提交行為
setLoading(true); // 設定為加載狀態
setError(null); // 清除任何先前的錯誤訊息
try {
// 向後端登入 API 發送 POST 請求
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// 注意:這裡根據你的後端 API 期待的字段名傳遞資料
body: JSON.stringify({ username: username, user_pwd: password }),
});
const data = await response.json(); // 解析響應的 JSON 數據
if (response.ok) {
// 登入成功
console.log('登入成功!'); // 在控制台輸出成功訊息
// 可以替換成更友好的 UI 提示,例如 Toast 通知
// toast.success('登入成功!');
router.push('/admin/dashboard'); // 登入成功後導向管理後台儀表板頁面
} else {
// 登入失敗,顯示後端返回的錯誤訊息
setError(data.message || '登入失敗,請檢查帳號密碼。');
// 可以替換成更友好的 UI 提示
// toast.error(data.message || '登入失敗,請檢查帳號密碼。');
}
} catch (err) {
// 捕獲網路錯誤或請求失敗等情況
console.error('登入請求失敗:', err);
setError('網路錯誤,請檢查您的網路連線或稍後再試。');
// toast.error('網路錯誤,請稍後再試。');
} finally {
setLoading(false); // 無論成功或失敗,都結束加載狀態
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 px-4 py-8"> {/* 增加垂直內邊距 */}
<div className="w-full max-w-sm bg-white p-8 rounded-lg shadow-xl border border-gray-200"> {/* 更柔和的陰影和圓角 */}
<h1 className="text-3xl font-extrabold text-center text-gray-800 mb-8"> {/* 更大膽的標題 */}
管理者登入
</h1>
<form onSubmit={handleLogin} className="space-y-6"> {/* 增加間距 */}
{error && ( // 如果有錯誤訊息,則顯示
<div
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-md relative text-sm" // 錯誤提示樣式
role="alert"
>
<span className="block sm:inline">{error}</span>
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
帳號
</label>
<input
type="text"
id="username" // 與 label 的 htmlFor 匹配,提升無障礙性
required // 必填欄位
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800 transition duration-150 ease-in-out" // 更好看的輸入框樣式
placeholder="請輸入您的 username 帳號"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
密碼
</label>
<input
type="password"
id="password" // 與 label 的 htmlFor 匹配
required // 必填欄位
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800 transition duration-150 ease-in-out"
placeholder="請輸入您的密碼"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white font-semibold py-2.5 rounded-md hover:bg-blue-700 transition duration-200 ease-in-out shadow-md hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed text-lg" // 按鈕樣式強化
disabled={loading} // 登入中時禁用按鈕
>
{loading ? '登入中...' : '登入'}
</button>
</form>
{/* 可選:添加其他連結,例如忘記密碼 */}
<div className="mt-6 text-center">
<Link href="/admin/forgot-password" className="text-sm text-blue-600 hover:underline">
忘記密碼?
</Link>
</div>
</div>
</div>
);
}
六、api:取得登入管理者用者資訊
// src/app/api/admin/userinfo/route.ts 取得登入管理者用者資訊
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import jwt, { JwtPayload } from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET; // 從環境變數讀取 SECRET
// 定義 JWT payload 的類型
interface AdminTokenPayload extends JwtPayload {
pk: number;
username: string;
role: string;
}
export async function GET(req: NextRequest) {
// 確保 SECRET 已設定
if (!SECRET) {
console.error("JWT_SECRET 環境變數未設定!");
return NextResponse.json({ success: false, message: "伺服器配置錯誤" }, { status: 500 });
}
const cookieStore = cookies();
const token = (await cookieStore).get("admin_token")?.value;
if (!token) {
return NextResponse.json({ success: false, message: "未經授權,請重新登入" }, { status: 401 });
}
try {
const payload = jwt.verify(token, SECRET) as AdminTokenPayload;
// 檢查角色確保是管理員
if (payload.role !== "admin") {
return NextResponse.json({ success: false, message: "權限不足" }, { status: 403 });
}
// 成功驗證並獲取資訊,回傳給前端
return NextResponse.json({
success: true,
user: {
pk: payload.pk,
username: payload.username, // 假設你的 JWT payload 中有 username
// 其他你想暴露給前端的非敏感資訊
},
});
} catch (err: any) {
console.error("獲取用戶資訊時 JWT 驗證失敗:", err.message);
// 根據錯誤類型返回不同訊息
if (err.name === 'TokenExpiredError') {
return NextResponse.json({ success: false, message: "登入會話已過期" }, { status: 401 });
} else if (err.name === 'JsonWebTokenError') {
return NextResponse.json({ success: false, message: "無效的登入憑證" }, { status: 401 });
} else {
return NextResponse.json({ success: false, message: "認證失敗" }, { status: 401 });
}
}
}
七、api:登出
// src/app/api/admin/logout/route.ts
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ success: true, message: "登出成功" });
// 清除 admin_token Cookie
res.cookies.set("admin_token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production", // 僅在 HTTPS 下傳輸 (生產環境)
path: "/",
maxAge: 0, // 將 maxAge 設為 0 會讓瀏覽器立即刪除此 Cookie
sameSite: "lax",
});
return res;
}
八、登入後導至管理者首頁
// src/app/admin/dashboard/page.tsx
"use client"; // 這是一個客戶端組件
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; // 導入 useRouter 用於導航
export default function AdminDashboard() {
const [adminName, setAdminName] = useState("訪客"); // 初始值設定為訪客
const [loading, setLoading] = useState(true); // 新增 loading 狀態
const [error, setError] = useState<string | null>(null); // 新增 error 狀態
const router = useRouter(); // 初始化 Next.js 路由器
// 在組件加載時獲取管理員資訊
useEffect(() => {
async function fetchAdminInfo() {
try {
const response = await fetch('/api/admin/userinfo'); // 呼叫獲取管理員資訊的 API
const data = await response.json();
if (response.ok && data.success) {
setAdminName(data.user.username); // 從 API 響應中設定管理員名稱
} else {
// 如果獲取資訊失敗,可能代表 token 過期或無效
setError(data.message || '無法載入管理員資訊');
// 這裡可以選擇是否直接重定向到登入頁,如果 layout 已經處理了,這裡可以不處理
// router.push('/login?error=auth_failed');
}
} catch (err) {
console.error('獲取管理員資訊時發生錯誤:', err);
setError('網路錯誤或伺服器問題,無法載入資訊。');
// router.push('/login?error=network_error');
} finally {
setLoading(false); // 結束加載狀態
}
}
fetchAdminInfo();
}, []); // 空依賴陣列表示只在組件首次渲染時執行
// 登出處理函數
const handleLogout = async () => {
// 可以添加一個確認框,例如:if (!confirm("確定要登出嗎?")) return;
try {
const response = await fetch('/api/admin/logout', { // 呼叫登出 API
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
console.log('成功登出!');
// 登出成功後導回登入頁面
router.push('/login');
} else {
console.error('登出失敗:', await response.json());
alert('登出失敗,請稍後再試。'); // 簡單的錯誤提示
}
} catch (error) {
console.error('登出請求發送失敗:', error);
alert('網路錯誤,登出失敗。');
}
};
if (loading) {
return (
<main className="min-h-screen bg-gray-100 flex items-center justify-center">
<p className="text-gray-600">載入中...</p>
</main>
);
}
if (error) {
return (
<main className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-md relative" role="alert">
<span className="block sm:inline">{error}</span>
<button
onClick={() => router.push('/login')}
className="ml-4 px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition"
>
重新登入
</button>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-gray-100 p-6">
<div className="max-w-6xl mx-auto bg-white shadow-md rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
{/* 左側:歡迎訊息 */}
<h1 className="text-2xl font-bold text-gray-800">
👋 歡迎
</h1>
{/* 右側:顯示使用者帳號和登出按鈕 */}
<div className="flex items-center space-x-4"> {/* 使用 flex 佈局和間距 */}
<span className="text-lg font-medium text-gray-700">
{adminName}
</span>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition duration-200 ease-in-out shadow-md text-sm"
>
登出
</button>
</div>
</div>
<p className="text-gray-600 mb-6">這裡是管理後台儀表板。</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-blue-100 p-4 rounded shadow-sm">
<h2 className="font-bold text-lg text-blue-800">最新消息</h2>
<p className="text-sm text-blue-700">管理最新消息列表</p>
</div>
<div className="bg-green-100 p-4 rounded shadow-sm">
<h2 className="font-bold text-lg text-green-800">產品管理</h2>
<p className="text-sm text-green-700">查看與編輯產品資訊</p>
</div>
<div className="bg-yellow-100 p-4 rounded shadow-sm">
<h2 className="font-bold text-lg text-yellow-800">帳號設定</h2>
<p className="text-sm text-yellow-700">修改密碼與個人資訊</p>
</div>
</div>
</div>
</main>
);
}
Yiru@Studio - 關於我 - 意如