基本功能完成,下一步开始美化UI
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} Boonlive OA System. All Rights Reserved.
|
||||
</div>
|
||||
<Logo variant="secondary" />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
export default AdminLoginPage;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
199
src/pages/admin/UserGroupManage.tsx
Normal file
199
src/pages/admin/UserGroupManage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user