基本功能完成,下一步开始美化UI

This commit is contained in:
2025-12-19 16:02:38 +08:00
parent 465d4d7b4a
commit 6ac216d184
46 changed files with 2576 additions and 618 deletions

View File

@@ -8,7 +8,7 @@ import {
} from '@ant-design/icons';
import { adminAPI } from '../../services/api';
import { formatDateTime } from '../../utils/validation';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from 'recharts';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts';
interface Statistics {
totalUsers: number;
@@ -98,7 +98,7 @@ const AdminDashboardPage = () => {
title: '得分',
dataIndex: 'totalScore',
key: 'totalScore',
render: (score: number) => <span className="font-semibold text-blue-600">{score} </span>,
render: (score: number) => <span className="font-semibold text-mars-600">{score} </span>,
},
{
title: '正确率',
@@ -139,6 +139,7 @@ const AdminDashboardPage = () => {
icon={<ReloadOutlined />}
onClick={fetchDashboardData}
loading={loading}
className="bg-mars-500 hover:bg-mars-600"
>
</Button>
@@ -147,33 +148,33 @@ const AdminDashboardPage = () => {
{/* 统计卡片 */}
<Row gutter={16} className="mb-8">
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="总用户数"
value={statistics?.totalUsers || 0}
prefix={<UserOutlined className="text-blue-500" />}
valueStyle={{ color: '#1890ff' }}
prefix={<UserOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="答题记录"
value={statistics?.totalRecords || 0}
prefix={<BarChartOutlined className="text-green-500" />}
valueStyle={{ color: '#52c41a' }}
prefix={<BarChartOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
/>
</Card>
</Col>
<Col span={8}>
<Card className="shadow-sm">
<Card className="shadow-sm hover:shadow-md transition-shadow">
<Statistic
title="平均得分"
value={statistics?.averageScore || 0}
precision={1}
prefix={<QuestionCircleOutlined className="text-orange-500" />}
valueStyle={{ color: '#fa8c16' }}
prefix={<QuestionCircleOutlined className="text-mars-400" />}
valueStyle={{ color: '#008C8C' }}
suffix="分"
/>
</Card>
@@ -186,14 +187,14 @@ const AdminDashboardPage = () => {
<Row gutter={16}>
{statistics.typeStats.map((stat) => (
<Col span={6} key={stat.type}>
<Card size="small" className="text-center">
<Card size="small" className="text-center hover:shadow-sm transition-shadow">
<div className="text-sm text-gray-600 mb-2">
{stat.type === 'single' && '单选题'}
{stat.type === 'multiple' && '多选题'}
{stat.type === 'judgment' && '判断题'}
{stat.type === 'text' && '文字题'}
</div>
<div className="text-2xl font-bold text-blue-600">
<div className="text-2xl font-bold text-mars-600">
{stat.correctRate}%
</div>
<div className="text-xs text-gray-500">
@@ -244,13 +245,13 @@ const AdminDashboardPage = () => {
return (
<div className="flex items-center">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2">
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-300"
className="h-full bg-mars-500 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<span className="font-semibold text-blue-600">{progress}%</span>
<span className="font-semibold text-mars-600">{progress}%</span>
</div>
);
},
@@ -262,41 +263,66 @@ const AdminDashboardPage = () => {
// 计算各类人数
const total = record.totalUsers;
const completed = record.completedUsers;
const passed = Math.round(completed * (record.passRate / 100));
const excellent = Math.round(completed * (record.excellentRate / 100));
const incomplete = total - completed;
// 准备饼图数据
// 原始计算
const passedTotal = Math.round(completed * (record.passRate / 100));
const excellentTotal = Math.round(completed * (record.excellentRate / 100));
// 互斥分类计算
const incomplete = total - completed;
const failed = completed - passedTotal;
const passedOnly = passedTotal - excellentTotal;
const excellent = excellentTotal;
// 准备环形图数据 (互斥分类)
const pieData = [
{ name: '已完成', value: completed, color: '#1890ff' },
{ name: '合格', value: passed, color: '#52c41a' },
{ name: '优秀', value: excellent, color: '#fa8c16' },
{ name: '未完成', value: incomplete, color: '#d9d9d9' }
{ name: '优秀', value: excellent, color: '#008C8C' }, // Mars Green (Primary)
{ name: '合格', value: passedOnly, color: '#00A3A3' }, // Mars Light
{ name: '不及格', value: failed, color: '#ff4d4f' }, // Red (Error)
{ name: '未完成', value: incomplete, color: '#f0f0f0' } // Gray
];
// 只显示有数据的项
const filteredData = pieData.filter(item => item.value > 0);
// 计算完成率用于中间显示
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="w-full h-40">
<div className="w-full h-20">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filteredData}
cx="50%"
cy="50%"
labelLine={{ stroke: '#999', strokeWidth: 1 }}
outerRadius={50}
fill="#8884d8"
innerRadius={25}
outerRadius={35}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${name}${value}`}
>
{filteredData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
<Label
value={`${completionRate}%`}
position="center"
className="text-sm font-bold fill-gray-700"
/>
</Pie>
<RechartsTooltip formatter={(value) => [`${value}`, '数量']} />
<Legend layout="vertical" verticalAlign="middle" align="right" formatter={(value, entry) => `${value} ${entry.payload?.value || 0}`} />
<RechartsTooltip
formatter={(value: any) => [`${value}`, '数量']}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: '12px' }}
formatter={(value, entry: any) => <span className="text-xs text-gray-600 ml-1">{value} {entry.payload.value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
@@ -328,4 +354,4 @@ const AdminDashboardPage = () => {
);
};
export default AdminDashboardPage;
export default AdminDashboardPage;

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { Card, Form, Input, Button, message, Select } from 'antd';
import { Card, Form, Input, Button, message, Select, Layout } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts';
import { adminAPI } from '../../services/api';
import { Logo } from '../../components/common/Logo';
const { Header, Content, Footer } = Layout;
// 定义登录记录类型 - 不再保存密码
interface LoginRecord {
@@ -90,109 +93,118 @@ const AdminLoginPage = () => {
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<Card className="shadow-xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-blue-600 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
<Layout className="min-h-screen bg-gray-50">
<Header className="bg-white shadow-sm flex items-center px-6 h-16 sticky top-0 z-10">
<Logo variant="primary" />
</Header>
<Form
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
form={form}
>
{/* 最近登录记录下拉选择 */}
{loginRecords.length > 0 && (
<Form.Item label="最近登录" className="mb-4">
<Select
size="large"
placeholder="选择最近登录记录"
className="w-full rounded-lg"
style={{ height: 'auto' }} // 让选择框高度自适应内容
onSelect={(value) => {
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
if (record) {
handleSelectRecord(record);
}
}}
options={loginRecords.map(record => ({
value: `${record.username}-${record.timestamp}`,
label: (
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
<span className="font-medium block">{record.username}</span>
<span className="text-xs text-gray-500 block">
{new Date(record.timestamp).toLocaleString()}
</span>
</div>
)
}))}
<Content className="flex items-center justify-center p-4 bg-gradient-to-br from-mars-50 to-white">
<div className="w-full max-w-md">
<Card className="shadow-xl border-t-4 border-t-mars-500">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-mars-600 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
<Form
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
form={form}
size="large"
>
{/* 最近登录记录下拉选择 */}
{loginRecords.length > 0 && (
<Form.Item label="最近登录" className="mb-4">
<Select
placeholder="选择最近登录记录"
className="w-full"
style={{ height: 'auto' }} // 让选择框高度自适应内容
onSelect={(value) => {
const record = loginRecords.find(r => `${r.username}-${r.timestamp}` === value);
if (record) {
handleSelectRecord(record);
}
}}
options={loginRecords.map(record => ({
value: `${record.username}-${record.timestamp}`,
label: (
<div className="flex flex-col p-1" style={{ minHeight: '40px', justifyContent: 'center' }}>
<span className="font-medium block">{record.username}</span>
<span className="text-xs text-gray-500 block">
{new Date(record.timestamp).toLocaleString()}
</span>
</div>
)
}))}
/>
</Form.Item>
)}
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
className="mb-4"
>
<Input
placeholder="请输入用户名"
autoComplete="username" // 正确的自动完成属性
/>
</Form.Item>
)}
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }
]}
className="mb-4"
>
<Input
placeholder="请输入用户名"
size="large"
className="rounded-lg"
autoComplete="username" // 正确的自动完成属性
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' }
]}
className="mb-4"
>
<Input.Password
placeholder="请输入密码"
size="large"
className="rounded-lg"
autoComplete="new-password" // 防止自动填充密码
allowClear // 允许清空密码
/>
</Form.Item>
<Form.Item className="mb-0">
<Button
type="primary"
htmlType="submit"
loading={loading}
size="large"
className="w-full rounded-lg bg-blue-600 hover:bg-blue-700 border-none"
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' }
]}
className="mb-4"
>
</Button>
</Form.Item>
</Form>
<Input.Password
placeholder="请输入密码"
autoComplete="new-password" // 防止自动填充密码
allowClear // 允许清空密码
/>
</Form.Item>
<div className="mt-6 text-center">
<a
href="/"
className="text-blue-600 hover:text-blue-800 text-sm"
>
</a>
</div>
</Card>
</div>
</div>
<Form.Item className="mb-0 mt-8">
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="bg-mars-500 hover:!bg-mars-600 border-none shadow-md h-12 text-lg font-medium"
>
</Button>
</Form.Item>
</Form>
<div className="mt-6 text-center">
<a
href="/"
className="text-mars-600 hover:text-mars-800 text-sm transition-colors"
>
</a>
</div>
</Card>
</div>
</Content>
<Footer className="bg-white border-t border-gray-100 py-6 px-8 flex flex-col md:flex-row justify-between items-center text-gray-400 text-sm">
<div className="mb-4 md:mb-0">
&copy; {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
</div>
<Logo variant="secondary" />
</Footer>
</Layout>
);
};
export default AdminLoginPage;
export default AdminLoginPage;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select } from 'antd';
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, DatePicker, Select, Tabs, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import api from '../../services/api';
import api, { userGroupAPI } from '../../services/api';
import dayjs from 'dayjs';
interface ExamTask {
@@ -16,6 +16,7 @@ interface ExamTask {
passRate: number;
excellentRate: number;
createdAt: string;
selectionConfig?: string;
}
interface ExamSubject {
@@ -29,28 +30,40 @@ interface User {
phone: string;
}
interface UserGroup {
id: string;
name: string;
memberCount: number;
}
const ExamTaskPage = () => {
const [tasks, setTasks] = useState<ExamTask[]>([]);
const [subjects, setSubjects] = useState<ExamSubject[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [reportModalVisible, setReportModalVisible] = useState(false);
const [reportData, setReportData] = useState<any>(null);
const [editingTask, setEditingTask] = useState<ExamTask | null>(null);
const [form] = Form.useForm();
// Cache for group members to calculate unique users
const [groupMembersMap, setGroupMembersMap] = useState<Record<string, string[]>>({});
const fetchData = async () => {
setLoading(true);
try {
const [tasksRes, subjectsRes, usersRes] = await Promise.all([
const [tasksRes, subjectsRes, usersRes, groupsRes] = await Promise.all([
api.get('/admin/tasks'),
api.get('/admin/subjects'),
api.get('/admin/users'),
userGroupAPI.getAll(),
]);
setTasks(tasksRes.data);
setSubjects(subjectsRes.data);
setUsers(usersRes.data);
setUserGroups(groupsRes);
} catch (error) {
message.error('获取数据失败');
} finally {
@@ -62,6 +75,44 @@ const ExamTaskPage = () => {
fetchData();
}, []);
// Watch form values for real-time calculation
const selectedUserIds = Form.useWatch('userIds', form) || [];
const selectedGroupIds = Form.useWatch('groupIds', form) || [];
// Fetch members when groups are selected
useEffect(() => {
const fetchMissingGroupMembers = async () => {
if (selectedGroupIds.length > 0) {
for (const gid of selectedGroupIds) {
if (!groupMembersMap[gid]) {
try {
const members = await userGroupAPI.getMembers(gid);
setGroupMembersMap(prev => ({
...prev,
[gid]: members.map((u: any) => u.id)
}));
} catch (e) {
console.error(`Failed to fetch members for group ${gid}`, e);
}
}
}
}
};
if (modalVisible) {
fetchMissingGroupMembers();
}
}, [selectedGroupIds, modalVisible]);
const uniqueUserCount = useMemo(() => {
const uniqueSet = new Set<string>(selectedUserIds);
selectedGroupIds.forEach((gid: string) => {
const members = groupMembersMap[gid] || [];
members.forEach(uid => uniqueSet.add(uid));
});
return uniqueSet.size;
}, [selectedUserIds, selectedGroupIds, groupMembersMap]);
const handleCreate = () => {
setEditingTask(null);
form.resetFields();
@@ -71,9 +122,27 @@ const ExamTaskPage = () => {
const handleEdit = async (task: ExamTask) => {
setEditingTask(task);
try {
// 获取任务已分配的用户列表
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
const userIds = userIdsRes.data;
// Parse selection config if available
let userIds = [];
let groupIds = [];
if (task.selectionConfig) {
try {
const config = JSON.parse(task.selectionConfig);
userIds = config.userIds || [];
groupIds = config.groupIds || [];
} catch (e) {
console.error('Failed to parse selection config', e);
}
}
// Fallback or if no selection config (legacy tasks), fetch from API which returns resolved users
// But for editing legacy tasks, we might not have group info.
// If selectionConfig is missing, we assume individual users only.
if (!task.selectionConfig) {
const userIdsRes = await api.get(`/admin/tasks/${task.id}/users`);
userIds = userIdsRes.data;
}
form.setFieldsValue({
name: task.name,
@@ -81,17 +150,10 @@ const ExamTaskPage = () => {
startAt: dayjs(task.startAt),
endAt: dayjs(task.endAt),
userIds: userIds,
groupIds: groupIds,
});
} catch (error) {
message.error('获取任务用户失败');
// 即使获取失败,也要打开模态框,只是用户列表为空
form.setFieldsValue({
name: task.name,
subjectId: task.subjectId,
startAt: dayjs(task.startAt),
endAt: dayjs(task.endAt),
userIds: [],
});
message.error('获取任务详情失败');
}
setModalVisible(true);
};
@@ -119,6 +181,12 @@ const ExamTaskPage = () => {
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (uniqueUserCount === 0) {
message.warning('请至少选择一位用户或一个用户组');
return;
}
const payload = {
...values,
startAt: values.startAt.toISOString(),
@@ -144,11 +212,15 @@ const ExamTaskPage = () => {
title: '任务名称',
dataIndex: 'name',
key: 'name',
width: 250,
ellipsis: true,
},
{
title: '考试科目',
dataIndex: 'subjectName',
key: 'subjectName',
width: 200,
ellipsis: true,
},
{
title: '开始时间',
@@ -233,8 +305,9 @@ const ExamTaskPage = () => {
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: ExamTask) => (
<Space>
<Space direction="vertical" size={0}>
<Button
type="text"
icon={<EditOutlined />}
@@ -349,32 +422,67 @@ const ExamTaskPage = () => {
/>
</Form.Item>
<Form.Item
name="userIds"
label="参与用户"
rules={[{ required: true, message: '请选择参与用户' }]}
>
<Select
mode="multiple"
placeholder="请选择参与用户"
style={{ width: '100%' }}
showSearch
filterOption={(input, option) => {
const value = option?.children as string;
return value.toLowerCase().includes(input.toLowerCase());
}}
maxTagCount={3}
maxTagPlaceholder={(count) => `+${count} 个用户`}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
virtual
<div className="bg-gray-50 p-4 rounded mb-4">
<h4 className="mb-2 font-medium"></h4>
<Form.Item
name="groupIds"
label="按用户组选择"
>
{users.map((user) => (
<Select.Option key={user.id} value={user.id}>
{user.name} ({user.phone})
</Select.Option>
))}
</Select>
</Form.Item>
<Select
mode="multiple"
placeholder="请选择用户组"
style={{ width: '100%' }}
optionFilterProp="children"
>
{userGroups.map((group) => (
<Select.Option key={group.id} value={group.id}>
{group.name} ({group.memberCount})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="userIds"
label="按单个用户选择"
normalize={(value) => {
if (!Array.isArray(value)) return value;
return [...value].sort((a, b) => {
const nameA = users.find(u => u.id === a)?.name || '';
const nameB = users.find(u => u.id === b)?.name || '';
return nameA.localeCompare(nameB, 'zh-CN');
});
}}
>
<Select
mode="multiple"
placeholder="请选择用户"
style={{ width: '100%' }}
className="user-select-scrollable"
showSearch
optionLabelProp="label"
filterOption={(input, option) => {
const label = option?.label as string;
if (label && label.toLowerCase().includes(input.toLowerCase())) return true;
const children = React.Children.toArray(option?.children).join('');
return children.toLowerCase().includes(input.toLowerCase());
}}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
virtual
>
{users.map((user) => (
<Select.Option key={user.id} value={user.id} label={user.name}>
{user.name} ({user.phone})
</Select.Option>
))}
</Select>
</Form.Item>
<div className="mt-2 text-right text-gray-500">
<span className="font-bold text-blue-600">{uniqueUserCount}</span>
</div>
</div>
</Form>
</Modal>

View File

@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined } from '@ant-design/icons';
import { userGroupAPI } from '../../services/api';
import dayjs from 'dayjs';
interface UserGroup {
id: string;
name: string;
description: string;
isSystem: boolean;
createdAt: string;
memberCount: number;
}
const UserGroupManage = () => {
const [groups, setGroups] = useState<UserGroup[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingGroup, setEditingGroup] = useState<UserGroup | null>(null);
const [form] = Form.useForm();
const fetchGroups = async () => {
setLoading(true);
try {
const res = await userGroupAPI.getAll();
setGroups(res);
} catch (error) {
message.error('获取用户组列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchGroups();
}, []);
const handleCreate = () => {
setEditingGroup(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (group: UserGroup) => {
if (group.isSystem) {
message.warning('系统内置用户组无法修改');
return;
}
setEditingGroup(group);
form.setFieldsValue({
name: group.name,
description: group.description,
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await userGroupAPI.delete(id);
message.success('删除成功');
fetchGroups();
} catch (error: any) {
message.error(error.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingGroup) {
await userGroupAPI.update(editingGroup.id, values);
message.success('更新成功');
} else {
await userGroupAPI.create(values);
message.success('创建成功');
}
setModalVisible(false);
fetchGroups();
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
const columns = [
{
title: '组名',
dataIndex: 'name',
key: 'name',
render: (text: string, record: UserGroup) => (
<Space>
{text}
{record.isSystem && <Tag color="blue"></Tag>}
</Space>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '成员数',
dataIndex: 'memberCount',
key: 'memberCount',
render: (count: number) => (
<Space>
<TeamOutlined />
{count}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: UserGroup) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
disabled={record.isSystem}
>
</Button>
{!record.isSystem && (
<Popconfirm
title="确定删除该用户组吗?"
description="删除后,组内成员将自动解除与该组的关联"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div className="flex justify-end mb-4">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={groups}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
/>
<Modal
title={editingGroup ? '编辑用户组' : '新增用户组'}
open={modalVisible}
onOk={handleModalOk}
onCancel={() => setModalVisible(false)}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="组名"
rules={[
{ required: true, message: '请输入组名' },
{ min: 2, max: 20, message: '组名长度在2-20个字符之间' }
]}
>
<Input placeholder="请输入组名" />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea placeholder="请输入描述" rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserGroupManage;

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload } from 'antd';
import { Table, Button, Input, Space, message, Popconfirm, Modal, Form, Switch, Upload, Tabs, Select, Tag } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ExportOutlined, ImportOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import api from '../../services/api';
import api, { userGroupAPI } from '../../services/api';
import type { UploadProps } from 'antd';
import UserGroupManage from './UserGroupManage';
interface User {
id: string;
@@ -12,6 +13,7 @@ interface User {
createdAt: string;
examCount?: number; // 参加考试次数
lastExamTime?: string; // 最后一次参加考试时间
groups?: any[];
}
interface QuizRecord {
@@ -25,21 +27,9 @@ interface QuizRecord {
taskName?: string;
}
interface QuizRecordDetail {
id: string;
question: {
content: string;
type: string;
options?: string[];
};
userAnswer: string | string[];
correctAnswer: string | string[];
score: number;
isCorrect: boolean;
}
const UserManagePage = () => {
const [users, setUsers] = useState<User[]>([]);
const [userGroups, setUserGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
@@ -92,8 +82,18 @@ const UserManagePage = () => {
}
};
const fetchUserGroups = async () => {
try {
const res = await userGroupAPI.getAll();
setUserGroups(res);
} catch (error) {
console.error('获取用户组失败');
}
};
useEffect(() => {
fetchUsers();
fetchUserGroups();
}, []);
const handleTableChange = (newPagination: any) => {
@@ -113,6 +113,11 @@ const UserManagePage = () => {
const handleCreate = () => {
setEditingUser(null);
form.resetFields();
// Set default groups (e.g. system group)
const systemGroups = userGroups.filter(g => g.isSystem).map(g => g.id);
form.setFieldsValue({ groupIds: systemGroups });
setModalVisible(true);
};
@@ -122,6 +127,7 @@ const UserManagePage = () => {
name: user.name,
phone: user.phone,
password: user.password,
groupIds: user.groups?.map(g => g.id) || []
});
setModalVisible(true);
};
@@ -292,6 +298,18 @@ const UserManagePage = () => {
dataIndex: 'phone',
key: 'phone',
},
{
title: '用户组',
dataIndex: 'groups',
key: 'groups',
render: (groups: any[]) => (
<Space size={[0, 4]} wrap>
{groups?.map(g => (
<Tag key={g.id} color={g.isSystem ? 'blue' : 'default'}>{g.name}</Tag>
))}
</Space>
)
},
{
title: '密码',
dataIndex: 'password',
@@ -367,10 +385,9 @@ const UserManagePage = () => {
beforeUpload: handleImport,
};
return (
const UserListContent = () => (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-800 mb-4"></h1>
<div className="flex justify-between items-center">
<Input
placeholder="按姓名搜索"
@@ -514,6 +531,22 @@ const UserManagePage = () => {
>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item
name="groupIds"
label="所属用户组"
>
<Select
mode="multiple"
placeholder="请选择用户组"
optionFilterProp="children"
>
{userGroups.map(g => (
<Select.Option key={g.id} value={g.id} disabled={g.isSystem}>
{g.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
@@ -614,6 +647,27 @@ const UserManagePage = () => {
</Modal>
</div>
);
return (
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-4"></h1>
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: '用户列表',
children: <UserListContent />,
},
{
key: '2',
label: '用户组管理',
children: <UserGroupManage />,
},
]}
/>
</div>
);
};
export default UserManagePage;
export default UserManagePage;