This commit is contained in:
2025-05-13 21:30:06 +08:00
commit 60abc68c60
115 changed files with 12478 additions and 0 deletions

41
charging_web_app/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// 其他 Next.js 配置...
// 添加 rewrites 配置用于开发环境代理
async rewrites() {
return [
{
source: '/api/:path*', // 匹配所有以 /api 开头的路径
destination: 'http://localhost:7529/api/:path*', // 代理到后端服务地址
},
];
},
};
export default nextConfig;

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7021
charging_web_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "charging_web_app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^2.2.3",
"axios": "^1.9.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,69 @@
'use client';
import React from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import LoadingSpinner from '@/components/LoadingSpinner';
const AdminDashboardPage: React.FC = () => {
const { user, logout, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated || !user) {
// AuthenticatedLayout 应该已经处理了重定向
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
// 如果用户不是管理员则重定向到普通用户dashboard
if (user.role !== 'admin') {
router.replace('/dashboard');
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
}
return (
<div className="container mx-auto p-4 pt-20">
<div className="bg-white shadow-md rounded-lg p-8 max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
</h1>
<p className="text-xl mb-4 text-gray-700">
, <span className="font-semibold">{user.username}</span>!
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<Link href="/admin/user-management" legacyBehavior>
<a className="block p-6 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-md transition duration-150 ease-in-out text-center">
<h2 className="text-xl font-semibold"></h2>
<p></p>
</a>
</Link>
{/* 可以添加更多管理员功能模块链接 */}
<div className="block p-6 bg-gray-200 text-gray-500 rounded-lg shadow-md text-center">
<h2 className="text-xl font-semibold"> ()</h2>
<p></p>
</div>
</div>
<button
onClick={async () => {
await logout();
}}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
>
</button>
</div>
</div>
);
};
export default AdminDashboardPage;

View File

@@ -0,0 +1,453 @@
'use client';
import React, { useEffect, useState, useCallback, ChangeEvent, FormEvent, Fragment } from 'react';
import { useAuth, User } from '@/contexts/AuthContext'; // 导入 User 类型
import { useRouter } from 'next/navigation';
import { api } from '@/services/api';
import LoadingSpinner from '@/components/LoadingSpinner';
import Link from 'next/link';
import { Dialog, Transition } from '@headlessui/react'; // Added Dialog and Transition
// 假设的角色枚举,与后端 UserRoleEnum 对应
const ROLES = [
{ label: '普通用户', value: 'user' },
{ label: '管理员', value: 'admin' },
// { label: '封禁用户', value: 'ban' }, // 根据需要添加
];
interface UserFormData {
username: string;
password?: string; // 编辑时可选,新增时必须
role: string;
balance: string; // 表单中通常是字符串,提交时转换
}
const UserManagementPage: React.FC = () => {
const { user: currentUser, isLoading: authLoading, isAuthenticated } = useAuth();
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [pageLoading, setPageLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- Modals State (简单示例,实际应用推荐使用组件库) ---
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false);
const [isEditUserModalOpen, setIsEditUserModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [userFormData, setUserFormData] = useState<Partial<UserFormData>>({}); // 使用 Partial 允许部分字段
const [isLoadingAction, setIsLoadingAction] = useState(false); // Added isLoadingAction state
const fetchUsers = useCallback(async () => {
setPageLoading(true);
setError(null);
try {
console.log('UserManagement: Fetching user list...');
const response = await api.get<User[]>('/user/list'); // 假设API直接返回User数组或BaseResponse<User[]>
// 根据实际API响应结构调整
// 如果是 BaseResponse<List<User>>,则需要 response.data.data
if (response.data && Array.isArray((response.data as any).data)) {
setUsers((response.data as any).data);
console.log('UserManagement: Users fetched successfully:', (response.data as any).data.length);
} else if (Array.isArray(response.data)) { // 如果直接返回数组
setUsers(response.data);
console.log('UserManagement: Users fetched successfully (direct array): inconceivable, response.data is BaseResponse, not array itself', response.data.length);
}
else {
console.error('UserManagement: Unexpected response structure for user list.', response.data);
const apiError = response.data as any;
if (apiError && apiError.code === 50000 && apiError.message) {
setError(`获取用户列表失败:${apiError.message}`);
} else {
setError('获取用户列表失败:响应格式不正确或未知错误');
}
setUsers([]);
}
} catch (err: any) {
console.error('UserManagement: Error fetching users:', err);
const errorMessage = err.response?.data?.message || err.message || '获取用户列表时发生错误';
setError(errorMessage);
if (err.response?.status === 403) {
// setError('您没有权限访问此资源。正在重定向...'); // 提示已存在,不再重复
// setTimeout(() => router.replace('/admin/dashboard'), 2000);
} else if (err.response?.status === 401) {
// setError('会话已过期或无效,请重新登录。正在重定向...');
// setTimeout(() => router.replace('/login'), 2000);
}
} finally {
setPageLoading(false);
}
}, []); // 空依赖数组因为router通常不需要作为依赖
useEffect(() => {
if (!authLoading && (!isAuthenticated || !currentUser || currentUser.role !== 'admin')) {
// 如果认证加载完成,但用户未认证、不是管理员,或用户信息不存在
console.log('UserManagement: Auth check failed or not admin, redirecting.');
router.replace(currentUser && currentUser.role === 'user' ? '/dashboard' : '/login');
return;
}
if (isAuthenticated && currentUser && currentUser.role === 'admin') {
fetchUsers();
}
}, [authLoading, isAuthenticated, currentUser, router, fetchUsers]); // 依赖项包含 router
// 新增删除用户处理函数
const handleDeleteUser = async (userId: number, username: string) => {
if (window.confirm(`确定要删除用户 "${username}" (ID: ${userId}) 吗?此操作不可恢复。`)) {
try {
setError(null); // 清除之前的错误
await api.delete(`/user/delete/${userId}`);
alert(`用户 "${username}" 删除成功!`);
// 刷新用户列表
fetchUsers();
// 或者从当前 users state 中移除该用户,以避免重新请求整个列表
// setUsers(currentUsers => currentUsers.filter(u => u.id !== userId));
} catch (err: any) {
console.error('Error deleting user:', err);
const errorMessage = err.response?.data?.message || err.message || '删除用户失败';
setError(errorMessage);
alert(`删除用户 "${username}" 失败: ${errorMessage}`);
}
}
};
// --- Add User Modal Logic ---
const handleOpenAddUserModal = () => {
setUserFormData({ role: 'user', balance: '0' }); // 默认值
setIsAddUserModalOpen(true);
};
const handleAddUser = async (e: FormEvent) => {
e.preventDefault();
if (!userFormData.username || !userFormData.password || !userFormData.role) {
alert('用户名、密码和角色不能为空!');
return;
}
setIsLoadingAction(true); // Set loading true
setError(null);
try {
const payload = {
username: userFormData.username,
password: userFormData.password,
role: userFormData.role,
balance: parseFloat(userFormData.balance || '0'),
};
await api.post('/user/admin/add', payload);
alert('用户添加成功!');
setIsAddUserModalOpen(false);
setEditingUser(null);
await fetchUsers();
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '添加用户失败';
alert(`添加用户失败: ${errorMessage}`);
setError(errorMessage);
} finally {
setIsLoadingAction(false); // Set loading false
}
};
// --- Edit User Modal Logic ---
const handleOpenEditUserModal = (userToEdit: User) => {
setEditingUser(userToEdit);
setUserFormData({
username: userToEdit.username,
role: userToEdit.role,
balance: userToEdit.balance.toString(),
password: '' // 密码字段留空,除非用户输入新密码
});
setIsEditUserModalOpen(true);
};
const handleUpdateUser = async (e: FormEvent) => {
e.preventDefault();
if (!editingUser || !userFormData.username || !userFormData.role) {
alert('用户信息不完整!');
return;
}
setIsLoadingAction(true); // Set loading true
setError(null);
try {
const payload: any = {
id: editingUser.id,
username: userFormData.username,
role: userFormData.role,
balance: parseFloat(userFormData.balance || '0'),
};
if (userFormData.password && userFormData.password.length > 0) {
payload.password = userFormData.password;
}
await api.put('/user/admin/update', payload);
alert('用户信息更新成功!');
setIsEditUserModalOpen(false);
setEditingUser(null);
await fetchUsers();
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '更新用户信息失败';
alert(`更新用户信息失败: ${errorMessage}`);
setError(errorMessage);
} finally {
setIsLoadingAction(false); // Set loading false
}
};
const handleFormInputChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setUserFormData(prev => ({ ...prev, [name]: value }));
};
const closeModal = () => {
setIsAddUserModalOpen(false);
setIsEditUserModalOpen(false);
// Reset form data if needed, or do it on open
setUserFormData({ username: '', password: '', role: 'user', balance: '0' });
setEditingUser(null);
};
if (authLoading || pageLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
// 如果在 authLoading 后仍然未认证或不是管理员AuthenticatedLayout 或此组件的 useEffect 会处理重定向
// 但为了防止在重定向前渲染内容,可以加一个判断
if (!isAuthenticated || !currentUser || currentUser.role !== 'admin') {
// 此处应该已经被重定向,显示加载动画避免闪烁
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
return (
<div className="container mx-auto p-4 pt-10">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<div>
<button
onClick={handleOpenAddUserModal}
className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg mr-3 transition duration-150 ease-in-out"
>
</button>
<Link href="/admin/dashboard" legacyBehavior>
<a className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg transition duration-150 ease-in-out">
</a>
</Link>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-400 rounded-md">
{error}
</div>
)}
{/* Add/Edit User Modal using Headless UI */}
<Transition appear show={isAddUserModalOpen || isEditUserModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 mb-4"
>
{isAddUserModalOpen ? '新增用户' : `编辑用户: ${editingUser?.username || ''}`}
</Dialog.Title>
{error && <p className="mb-3 text-sm text-red-600 bg-red-100 p-2 rounded">{error}</p>} {/* Display error messages in modal */}
<form onSubmit={isAddUserModalOpen ? handleAddUser : handleUpdateUser}>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium text-gray-700"></label>
<input
type="text"
name="username"
id="username"
value={userFormData.username}
onChange={handleFormInputChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
{/* Password field: only required for add, optional for edit (to change password) */}
{(isAddUserModalOpen || isEditUserModalOpen) && (
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
{isAddUserModalOpen ? '' : '(如需重置请输入新密码)'}
</label>
<input
type="password"
name="password"
id="password"
value={userFormData.password}
onChange={handleFormInputChange}
required={isAddUserModalOpen} // Only required for new users
minLength={isAddUserModalOpen ? 6 : undefined} // Min length for new users
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder={isEditUserModalOpen ? "留空则不修改密码" : ""}
/>
</div>
)}
<div className="mb-4">
<label htmlFor="role" className="block text-sm font-medium text-gray-700"></label>
<select
name="role"
id="role"
value={userFormData.role}
onChange={handleFormInputChange}
required
disabled={isEditUserModalOpen && editingUser?.id === currentUser?.id && currentUser?.role === 'admin'} // Prevent admin from changing own role easily
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="user">User</option>
<option value="admin">Admin</option>
{/* <option value="ban">Ban</option> */}
</select>
{isEditUserModalOpen && editingUser?.id === currentUser?.id && currentUser?.role === 'admin' && (
<p className="mt-1 text-xs text-red-600"></p>
)}
</div>
<div className="mb-6">
<label htmlFor="balance" className="block text-sm font-medium text-gray-700"></label>
<input
type="number"
name="balance"
id="balance"
value={userFormData.balance}
onChange={handleFormInputChange}
required
step="0.01" // Allow decimal for currency
min="0"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="mt-6 flex items-center justify-end space-x-3">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
onClick={closeModal}
>
</button>
<button
type="submit"
disabled={isLoadingAction} // Use isLoadingAction here
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:opacity-50"
>
{isLoadingAction ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : isAddUserModalOpen ? '确认新增' : '确认修改'}
</button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
<div className="bg-white shadow-md rounded-lg overflow-x-auto">
<table className="min-w-full leading-normal">
<thead>
<tr>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
ID
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
<th className="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{users.length > 0 ? (
users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">{u.id}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">{u.username}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<span
className={`px-2 py-1 font-semibold leading-tight rounded-full text-xs
${u.role === 'admin' ? 'bg-green-100 text-green-700' : ''}
${u.role === 'user' ? 'bg-blue-100 text-blue-700' : ''}
${u.role === 'ban' ? 'bg-red-100 text-red-700' : ''}
`}
>
{u.role}
</span>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<p className="text-gray-900 whitespace-no-wrap">¥{u.balance.toFixed(2)}</p>
</td>
<td className="px-5 py-4 border-b border-gray-200 bg-white text-sm">
<button
onClick={() => handleOpenEditUserModal(u)}
className="text-indigo-600 hover:text-indigo-900 mr-3 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentUser?.id === u.id && u.role === 'admin'} // 通常不允许管理员直接编辑自己(特别是角色),或者需要更复杂的逻辑
>
</button>
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="text-red-600 hover:text-red-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={currentUser?.id === u.id}
>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-5 py-5 border-b border-gray-200 bg-white text-center text-sm">
{pageLoading ? '正在加载用户...' : (error ? '加载用户失败' : '没有找到用户数据')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default UserManagementPage;

View File

@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import LoadingSpinner from '@/components/LoadingSpinner';
const DashboardPage: React.FC = () => {
const { user, logout, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated || !user) {
// AuthenticatedLayout 应该已经处理了重定向
// 但作为备用,可以显示加载或 null
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>;
}
// 如果用户是管理员但意外访问了普通用户dashboard则重定向到管理员dashboard
// 这一步是可选的因为AuthContext中的login已经做了角色判断和重定向
// 但作为额外的保护层防止用户通过直接输入URL访问不匹配的dashboard
if (user.role === 'admin') {
router.replace('/admin/dashboard');
return <div className="flex items-center justify-center min-h-screen"><LoadingSpinner /></div>; // 显示加载直到重定向完成
}
return (
<div className="container mx-auto p-4 pt-20">
<div className="bg-white shadow-md rounded-lg p-8 max-w-md mx-auto">
<h1 className="text-3xl font-bold mb-6 text-center text-gray-800">
, {user.username}!
</h1>
<div className="mb-6">
<p className="text-lg text-gray-700">
<span className="font-semibold">ID:</span> {user.id}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> {user.role}
</p>
<p className="text-lg text-gray-700">
<span className="font-semibold">:</span> ¥{user.balance.toFixed(2)}
</p>
</div>
<button
onClick={async () => {
await logout();
// logout 函数内部会处理路由跳转到 /login
}}
className="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-4 rounded-lg transition duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"
>
</button>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,46 @@
'use client';
import React, { useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading, user } = useAuth(); // user is available if needed
const router = useRouter();
useEffect(() => {
// Only redirect if loading is complete and user is not authenticated
if (!isLoading && !isAuthenticated) {
console.log('AuthenticatedLayout: User not authenticated, redirecting to login.');
router.replace('/login');
}
}, [isAuthenticated, isLoading, router]);
// If still loading, show spinner
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
// If loading is complete but user is not authenticated,
// useEffect will handle redirection. Render null or spinner to avoid flashing content.
if (!isAuthenticated) {
// This state should ideally be brief as useEffect redirects.
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner /> {/* Or return null; */}
</div>
);
}
// If authenticated, render children
return <>{children}</>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "MQTT 智能充电桩",
description: "MQTT 智能充电桩控制平台",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import React, { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated, user } = useAuth();
const router = useRouter();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username || !password) {
setError('用户名和密码不能为空');
return;
}
setIsLoading(true);
setError(null);
try {
await login(username, password);
// 登录成功后AuthContext会更新状态下面useEffect会处理跳转
// 或者在这里直接根据角色跳转
// const targetPath = response.data.data.role === 'admin' ? '/admin/dashboard' : '/dashboard';
// router.push(targetPath);
} catch (err: any) {
setError(typeof err === 'string' ? err : '登录时发生错误,请检查凭据或网络连接。');
} finally {
setIsLoading(false);
}
};
// 如果用户已认证,则重定向到对应的主页
React.useEffect(() => {
if (isAuthenticated && user) {
const targetPath = user.role === 'admin' ? '/admin/dashboard' : '/dashboard';
console.log(`User already authenticated (${user.username}, ${user.role}). Redirecting to ${targetPath}...`);
router.replace(targetPath); // 使用replace避免用户回退到登录页
}
}, [isAuthenticated, user, router]);
// 如果仍在加载认证状态或已经认证通过(等待跳转),可以显示加载中
// if (isLoadingInitialAuth || isAuthenticated) {
// return <div className="flex justify-center items-center min-h-screen">加载中...</div>;
// }
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-3xl font-bold text-blue-600">
MQTT
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入用户名"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入密码"
/>
</div>
{error && (
<p className="text-center text-sm text-red-600">{error}</p>
)}
<div>
<button
type="submit"
disabled={isLoading}
className={`flex w-full justify-center rounded-md border border-transparent ${isLoading ? 'bg-blue-300' : 'bg-blue-600 hover:bg-blue-700'} py-2 px-4 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out`}
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{isLoading ? '登录中...' : '登 录'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import React, { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [checkPassword, setCheckPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
const router = useRouter();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username || !password || !checkPassword) {
setError('所有字段均为必填项');
setSuccessMessage(null);
return;
}
if (password !== checkPassword) {
setError('两次输入的密码不一致');
setSuccessMessage(null);
return;
}
// 可选:添加更复杂的密码策略校验
if (password.length < 6) {
setError('密码长度不能少于6位');
setSuccessMessage(null);
return;
}
setIsLoading(true);
setError(null);
setSuccessMessage(null);
try {
await register(username, password, checkPassword);
setSuccessMessage('注册成功!正在跳转到登录页面...');
// 注册成功后延迟跳转到登录页
setTimeout(() => {
router.push('/login');
}, 2000); // 延迟2秒
} catch (err: any) {
setError(typeof err === 'string' ? err : '注册时发生错误,请稍后重试或联系管理员。');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-3xl font-bold text-blue-600">
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入用户名"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请输入至少6位密码"
/>
</div>
<div>
<label
htmlFor="checkPassword"
className="block text-sm font-medium text-gray-700"
>
</label>
<input
id="checkPassword"
name="checkPassword"
type="password"
autoComplete="new-password"
required
value={checkPassword}
onChange={(e) => setCheckPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
placeholder="请再次输入密码"
/>
</div>
{error && (
<p className="text-center text-sm text-red-600">{error}</p>
)}
{successMessage && (
<p className="text-center text-sm text-green-600">{successMessage}</p>
)}
<div>
<button
type="submit"
disabled={isLoading || !!successMessage} // 成功后也禁用按钮
className={`flex w-full justify-center rounded-md border border-transparent ${isLoading || successMessage ? 'bg-blue-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'} py-2 px-4 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out mt-2`}
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : null}
{isLoading ? '注册中...' : '注 册'}
</button>
</div>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
const LoadingSpinner: React.FC = () => {
return (
<div className="flex flex-col items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-10 w-10 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p className="mt-2 text-lg text-gray-700">...</p>
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,159 @@
'use client'; // 标记为客户端组件因为使用了useState, useEffect, useContext和localStorage
import React, { createContext, useState, useContext, useEffect, ReactNode, useCallback } from 'react';
import { useRouter } from 'next/navigation'; // 使用Next.js的路由
import { api } from '@/services/api';
// 用户类型定义 (与之前一致)
export interface User {
id: number;
username: string;
role: string; // 确保这里的 'admin' 和后端 UserRoleEnum.ADMIN.getValue() 一致
balance: number;
}
// 认证上下文类型
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (username: string, password: string, checkPassword: string) => Promise<void>;
checkAuth: () => Promise<void>; // 新增:检查认证状态的函数
}
// 创建认证上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 认证提供者属性
interface AuthProviderProps {
children: ReactNode;
}
// 认证提供者组件
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); // 初始为true在checkAuth完成后设为false
const router = useRouter();
// 检查认证状态 (例如: 页面加载或需要权限时调用)
const checkAuth = useCallback(async () => {
console.log('Checking authentication status...');
// 不需要在这里 setIsLoading(true),因为初始值就是 true
try {
const response = await api.get('/user/current');
if (response.data.code === 0 && response.data.data) {
setUser(response.data.data);
console.log('User authenticated:', response.data.data.username, 'Role:', response.data.data.role);
} else {
setUser(null);
console.log('User not authenticated or API error. Message:', response.data.message);
}
} catch (error: any) {
console.error('Error checking auth:', error);
setUser(null);
// 如果 /user/current 返回 401/403说明session无效或没有权限这通常是预期行为不需要特殊处理
} finally {
setIsLoading(false); // 无论成功与否,结束加载状态
console.log('Auth check finished. isLoading set to false.');
}
}, []); // 移除了 router 依赖,因为它不直接使用 router
// 组件挂载时执行一次认证检查
useEffect(() => {
checkAuth();
}, [checkAuth]);
// 登录函数
const login = async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await api.post('/user/login', { username, password });
if (response.data.code === 0 && response.data.data) {
const loggedInUser = response.data.data as User;
setUser(loggedInUser);
console.log('Login successful for:', loggedInUser.username, 'Role:', loggedInUser.role);
// 登录成功后跳转
// 确保后端返回的 role 字符串与这里的判断条件一致 (e.g., 'admin')
if (loggedInUser.role === 'admin') {
router.push('/admin/dashboard');
} else {
router.push('/dashboard');
}
} else {
throw new Error(response.data.message || '登录失败');
}
} catch (error: any) {
console.error('登录出错:', error);
setUser(null);
throw error.message || error || '登录时发生未知错误';
} finally {
setIsLoading(false);
}
};
// 注销函数
const logout = async () => {
// setIsLoading(true); // 可以考虑在登出操作期间也显示加载状态
try {
await api.post('/user/logout');
console.log('Logout successful.');
} catch (error: any) {
console.error('注销API调用出错:', error);
} finally {
setUser(null);
setIsLoading(false);
router.push('/login');
}
};
// 注册函数
const register = async (username: string, password: string, checkPassword: string) => {
setIsLoading(true);
try {
const response = await api.post('/user/register', {
username,
password,
checkPassword
});
if (response.data.code !== 0) {
throw new Error(response.data.message || '注册失败');
}
console.log('Registration successful for:', username);
// 注册成功后通常提示用户去登录,并跳转到登录页
alert('注册成功!请登录。'); // 可以用更友好的提示组件替换 alert
router.push('/login');
} catch (error: any) {
console.error('注册出错:', error);
throw error.message || error || '注册时发生未知错误';
} finally {
setIsLoading(false);
}
};
const value = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
register,
checkAuth // 提供检查函数
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// 自定义钩子便于使用上下文
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth必须在AuthProvider内部使用');
}
return context;
};

View File

@@ -0,0 +1,64 @@
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
// 创建axios实例
export const api = axios.create({
// baseURL: 'http://localhost:7529/api', // 本地开发时后端API基础URL
baseURL: '/api', // 使用相对路径依赖Next.js代理或部署时反向代理
withCredentials: true, // 允许携带Cookie以支持Session认证
timeout: 10000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
api.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig> => {
// 可以在这里添加通用请求前的处理逻辑
// 例如: 添加认证token (如果使用Token认证)
// if (typeof window !== 'undefined') { // 确保在浏览器环境中
// const token = localStorage.getItem('auth_token');
// if (token) {
// config.headers = config.headers || {};
// config.headers.Authorization = `Bearer ${token}`;
// }
// }
return config;
},
(error: any) => {
return Promise.reject(error);
}
);
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse): AxiosResponse | Promise<AxiosResponse> => {
// 可以在这里统一处理接口成功响应
// 例如:检查后端自定义的成功码
// if (response.data && response.data.code !== 0) {
// return Promise.reject(new Error(response.data.message || 'Error'));
// }
return response;
},
(error: AxiosError) => {
// 可以在这里统一处理接口错误响应
if (error.response) {
// 服务器返回错误状态码
console.error('API Error Response:', error.response.data);
if (error.response.status === 401) {
// 未授权,可以进行跳转到登录页等操作
console.error('用户未登录或会话已过期');
// **重要**: 在这里不能直接导航应该在调用处或Context中处理
// 通常会触发登出逻辑或设置一个状态让UI跳转
}
} else if (error.request) {
// 请求已发送但未收到响应
console.error('网络请求失败,请检查网络连接:', error.request);
} else {
// 请求配置出错
console.error('请求配置错误:', error.message);
}
// 抛出包含更多信息的错误,便于上层处理
return Promise.reject(error.response?.data || error.message || 'An unknown error occurred');
}
);

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}