第四阶段开发完成
This commit is contained in:
1024
charging_web_app/package-lock.json
generated
1024
charging_web_app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.3",
|
||||
"antd": "^5.25.1",
|
||||
"axios": "^1.9.0",
|
||||
"next": "15.3.2",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Button, Card, Col, DatePicker, Form, Input, InputNumber, message,
|
||||
Row, Select, Space, Table, Tabs, Typography, Modal
|
||||
} from 'antd';
|
||||
import { api } from '@/services/api';
|
||||
import { BaseResponse } from '@/types/api';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// --- TypeScript Interfaces ---
|
||||
interface ActivationCodeVO {
|
||||
id: number;
|
||||
code: string;
|
||||
value: number;
|
||||
isUsed: number;
|
||||
userId?: number | null;
|
||||
userName?: string | null;
|
||||
useTime?: string | null;
|
||||
expireTime?: string | null;
|
||||
batchId?: string | null;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
interface GenerateCodesRequest {
|
||||
count: number;
|
||||
value: number;
|
||||
expireTime?: string | null; // ISO string or null
|
||||
batchId?: string | null;
|
||||
}
|
||||
|
||||
interface ActivationCodeQueryFormData extends ActivationCodeQueryRequest {
|
||||
expireTimeRange?: [any, any]; // Using 'any' for now, ideally Dayjs or Moment tuple
|
||||
createTimeRange?: [any, any];
|
||||
}
|
||||
|
||||
interface ActivationCodeQueryRequest {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
code?: string;
|
||||
isUsed?: number;
|
||||
batchId?: string;
|
||||
userId?: number;
|
||||
valueMin?: number;
|
||||
valueMax?: number;
|
||||
expireTimeStart?: string;
|
||||
expireTimeEnd?: string;
|
||||
createTimeStart?: string;
|
||||
createTimeEnd?: string;
|
||||
sortField?: string;
|
||||
sortOrder?: string;
|
||||
}
|
||||
|
||||
interface Page<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
current: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const AdminActivationCodesPage = () => {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [generateForm] = Form.useForm<GenerateCodesRequest>();
|
||||
const [queryForm] = Form.useForm<ActivationCodeQueryFormData>();
|
||||
|
||||
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const [activationCodes, setActivationCodes] = useState<ActivationCodeVO[]>([]);
|
||||
const [totalCodes, setTotalCodes] = useState(0);
|
||||
const [codesLoading, setCodesLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
// 添加删除Modal的状态
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const [currentCodeId, setCurrentCodeId] = useState<number | null>(null);
|
||||
|
||||
// 权限检查和重定向
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/login');
|
||||
} else if (user?.role !== 'admin') {
|
||||
message.error('您没有权限访问此页面。');
|
||||
router.replace('/dashboard'); // 或者其他非管理员默认页
|
||||
}
|
||||
}
|
||||
}, [authLoading, isAuthenticated, user, router]);
|
||||
|
||||
// --- Generate Codes Logic ---
|
||||
const handleGenerateCodes = async (values: GenerateCodesRequest) => {
|
||||
setIsGenerating(true);
|
||||
setGeneratedCodes([]);
|
||||
try {
|
||||
const requestPayload = {
|
||||
...values,
|
||||
expireTime: values.expireTime ? (values.expireTime as any).toISOString() : null,
|
||||
};
|
||||
const response = await api.post<BaseResponse<string[]>>('/activation-code/admin/generate', requestPayload);
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setGeneratedCodes(response.data.data);
|
||||
message.success(`成功生成 ${response.data.data.length} 个激活码!`);
|
||||
generateForm.resetFields();
|
||||
fetchActivationCodes({ current: 1 });
|
||||
} else {
|
||||
message.error(response.data.message || '生成激活码失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || error.message || '生成激活码时发生错误');
|
||||
}
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
// --- List Codes Logic ---
|
||||
const fetchActivationCodes = useCallback(async (params: Partial<ActivationCodeQueryRequest> = {}) => {
|
||||
setCodesLoading(true);
|
||||
try {
|
||||
const formValues = queryForm.getFieldsValue();
|
||||
const queryParams: ActivationCodeQueryRequest = {
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...formValues,
|
||||
...params,
|
||||
};
|
||||
|
||||
if (formValues.expireTimeRange && formValues.expireTimeRange.length === 2) {
|
||||
queryParams.expireTimeStart = (formValues.expireTimeRange[0] as any).toISOString();
|
||||
queryParams.expireTimeEnd = (formValues.expireTimeRange[1] as any).toISOString();
|
||||
}
|
||||
delete (queryParams as ActivationCodeQueryFormData).expireTimeRange;
|
||||
|
||||
if (formValues.createTimeRange && formValues.createTimeRange.length === 2) {
|
||||
queryParams.createTimeStart = (formValues.createTimeRange[0] as any).toISOString();
|
||||
queryParams.createTimeEnd = (formValues.createTimeRange[1] as any).toISOString();
|
||||
}
|
||||
delete (queryParams as ActivationCodeQueryFormData).createTimeRange;
|
||||
|
||||
const response = await api.post<BaseResponse<Page<ActivationCodeVO>>>('/activation-code/admin/list/page', queryParams);
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setActivationCodes(response.data.data.records || []);
|
||||
setTotalCodes(response.data.data.total || 0);
|
||||
} else {
|
||||
message.error(response.data.message || '获取激活码列表失败');
|
||||
setActivationCodes([]);
|
||||
setTotalCodes(0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || error.message || '获取激活码列表时发生错误');
|
||||
setActivationCodes([]);
|
||||
setTotalCodes(0);
|
||||
}
|
||||
setCodesLoading(false);
|
||||
}, [pagination, queryForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
fetchActivationCodes();
|
||||
}
|
||||
}, [fetchActivationCodes, isAuthenticated, user]);
|
||||
|
||||
const handleTableChange = (newPagination: any, filters: any, sorter: any) => {
|
||||
const sortParams: Partial<ActivationCodeQueryRequest> = {};
|
||||
if (sorter.field && sorter.order) {
|
||||
sortParams.sortField = sorter.field as string;
|
||||
sortParams.sortOrder = sorter.order;
|
||||
}
|
||||
const newPager = {
|
||||
current: newPagination.current,
|
||||
pageSize: newPagination.pageSize,
|
||||
};
|
||||
setPagination(newPager);
|
||||
fetchActivationCodes({
|
||||
...newPager,
|
||||
...sortParams
|
||||
});
|
||||
};
|
||||
|
||||
const onQueryFinish = (values: ActivationCodeQueryFormData) => {
|
||||
const newPager = {...pagination, current: 1};
|
||||
setPagination(newPager);
|
||||
fetchActivationCodes({current: 1});
|
||||
}
|
||||
|
||||
const handleDeleteCode = (codeId: number) => {
|
||||
console.log('handleDeleteCode called with ID:', codeId);
|
||||
setCurrentCodeId(codeId);
|
||||
setIsDeleteModalVisible(true);
|
||||
console.log('Delete modal state set to visible');
|
||||
};
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteConfirm = () => {
|
||||
if (currentCodeId) {
|
||||
console.log('Delete confirmed for ID:', currentCodeId);
|
||||
|
||||
// 实际执行删除的代码
|
||||
api.post<BaseResponse<boolean>>(
|
||||
`/activation-code/admin/delete`,
|
||||
{ id: currentCodeId }
|
||||
).then(response => {
|
||||
if (response.data.code === 0 && response.data.data === true) {
|
||||
message.success('激活码删除成功!');
|
||||
fetchActivationCodes({ current: pagination.current }); // 刷新当前页
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败');
|
||||
}
|
||||
}).catch(error => {
|
||||
message.error(error.message || '删除激活码时发生网络错误');
|
||||
});
|
||||
}
|
||||
setIsDeleteModalVisible(false);
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
const handleDeleteCancel = () => {
|
||||
console.log('Delete cancelled');
|
||||
setIsDeleteModalVisible(false);
|
||||
};
|
||||
|
||||
// --- Columns for Activation Codes Table ---
|
||||
const columns: any[] = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', sorter: true },
|
||||
{ title: '激活码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '面值', dataIndex: 'value', key: 'value', sorter: true, render: (val: number) => `¥${val.toFixed(2)}` },
|
||||
{
|
||||
title: '状态', dataIndex: 'isUsed', key: 'isUsed', sorter: true,
|
||||
render: (isUsed: number) => isUsed === 1 ? <Text type="danger">已使用</Text> : <Text type="success">未使用</Text>,
|
||||
filters: [
|
||||
{ text: '未使用', value: 0 },
|
||||
{ text: '已使用', value: 1 },
|
||||
],
|
||||
},
|
||||
{ title: '使用者ID', dataIndex: 'userId', key: 'userId', sorter: true },
|
||||
{ title: '使用者', dataIndex: 'userName', key: 'userName' },
|
||||
{ title: '使用时间', dataIndex: 'useTime', key: 'useTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '-' },
|
||||
{ title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', sorter: true, render: (time: string) => time ? new Date(time).toLocaleString() : '永不' },
|
||||
{ title: '批次号', dataIndex: 'batchId', key: 'batchId' },
|
||||
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', sorter: true, defaultSortOrder: 'descend', render: (time: string) => new Date(time).toLocaleString() },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (text: any, record: ActivationCodeVO) => (
|
||||
<Space size="middle">
|
||||
<Button type="link" danger onClick={() => handleDeleteCode(record.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (authLoading) return <LoadingSpinner />;
|
||||
if (!isAuthenticated || user?.role !== 'admin') return null; // 或显示权限不足的组件
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Title level={2} style={{ marginBottom: '24px' }}>激活码管理中心</Title>
|
||||
|
||||
<Tabs defaultActiveKey="1">
|
||||
<TabPane tab="生成激活码" key="1">
|
||||
<Card title="批量生成激活码" style={{ marginBottom: 24 }}>
|
||||
<Form form={generateForm} layout="vertical" onFinish={handleGenerateCodes}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Form.Item name="count" label="生成数量" rules={[{ required: true, message: '请输入生成数量' }]}>
|
||||
<InputNumber min={1} max={1000} style={{ width: '100%' }} placeholder="例如: 100" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Form.Item name="value" label="激活码面值 (元)" rules={[{ required: true, message: '请输入激活码面值' }]}>
|
||||
<InputNumber min={0.01} step={0.01} style={{ width: '100%' }} placeholder="例如: 10.00" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Form.Item name="expireTime" label="过期时间 (可选)">
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="选择日期和时间" format="YYYY-MM-DD HH:mm:ss" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Form.Item name="batchId" label="批次号 (可选)">
|
||||
<Input placeholder="例如: spring_promo_2024" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={isGenerating} disabled={isGenerating}>
|
||||
{isGenerating ? '正在生成...' : '立即生成'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{generatedCodes.length > 0 && (
|
||||
<Card title="本次生成的激活码" type="inner" style={{ marginTop: 16 }}>
|
||||
<Space direction="vertical" style={{width: '100%'}}>
|
||||
{generatedCodes.map((code, index) => (
|
||||
<Text copyable key={index}>{code}</Text>
|
||||
))}
|
||||
</Space>
|
||||
<Text type="secondary" style={{marginTop: 8, display: 'block'}}>共生成 {generatedCodes.length} 个。请妥善保管。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="管理激活码列表" key="2">
|
||||
<Card title="筛选查询" style={{ marginBottom: 24 }}>
|
||||
<Form form={queryForm} layout="vertical" onFinish={onQueryFinish}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="code" label="激活码"><Input placeholder="模糊查询" /></Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item name="isUsed" label="使用状态">
|
||||
<Select allowClear placeholder="全部状态">
|
||||
<Select.Option value={0}>未使用</Select.Option>
|
||||
<Select.Option value={1}>已使用</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="batchId" label="批次号"><Input placeholder="精确查询" /></Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="userId" label="使用者ID"><InputNumber style={{ width: '100%' }} placeholder="精确查询" /></Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="valueMin" label="最小面值"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="valueMax" label="最大面值"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="expireTimeRange" label="过期时间范围">
|
||||
<RangePicker showTime style={{ width: '100%' }} format="YYYY-MM-DD HH:mm:ss" />
|
||||
</Form.Item></Col>
|
||||
<Col xs={24} sm={12} md={6}><Form.Item name="createTimeRange" label="创建时间范围">
|
||||
<RangePicker showTime style={{ width: '100%' }} format="YYYY-MM-DD HH:mm:ss" />
|
||||
</Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={codesLoading}>查询</Button>
|
||||
<Button style={{ marginLeft: 8 }} onClick={() => { queryForm.resetFields(); onQueryFinish({}); }}>重置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={activationCodes}
|
||||
loading={codesLoading}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: totalCodes,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Modal
|
||||
title="确认删除"
|
||||
open={isDeleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okType="danger"
|
||||
>
|
||||
<p>您确定要删除这个激活码 (ID: {currentCodeId}) 吗?此操作不可恢复。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminActivationCodesPage;
|
||||
@@ -5,8 +5,9 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings } from 'react-icons/fi';
|
||||
import { api } from '@/utils/axios';
|
||||
import { FiUsers, FiCpu, FiBarChart2, FiGrid, FiUserCheck, FiList, FiTerminal, FiSettings, FiGift } from 'react-icons/fi';
|
||||
import { api } from '@/services/api';
|
||||
import { BaseResponse } from '@/types/api';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
|
||||
// 匹配后端 AdminDashboardStatsVO
|
||||
@@ -28,13 +29,6 @@ interface AdminStats {
|
||||
// availableParkingSpots: number;
|
||||
}
|
||||
|
||||
// 建议定义一个通用的 BaseResponse 接口
|
||||
interface BackendBaseResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -56,23 +50,22 @@ const AdminDashboardPage = () => {
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
setStatsLoading(true);
|
||||
setStatsError(null);
|
||||
api.get<BackendBaseResponse<AdminStats>>('/admin/stats/summary') // 使用 BackendBaseResponse
|
||||
.then((response: AxiosResponse<BackendBaseResponse<AdminStats>>) => {
|
||||
if (response.data && response.data.data) {
|
||||
// 后端返回的 totalRevenue 对应前端接口的 totalRevenueToday
|
||||
// 后端返回的 activeSessions 对应前端接口的 totalChargingSessionsToday
|
||||
// 但为了保持一致性,我们最好让前端AdminStats接口的字段名与后端VO的字段名一致
|
||||
// 这里暂时直接使用后端返回的字段名,前端 JSX 部分也需要对应调整
|
||||
api.get<BaseResponse<AdminStats>>('/admin/stats/summary')
|
||||
.then((response: AxiosResponse<BaseResponse<AdminStats>>) => {
|
||||
if (response.data && response.data.code === 0 && response.data.data) {
|
||||
setAdminStats(response.data.data);
|
||||
} else {
|
||||
console.error("Failed to fetch admin stats or data is null/malformed:", response);
|
||||
setStatsError("加载统计数据失败: 数据为空或格式错误。");
|
||||
console.error("Failed to fetch admin stats or data is null/malformed:", response.data?.message);
|
||||
setStatsError(response.data?.message || "加载统计数据失败: 数据为空或格式错误。");
|
||||
setAdminStats(null);
|
||||
}
|
||||
})
|
||||
.catch((error: AxiosError) => {
|
||||
.catch((error: AxiosError<BaseResponse<unknown>>) => {
|
||||
console.error("Failed to fetch admin stats:", error);
|
||||
if (error.response) {
|
||||
const backendMessage = error.response?.data?.message;
|
||||
if (backendMessage) {
|
||||
setStatsError(`加载统计数据失败: ${backendMessage}`);
|
||||
} else if (error.response) {
|
||||
setStatsError(`加载统计数据失败: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
setStatsError("加载统计数据失败: 未收到服务器响应。");
|
||||
@@ -200,7 +193,6 @@ const AdminDashboardPage = () => {
|
||||
description="查看所有充电会话记录、详情"
|
||||
href="/admin/sessions"
|
||||
icon={<FiList size={28}/>}
|
||||
disabled={true}
|
||||
/>
|
||||
<NavCard
|
||||
title="用户管理"
|
||||
@@ -208,6 +200,12 @@ const AdminDashboardPage = () => {
|
||||
href="/admin/user-management"
|
||||
icon={<FiUserCheck size={28}/>}
|
||||
/>
|
||||
<NavCard
|
||||
title="激活码管理"
|
||||
description="生成、查询和管理激活码"
|
||||
href="/admin/activation-codes"
|
||||
icon={<FiGift size={28}/>}
|
||||
/>
|
||||
<NavCard
|
||||
title="系统设置"
|
||||
description="配置系统参数、费率等"
|
||||
|
||||
@@ -5,8 +5,11 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { api, BaseResponse } from '@/utils/axios'; // Import the api instance
|
||||
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle } from 'react-icons/fi';
|
||||
import { api } from '@/services/api'; // 新的API实例导入
|
||||
import { BaseResponse } from '@/types/api'; // 新的BaseResponse导入
|
||||
import { FiDollarSign, FiZap, FiClock, FiList, FiChevronRight, FiInfo, FiPower, FiAlertCircle, FiGift } from 'react-icons/fi';
|
||||
import { Button, Modal, message } from 'antd'; // 确保Button和Modal已导入
|
||||
import RedeemCodeForm from '@/components/RedeemCodeForm'; // 导入兑换表单组件
|
||||
|
||||
// Interface for active charging session (matches ChargingSessionVO fields used)
|
||||
interface ActiveChargingSession {
|
||||
@@ -26,7 +29,7 @@ interface UserDashboardStats {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth();
|
||||
const { user, isAuthenticated, isLoading: authLoading, logout, checkAuth } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [activeSession, setActiveSession] = useState<ActiveChargingSession | null | undefined>(undefined);
|
||||
@@ -34,6 +37,7 @@ export default function DashboardPage() {
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [isRedeemModalVisible, setIsRedeemModalVisible] = useState(false); // Modal状态
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
@@ -51,44 +55,30 @@ export default function DashboardPage() {
|
||||
setDataLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 使用 Promise.allSettled 来处理部分请求失败的情况,如果需要
|
||||
const [sessionResult, statsResult] = await Promise.all([
|
||||
api.get<BaseResponse<ActiveChargingSession | null>>('/session/my/active'),
|
||||
api.get<BaseResponse<UserDashboardStats>>('/user/stats/mine')
|
||||
]);
|
||||
|
||||
if (sessionResult.data && sessionResult.data.code === 0) { // 假设 code 0 代表成功
|
||||
console.log("Active session response:", sessionResult.data);
|
||||
setActiveSession(sessionResult.data.data); // data 可以是 null
|
||||
if (sessionResult.data && sessionResult.data.code === 0) {
|
||||
setActiveSession(sessionResult.data.data);
|
||||
} else {
|
||||
// setActiveSession(null); // 或者根据后端错误信息设置
|
||||
console.warn("Failed to fetch active session or no active session:", sessionResult.data?.message);
|
||||
setActiveSession(null); // 如果没有活动会话,后端data.data可能为null,是正常情况
|
||||
setActiveSession(null);
|
||||
}
|
||||
|
||||
if (statsResult.data && statsResult.data.code === 0 && statsResult.data.data) {
|
||||
console.log("User stats response:", statsResult.data.data);
|
||||
// 检查后端返回的是monthlyCharges还是monthlySessions,并进行适当转换
|
||||
const statsData = statsResult.data.data;
|
||||
if ('monthlyCharges' in statsData && statsData.monthlyCharges !== undefined) {
|
||||
// 如果后端返回的是monthlyCharges字段,映射到monthlySessions
|
||||
setUserStats({
|
||||
monthlySessions: statsData.monthlyCharges as number,
|
||||
monthlySpending: statsData.monthlySpending
|
||||
});
|
||||
} else {
|
||||
// 否则假定数据结构已匹配接口
|
||||
setUserStats(statsData);
|
||||
}
|
||||
setUserStats({
|
||||
monthlySessions: (statsData as any).monthlyCharges !== undefined ? (statsData as any).monthlyCharges : statsData.monthlySessions,
|
||||
monthlySpending: statsData.monthlySpending
|
||||
});
|
||||
} else {
|
||||
setUserStats(null);
|
||||
console.error("Failed to fetch user stats:", statsResult.data?.message);
|
||||
// setError(prev => prev ? prev + '\n' + (statsResult.data?.message || '获取用户统计失败') : (statsResult.data?.message || '获取用户统计失败'));
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching dashboard data:", err);
|
||||
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败,请稍后再试。';
|
||||
const errorMessage = err.response?.data?.message || err.message || '获取仪表盘数据失败';
|
||||
setError(prevError => prevError ? `${prevError}\n${errorMessage}` : errorMessage);
|
||||
setActiveSession(null);
|
||||
setUserStats(null);
|
||||
@@ -100,15 +90,14 @@ export default function DashboardPage() {
|
||||
if (isAuthenticated && user?.role === 'user') {
|
||||
fetchData();
|
||||
} else if (!authLoading && !isAuthenticated) {
|
||||
// Handle cases where user is not authenticated and not loading
|
||||
setDataLoading(false); // Stop loading if not fetching
|
||||
setDataLoading(false);
|
||||
}
|
||||
}, [fetchData, isAuthenticated, user, authLoading]);
|
||||
|
||||
// 直接从仪表盘停止充电
|
||||
const handleStopCharging = async () => {
|
||||
if (!activeSession || !activeSession.id) {
|
||||
setError("无法停止充电:无效的会话信息。");
|
||||
message.error("无法停止充电:无效的会话信息。"); // 使用 antd message
|
||||
return;
|
||||
}
|
||||
setIsStopping(true);
|
||||
@@ -116,14 +105,17 @@ export default function DashboardPage() {
|
||||
try {
|
||||
const response = await api.post<BaseResponse<any>>(`/session/stop`, { sessionId: activeSession.id });
|
||||
if (response.data && response.data.code === 0) {
|
||||
// 停止成功后重新获取数据
|
||||
fetchData();
|
||||
message.success('已成功发送停止充电请求。');
|
||||
} else {
|
||||
setError(response.data?.message || "停止充电请求失败");
|
||||
message.error(response.data?.message || "停止充电请求失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error stopping charging session:", err);
|
||||
setError(err.response?.data?.message || err.message || "停止充电时发生错误");
|
||||
const msg = err.response?.data?.message || err.message || "停止充电时发生错误";
|
||||
setError(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
@@ -168,19 +160,32 @@ export default function DashboardPage() {
|
||||
return '--';
|
||||
};
|
||||
|
||||
const handleRedeemSuccess = () => {
|
||||
setIsRedeemModalVisible(false);
|
||||
checkAuth(); // 刷新用户数据,尤其是余额
|
||||
// fetchData(); // 也可以选择刷新整个仪表盘数据,如果余额不通过checkAuth更新
|
||||
message.success('激活码已成功兑换,余额已更新!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800">用户中心</h1>
|
||||
{/* Logout button is in global layout, no need here if using that strategy */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FiGift className="mr-2" />}
|
||||
onClick={() => setIsRedeemModalVisible(true)}
|
||||
>
|
||||
兑换激活码
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-2">欢迎, {user.username}!</h2>
|
||||
<p className="text-gray-600 mb-1"><span className="font-medium">用户ID:</span> {user.id}</p>
|
||||
<p className="text-gray-600 mb-1"><span className="font-medium">角色:</span> {user.role === 'user' ? '普通用户' : user.role}</p>
|
||||
<p className="text-gray-600"><span className="font-medium">账户余额:</span> ¥{user.balance?.toFixed(2) ?? 'N/A'}</p>
|
||||
<p className="text-gray-600"><span className="font-medium">账户余额:</span> <span className="text-lg font-semibold text-green-600">¥{user.balance?.toFixed(2) ?? 'N/A'}</span></p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -286,6 +291,20 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="兑换激活码"
|
||||
open={isRedeemModalVisible}
|
||||
onCancel={() => setIsRedeemModalVisible(false)}
|
||||
footer={null} // RedeemCodeForm 内部有自己的提交和取消逻辑
|
||||
destroyOnClose // 关闭时销毁内部组件状态
|
||||
>
|
||||
<RedeemCodeForm
|
||||
onSuccess={handleRedeemSuccess}
|
||||
onCancel={() => setIsRedeemModalVisible(false)} // Modal 的取消按钮也调用此 handler
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
charging_web_app/src/app/(authenticated)/redeem-code/page.tsx
Normal file
107
charging_web_app/src/app/(authenticated)/redeem-code/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState, ChangeEvent, FormEvent } from 'react';
|
||||
import { Input, Button, Card, Typography, message } from 'antd';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api as apiService } from '@/services/api';
|
||||
|
||||
// 暂时在此处定义 BaseResponse,因为 @/services/api.ts 未导出它
|
||||
// 理想情况下,它应该在全局类型定义文件中或与api实例一起导出
|
||||
export interface BaseResponse<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const RedeemActivationCodePage = () => {
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCode(e.target.value.trim());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
message.error('请先登录后再兑换激活码。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError('请输入激活码。');
|
||||
message.error('请输入激活码。');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiService.post<BaseResponse<boolean>>('/api/activation-code/redeem',
|
||||
{ code }
|
||||
);
|
||||
|
||||
if (response.data && response.data.code === 0 && response.data.data === true) {
|
||||
message.success('激活码兑换成功!您的余额已更新。');
|
||||
setCode('');
|
||||
} else {
|
||||
const errorMessage = response.data?.message || '兑换失败,请稍后再试。';
|
||||
setError(errorMessage);
|
||||
message.error(errorMessage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Redeem code error:', err);
|
||||
const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误,请检查网络或联系管理员。';
|
||||
setError(errorMessage);
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '500px', margin: '50px auto', padding: '20px' }}>
|
||||
<Card bordered={false}>
|
||||
<Title level={3} style={{ textAlign: 'center', marginBottom: '30px' }}>
|
||||
兑换激活码
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
placeholder="请输入您的激活码"
|
||||
value={code}
|
||||
onChange={handleInputChange}
|
||||
size="large"
|
||||
style={{ marginBottom: '20px' }}
|
||||
disabled={isLoading || !isAuthenticated}
|
||||
/>
|
||||
{error && (
|
||||
<Typography.Text type="danger" style={{ display: 'block', marginBottom: '10px' }}>
|
||||
{error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isAuthenticated}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{isLoading ? '兑换中...' : '立即兑换'}
|
||||
</Button>
|
||||
{!isAuthenticated && (
|
||||
<Typography.Text type="warning" style={{ display: 'block', marginTop: '10px', textAlign: 'center' }}>
|
||||
请先登录以兑换激活码。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedeemActivationCodePage;
|
||||
109
charging_web_app/src/components/RedeemCodeForm.tsx
Normal file
109
charging_web_app/src/components/RedeemCodeForm.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useState, ChangeEvent, FormEvent } from 'react';
|
||||
import { Input, Button, Typography, message } from 'antd';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api as apiService } from '@/services/api';
|
||||
import { BaseResponse } from '@/types/api'; // 假设 BaseResponse 移到了这里
|
||||
|
||||
interface RedeemCodeFormProps {
|
||||
onSuccess?: () => void; // 兑换成功后的回调
|
||||
onCancel?: () => void; // 可选的取消/关闭回调
|
||||
}
|
||||
|
||||
const RedeemCodeForm: React.FC<RedeemCodeFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isAuthenticated, checkAuth } = useAuth(); // checkAuth 用于成功后刷新用户信息
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCode(e.target.value.trim());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: FormEvent<HTMLFormElement>) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
message.error('请先登录后再兑换激活码。');
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
setError('请输入激活码。');
|
||||
message.error('请输入激活码。');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiService.post<BaseResponse<boolean>>('/activation-code/redeem',
|
||||
{ code }
|
||||
);
|
||||
|
||||
console.log('Backend response for redeem:', response.data);
|
||||
|
||||
if (response.data && response.data.code === 0 && response.data.data === true) {
|
||||
message.success('激活码兑换成功!您的余额已更新。');
|
||||
setCode('');
|
||||
await checkAuth(); // 刷新用户信息,特别是余额
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
const errorMessage = response.data?.message || '兑换失败,请稍后再试。';
|
||||
setError(errorMessage);
|
||||
message.error(errorMessage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Redeem code error:', err);
|
||||
const errorMessage = err.response?.data?.message || err.message || '兑换过程中发生错误。';
|
||||
setError(errorMessage);
|
||||
message.error(errorMessage);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
placeholder="请输入您的激活码"
|
||||
value={code}
|
||||
onChange={handleInputChange}
|
||||
size="large"
|
||||
style={{ marginBottom: '20px' }}
|
||||
disabled={isLoading || !isAuthenticated}
|
||||
/>
|
||||
{error && (
|
||||
<Typography.Text type="danger" style={{ display: 'block', marginBottom: '10px' }}>
|
||||
{error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isAuthenticated || !code.trim()}
|
||||
block
|
||||
size="large"
|
||||
style={{ marginBottom: onCancel ? '10px' : '0' }} // 如果有取消按钮,给兑换按钮留点空间
|
||||
>
|
||||
{isLoading ? '兑换中...' : '立即兑换'}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
block
|
||||
size="large"
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
{!isAuthenticated && (
|
||||
<Typography.Text type="warning" style={{ display: 'block', marginTop: '10px', textAlign: 'center' }}>
|
||||
请先登录以兑换激活码。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedeemCodeForm;
|
||||
@@ -44,6 +44,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
try {
|
||||
const response = await api.get('/user/current');
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
console.log('Data from /user/current for setUser:', JSON.stringify(response.data.data));
|
||||
setUser(response.data.data);
|
||||
console.log('User authenticated:', response.data.data.username, 'Role:', response.data.data.role);
|
||||
} else {
|
||||
|
||||
10
charging_web_app/src/types/api.ts
Normal file
10
charging_web_app/src/types/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 通用的后端响应体结构
|
||||
*/
|
||||
export interface BaseResponse<T> {
|
||||
code: number; // 响应状态码,0表示成功
|
||||
data: T; // 泛型数据,可以是任何类型
|
||||
message?: string; // 可选的消息说明
|
||||
}
|
||||
|
||||
// 你可以在这里添加其他通用的API相关类型定义
|
||||
Reference in New Issue
Block a user