用户
This commit is contained in:
41
charging_web_app/.gitignore
vendored
Normal file
41
charging_web_app/.gitignore
vendored
Normal 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
|
||||
36
charging_web_app/README.md
Normal file
36
charging_web_app/README.md
Normal 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.
|
||||
16
charging_web_app/eslint.config.mjs
Normal file
16
charging_web_app/eslint.config.mjs
Normal 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;
|
||||
16
charging_web_app/next.config.mjs
Normal file
16
charging_web_app/next.config.mjs
Normal 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;
|
||||
7
charging_web_app/next.config.ts
Normal file
7
charging_web_app/next.config.ts
Normal 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
7021
charging_web_app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
charging_web_app/package.json
Normal file
29
charging_web_app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
charging_web_app/postcss.config.mjs
Normal file
5
charging_web_app/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
charging_web_app/public/file.svg
Normal file
1
charging_web_app/public/file.svg
Normal 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 |
1
charging_web_app/public/globe.svg
Normal file
1
charging_web_app/public/globe.svg
Normal 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 |
1
charging_web_app/public/next.svg
Normal file
1
charging_web_app/public/next.svg
Normal 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 |
1
charging_web_app/public/vercel.svg
Normal file
1
charging_web_app/public/vercel.svg
Normal 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 |
1
charging_web_app/public/window.svg
Normal file
1
charging_web_app/public/window.svg
Normal 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 |
@@ -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;
|
||||
@@ -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;
|
||||
67
charging_web_app/src/app/(authenticated)/dashboard/page.tsx
Normal file
67
charging_web_app/src/app/(authenticated)/dashboard/page.tsx
Normal 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;
|
||||
46
charging_web_app/src/app/(authenticated)/layout.tsx
Normal file
46
charging_web_app/src/app/(authenticated)/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
BIN
charging_web_app/src/app/favicon.ico
Normal file
BIN
charging_web_app/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
charging_web_app/src/app/globals.css
Normal file
26
charging_web_app/src/app/globals.css
Normal 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;
|
||||
}
|
||||
27
charging_web_app/src/app/layout.tsx
Normal file
27
charging_web_app/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
charging_web_app/src/app/login/page.tsx
Normal file
130
charging_web_app/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
charging_web_app/src/app/page.tsx
Normal file
103
charging_web_app/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
charging_web_app/src/app/register/page.tsx
Normal file
155
charging_web_app/src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
charging_web_app/src/components/LoadingSpinner.tsx
Normal file
31
charging_web_app/src/components/LoadingSpinner.tsx
Normal 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;
|
||||
159
charging_web_app/src/contexts/AuthContext.tsx
Normal file
159
charging_web_app/src/contexts/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
64
charging_web_app/src/services/api.ts
Normal file
64
charging_web_app/src/services/api.ts
Normal 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');
|
||||
}
|
||||
);
|
||||
27
charging_web_app/tsconfig.json
Normal file
27
charging_web_app/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user