mirror of
https://github.com/PlayEdu/backend
synced 2025-06-27 23:52:48 +08:00
commit
7383f08ab8
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
/node_modules
|
||||
/build
|
||||
/dist
|
12
Dockerfile
12
Dockerfile
@ -1,5 +1,13 @@
|
||||
FROM node:lts-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN yarn config set registry https://registry.npm.taobao.org && yarn && yarn build
|
||||
|
||||
FROM nginx:1.23.4-alpine-slim
|
||||
|
||||
COPY dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /app/docker/nginx.conf /etc/nginx/nginx.conf
|
13
src/AutoTop.ts
Normal file
13
src/AutoTop.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
const AutoScorllTop: React.FC<{ children: any }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export default AutoScorllTop;
|
@ -78,3 +78,46 @@ export function storeBatch(startLine: number, users: string[][]) {
|
||||
users: users,
|
||||
});
|
||||
}
|
||||
|
||||
export function learnStats(id: number) {
|
||||
return client.get(`/backend/v1/user/${id}/learn-stats`, {});
|
||||
}
|
||||
|
||||
export function learnHours(
|
||||
id: number,
|
||||
page: number,
|
||||
size: number,
|
||||
params: object
|
||||
) {
|
||||
return client.get(`/backend/v1/user/${id}/learn-hours`, {
|
||||
page,
|
||||
size,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export function learnCourses(
|
||||
id: number,
|
||||
page: number,
|
||||
size: number,
|
||||
params: object
|
||||
) {
|
||||
return client.get(`/backend/v1/user/${id}/learn-courses`, {
|
||||
page,
|
||||
size,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export function departmentProgress(
|
||||
id: number,
|
||||
page: number,
|
||||
size: number,
|
||||
params: object
|
||||
) {
|
||||
return client.get(`/backend/v1/department/${id}/users`, {
|
||||
page,
|
||||
size,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import avatar from "../../assets/images/commen/avatar.png";
|
||||
import { logoutAction } from "../../store/user/loginUserSlice";
|
||||
import { clearToken } from "../../utils/index";
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
@ -13,6 +14,7 @@ export const Header: React.FC = () => {
|
||||
|
||||
const onClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === "login_out") {
|
||||
clearToken();
|
||||
dispatch(logoutAction());
|
||||
navigate("/login");
|
||||
} else if (key === "change_password") {
|
||||
|
@ -11,6 +11,8 @@ interface Option {
|
||||
interface PropInterface {
|
||||
type: string;
|
||||
text: string;
|
||||
refresh: boolean;
|
||||
showNum: boolean;
|
||||
onUpdate: (keys: any, title: any) => void;
|
||||
}
|
||||
|
||||
@ -18,14 +20,22 @@ export const TreeDepartment = (props: PropInterface) => {
|
||||
const [treeData, setTreeData] = useState<any>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [selectKey, setSelectKey] = useState<any>([]);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
department.departmentList().then((res: any) => {
|
||||
const departments = res.data.departments;
|
||||
|
||||
const departCount = res.data.dep_user_count;
|
||||
setUserTotal(res.data.user_total);
|
||||
if (JSON.stringify(departments) !== "{}") {
|
||||
const new_arr: Option[] = checkArr(departments, 0);
|
||||
setTreeData(new_arr);
|
||||
if (props.showNum) {
|
||||
const new_arr: any = checkNewArr(departments, 0, departCount);
|
||||
setTreeData(new_arr);
|
||||
} else {
|
||||
const new_arr: Option[] = checkArr(departments, 0);
|
||||
setTreeData(new_arr);
|
||||
}
|
||||
} else {
|
||||
const new_arr: Option[] = [
|
||||
{
|
||||
@ -38,7 +48,39 @@ export const TreeDepartment = (props: PropInterface) => {
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [props.refresh]);
|
||||
|
||||
const checkNewArr = (departments: any[], id: number, counts: any) => {
|
||||
const arr = [];
|
||||
for (let i = 0; i < departments[id].length; i++) {
|
||||
if (!departments[departments[id][i].id]) {
|
||||
arr.push({
|
||||
title: getNewTitle(
|
||||
departments[id][i].name,
|
||||
departments[id][i].id,
|
||||
counts
|
||||
),
|
||||
key: departments[id][i].id,
|
||||
});
|
||||
} else {
|
||||
const new_arr: any = checkNewArr(
|
||||
departments,
|
||||
departments[id][i].id,
|
||||
counts
|
||||
);
|
||||
arr.push({
|
||||
title: getNewTitle(
|
||||
departments[id][i].name,
|
||||
departments[id][i].id,
|
||||
counts
|
||||
),
|
||||
key: departments[id][i].id,
|
||||
children: new_arr,
|
||||
});
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
const checkArr = (departments: any[], id: number) => {
|
||||
const arr = [];
|
||||
@ -60,6 +102,15 @@ export const TreeDepartment = (props: PropInterface) => {
|
||||
return arr;
|
||||
};
|
||||
|
||||
const getNewTitle = (title: any, id: number, counts: any) => {
|
||||
if (counts) {
|
||||
let value = counts[id] || 0;
|
||||
return title + "(" + value + ")";
|
||||
} else {
|
||||
return title;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = (selectedKeys: any, info: any) => {
|
||||
let label = "全部" + props.text;
|
||||
if (info) {
|
||||
@ -89,6 +140,7 @@ export const TreeDepartment = (props: PropInterface) => {
|
||||
onClick={() => onSelect([], "")}
|
||||
>
|
||||
全部{props.text}
|
||||
{props.showNum && userTotal ? "(" + userTotal + ")" : ""}
|
||||
</div>
|
||||
{treeData.length > 0 && (
|
||||
<Tree
|
||||
|
@ -257,6 +257,17 @@ code {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playedu-main-sp-top {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
float: left;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
padding: 24px 0px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playedu-main-body {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
@ -7,6 +7,7 @@ import { Provider } from "react-redux";
|
||||
import store from "./store";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import AutoScorllTop from "./AutoTop";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<Provider store={store}>
|
||||
@ -15,7 +16,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
theme={{ token: { colorPrimary: "#ff4d4f" } }}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AutoScorllTop>
|
||||
<App />
|
||||
</AutoScorllTop>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</Provider>
|
||||
|
@ -89,6 +89,8 @@ const CoursePage = () => {
|
||||
children: (
|
||||
<div className="float-left">
|
||||
<TreeDepartment
|
||||
refresh={refresh}
|
||||
showNum={false}
|
||||
type="no-course"
|
||||
text={"部门"}
|
||||
onUpdate={(keys: any, title: any) => {
|
||||
@ -231,7 +233,9 @@ const CoursePage = () => {
|
||||
p="course"
|
||||
onClick={() => {
|
||||
setCid(Number(record.id));
|
||||
navigate("/course/user/" + Number(record.id));
|
||||
navigate(
|
||||
"/course/user/" + Number(record.id) + "?title=" + record.title
|
||||
);
|
||||
}}
|
||||
disabled={null}
|
||||
/>
|
||||
@ -337,13 +341,14 @@ const CoursePage = () => {
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
centered
|
||||
tabBarGutter={55}
|
||||
items={items}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="right-box">
|
||||
<div className="playedu-main-title float-left mb-24">
|
||||
课程/{selLabel}
|
||||
线上课 | {selLabel}
|
||||
</div>
|
||||
<div className="float-left j-b-flex mb-24">
|
||||
<div className="d-flex">
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
Image,
|
||||
} from "antd";
|
||||
import { course } from "../../api";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { BackBartment } from "../../compenents";
|
||||
import { ExclamationCircleFilled } from "@ant-design/icons";
|
||||
@ -31,6 +31,7 @@ interface DataType {
|
||||
|
||||
const CourseUserPage = () => {
|
||||
const params = useParams();
|
||||
const result = new URLSearchParams(useLocation().search);
|
||||
const [list, setList] = useState<any>([]);
|
||||
const [users, setUsers] = useState<any>([]);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
@ -42,10 +43,11 @@ const CourseUserPage = () => {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [idCard, setIdCard] = useState<string>("");
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<any>([]);
|
||||
const [title, setTitle] = useState<string>(String(result.get("title")));
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: "学员名称",
|
||||
title: "学员",
|
||||
render: (_, record: any) => (
|
||||
<div className="d-flex">
|
||||
<Image
|
||||
@ -70,11 +72,6 @@ const CourseUserPage = () => {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "学习进度",
|
||||
dataIndex: "progress",
|
||||
render: (progress: number) => <span>{progress / 100}%</span>,
|
||||
},
|
||||
{
|
||||
title: "第一次学习时间",
|
||||
dataIndex: "created_at",
|
||||
@ -85,6 +82,15 @@ const CourseUserPage = () => {
|
||||
dataIndex: "finished_at",
|
||||
render: (text: string) => <span>{dateFormat(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: "学习进度",
|
||||
dataIndex: "progress",
|
||||
render: (progress: number) => (
|
||||
<span className={progress >= 10000 ? "c-green" : "c-red"}>
|
||||
{progress / 100}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@ -178,7 +184,7 @@ const CourseUserPage = () => {
|
||||
<Row className="playedu-main-body">
|
||||
<Col span={24}>
|
||||
<div className="float-left mb-24">
|
||||
<BackBartment title="线上课学员" />
|
||||
<BackBartment title={title || "线上课学员"} />
|
||||
</div>
|
||||
<div className="float-left j-b-flex mb-24">
|
||||
<div className="d-flex">
|
||||
@ -215,7 +221,7 @@ const CourseUserPage = () => {
|
||||
placeholder="请输入学员邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex mr-24">
|
||||
{/* <div className="d-flex mr-24">
|
||||
<Typography.Text>身份证号:</Typography.Text>
|
||||
<Input
|
||||
value={idCard}
|
||||
@ -225,7 +231,7 @@ const CourseUserPage = () => {
|
||||
style={{ width: 160 }}
|
||||
placeholder="请输入身份证号"
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="d-flex">
|
||||
<Button className="mr-16" onClick={resetList}>
|
||||
重 置
|
||||
|
@ -15,7 +15,7 @@ const ErrorPage = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
navigate("/", { replace: true });
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
|
@ -71,7 +71,7 @@ const LoginPage = () => {
|
||||
await getSystemConfig(); //获取系统配置并写入store
|
||||
await getUser(); //获取登录用户的信息并写入store
|
||||
|
||||
navigate("/");
|
||||
navigate("/", { replace: true });
|
||||
} catch (e) {
|
||||
message.error("登录出现错误");
|
||||
console.error("错误信息", e);
|
||||
|
0
src/pages/member/departmentUser.module.less
Normal file
0
src/pages/member/departmentUser.module.less
Normal file
262
src/pages/member/departmentUser.tsx
Normal file
262
src/pages/member/departmentUser.tsx
Normal file
@ -0,0 +1,262 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./departmentUser.module.less";
|
||||
import {
|
||||
Typography,
|
||||
Input,
|
||||
Modal,
|
||||
Image,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Table,
|
||||
} from "antd";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { BackBartment, DurationText } from "../../compenents";
|
||||
import { dateFormat } from "../../utils/index";
|
||||
import { user as member } from "../../api/index";
|
||||
const { Column, ColumnGroup } = Table;
|
||||
|
||||
interface DataType {
|
||||
id: React.Key;
|
||||
title: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
total_duration: number;
|
||||
finished_duration: number;
|
||||
is_finished: boolean;
|
||||
}
|
||||
|
||||
const MemberDepartmentProgressPage = () => {
|
||||
const result = new URLSearchParams(useLocation().search);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [size, setSize] = useState(10);
|
||||
const [list, setList] = useState<any>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [courses, setCourses] = useState<any>([]);
|
||||
const [records, setRecords] = useState<any>({});
|
||||
const [totalHour, setTotalHour] = useState(0);
|
||||
const [name, setName] = useState<string>("");
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [id_card, setIdCard] = useState<string>("");
|
||||
const [did, setDid] = useState(Number(result.get("id")));
|
||||
const [title, setTitle] = useState(String(result.get("title")));
|
||||
|
||||
useEffect(() => {
|
||||
getData();
|
||||
}, [refresh, page, size]);
|
||||
|
||||
const getData = () => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
member
|
||||
.departmentProgress(did, page, size, {
|
||||
sort_field: "",
|
||||
sort_algo: "",
|
||||
name: name,
|
||||
email: email,
|
||||
id_card: id_card,
|
||||
})
|
||||
.then((res: any) => {
|
||||
setList(res.data.data);
|
||||
setTotal(res.data.total);
|
||||
let data = res.data.courses;
|
||||
let arr = [];
|
||||
let value = 0;
|
||||
for (let key in data) {
|
||||
arr.push(data[key]);
|
||||
value += data[key].class_hour;
|
||||
}
|
||||
setCourses(arr);
|
||||
setTotalHour(value);
|
||||
setRecords(res.data.user_course_records);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const resetData = () => {
|
||||
setName("");
|
||||
setEmail("");
|
||||
setIdCard("");
|
||||
setPage(1);
|
||||
setSize(10);
|
||||
setList([]);
|
||||
setRefresh(!refresh);
|
||||
};
|
||||
|
||||
const paginationProps = {
|
||||
current: page, //当前页码
|
||||
pageSize: size,
|
||||
total: total, // 总条数
|
||||
onChange: (page: number, pageSize: number) =>
|
||||
handlePageChange(page, pageSize), //改变页码的函数
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
setPage(page);
|
||||
setSize(pageSize);
|
||||
};
|
||||
|
||||
const getTotalHours = (params: any) => {
|
||||
if (params) {
|
||||
let value = 0;
|
||||
for (let key in params) {
|
||||
value += params[key].hour_count;
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getFinishedHours = (params: any) => {
|
||||
if (params) {
|
||||
let value = 0;
|
||||
for (let key in params) {
|
||||
value += params[key].finished_count;
|
||||
}
|
||||
return value;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="playedu-main-body">
|
||||
<div className="float-left mb-24">
|
||||
<BackBartment title={title + "学习进度"} />
|
||||
</div>
|
||||
<div className="float-left j-b-flex mb-24">
|
||||
<div className="d-flex helper-text ">
|
||||
(以下表格内数字对应的是表头课程的“已学完课时数/总课时数”)
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mr-24 ">
|
||||
<Typography.Text>姓名:</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
placeholder="请输入姓名关键字"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex mr-24">
|
||||
<Typography.Text>邮箱:</Typography.Text>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="d-flex mr-24">
|
||||
<Typography.Text>身份证号:</Typography.Text>
|
||||
<Input
|
||||
value={id_card}
|
||||
onChange={(e) => {
|
||||
setIdCard(e.target.value);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
placeholder="请输入身份证号"
|
||||
/>
|
||||
</div> */}
|
||||
<div className="d-flex">
|
||||
<Button className="mr-16" onClick={resetData}>
|
||||
重 置
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setRefresh(!refresh);
|
||||
}}
|
||||
>
|
||||
查 询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="float-left">
|
||||
<Table
|
||||
bordered
|
||||
dataSource={list}
|
||||
loading={loading}
|
||||
pagination={paginationProps}
|
||||
rowKey={(record) => record.id}
|
||||
scroll={{ x: 1200 }}
|
||||
>
|
||||
<Column
|
||||
fixed="left"
|
||||
title="学员"
|
||||
dataIndex="name"
|
||||
key="name"
|
||||
width={100}
|
||||
render={(_, record: any) => (
|
||||
<>
|
||||
<Image
|
||||
style={{ borderRadius: "50%" }}
|
||||
src={record.avatar}
|
||||
preview={false}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<span className="ml-8">{record.name}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{courses.map((item: any) => (
|
||||
<Column
|
||||
title={item.title}
|
||||
ellipsis={true}
|
||||
dataIndex="id"
|
||||
key={item.id}
|
||||
width={100}
|
||||
render={(_, record: any) => (
|
||||
<>
|
||||
{records[record.id] && records[record.id][item.id] ? (
|
||||
records[record.id][item.id].is_finished === 1 ? (
|
||||
<span>已完成</span>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
{records[record.id][item.id].finished_count}
|
||||
</span>{" "}
|
||||
/ <span>{item.class_hour}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span>0</span> / <span>{item.class_hour}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<Column
|
||||
fixed="right"
|
||||
title="所有课程总课时"
|
||||
dataIndex="id"
|
||||
key="id"
|
||||
width={100}
|
||||
render={(_, record: any) => (
|
||||
<>
|
||||
<span>{getFinishedHours(records[record.id])}</span> /{" "}
|
||||
<span>{totalHour}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MemberDepartmentProgressPage;
|
@ -8,13 +8,19 @@ import {
|
||||
Table,
|
||||
message,
|
||||
Image,
|
||||
Dropdown,
|
||||
} from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
// import styles from "./index.module.less";
|
||||
import { PlusOutlined, ExclamationCircleFilled } from "@ant-design/icons";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DownOutlined,
|
||||
ExclamationCircleFilled,
|
||||
} from "@ant-design/icons";
|
||||
import { user } from "../../api/index";
|
||||
import { dateFormat } from "../../utils/index";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, Navigate } from "react-router-dom";
|
||||
import { TreeDepartment, PerButton } from "../../compenents";
|
||||
import { MemberCreate } from "./compenents/create";
|
||||
import { MemberUpdate } from "./compenents/update";
|
||||
@ -50,7 +56,7 @@ const MemberPage = () => {
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: "学员姓名",
|
||||
title: "学员",
|
||||
dataIndex: "name",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
@ -97,32 +103,73 @@ const MemberPage = () => {
|
||||
key: "action",
|
||||
fixed: "right",
|
||||
width: 160,
|
||||
render: (_, record: any) => (
|
||||
<Space size="small">
|
||||
<PerButton
|
||||
type="link"
|
||||
text="编辑"
|
||||
class="b-link c-red"
|
||||
icon={null}
|
||||
p="user-update"
|
||||
onClick={() => {
|
||||
setMid(Number(record.id));
|
||||
setUpdateVisible(true);
|
||||
}}
|
||||
disabled={null}
|
||||
/>
|
||||
<div className="form-column"></div>
|
||||
<PerButton
|
||||
type="link"
|
||||
text="删除"
|
||||
class="b-link c-red"
|
||||
icon={null}
|
||||
p="user-destroy"
|
||||
onClick={() => delUser(record.id)}
|
||||
disabled={null}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
render: (_, record: any) => {
|
||||
const items: MenuProps["items"] = [
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<PerButton
|
||||
type="link"
|
||||
text="编辑"
|
||||
class="b-link c-red"
|
||||
icon={null}
|
||||
p="user-update"
|
||||
onClick={() => {
|
||||
setMid(Number(record.id));
|
||||
setUpdateVisible(true);
|
||||
}}
|
||||
disabled={null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<PerButton
|
||||
type="link"
|
||||
text="删除"
|
||||
class="b-link c-red"
|
||||
icon={null}
|
||||
p="user-destroy"
|
||||
onClick={() => delUser(record.id)}
|
||||
disabled={null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
<Link
|
||||
style={{ textDecoration: "none" }}
|
||||
to={`/member/learn?id=${record.id}`}
|
||||
>
|
||||
<PerButton
|
||||
type="link"
|
||||
text="学习"
|
||||
class="b-link c-red"
|
||||
icon={null}
|
||||
p="user-learn"
|
||||
onClick={() => null}
|
||||
disabled={null}
|
||||
/>
|
||||
</Link>
|
||||
<div className="form-column"></div>
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button
|
||||
type="link"
|
||||
className="b-link c-red"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Space size="small" align="center">
|
||||
更多
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -200,17 +247,25 @@ const MemberPage = () => {
|
||||
<div className="tree-main-body">
|
||||
<div className="left-box">
|
||||
<TreeDepartment
|
||||
refresh={refresh}
|
||||
showNum={true}
|
||||
type=""
|
||||
text={"部门"}
|
||||
onUpdate={(keys: any, title: any) => {
|
||||
setDepIds(keys);
|
||||
setLabel(title);
|
||||
var index = title.indexOf("(");
|
||||
if (index !== -1) {
|
||||
var resolve = title.substring(0, index);
|
||||
setLabel(resolve);
|
||||
} else {
|
||||
setLabel(title);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="right-box">
|
||||
<div className="playedu-main-title float-left mb-24">
|
||||
学员/{selLabel}
|
||||
学员 | {selLabel}
|
||||
</div>
|
||||
<div className="float-left j-b-flex mb-24">
|
||||
<div className="d-flex">
|
||||
@ -223,17 +278,37 @@ const MemberPage = () => {
|
||||
onClick={() => setCreateVisible(true)}
|
||||
disabled={null}
|
||||
/>
|
||||
<Link style={{ textDecoration: "none" }} to={`/member/import`}>
|
||||
<PerButton
|
||||
type="default"
|
||||
text="批量导入学员"
|
||||
class="mr-16"
|
||||
icon={null}
|
||||
p="user-store"
|
||||
onClick={() => null}
|
||||
disabled={null}
|
||||
/>
|
||||
</Link>
|
||||
{dep_ids.length === 0 && (
|
||||
<Link style={{ textDecoration: "none" }} to={`/member/import`}>
|
||||
<PerButton
|
||||
type="default"
|
||||
text="批量导入学员"
|
||||
class="mr-16"
|
||||
icon={null}
|
||||
p="user-store"
|
||||
onClick={() => null}
|
||||
disabled={null}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
{dep_ids.length > 0 && (
|
||||
<Link
|
||||
style={{ textDecoration: "none" }}
|
||||
to={`/member/departmentUser?id=${dep_ids.join(
|
||||
","
|
||||
)}&title=${selLabel}`}
|
||||
>
|
||||
<PerButton
|
||||
type="default"
|
||||
text="部门学员进度"
|
||||
class="mr-16"
|
||||
icon={null}
|
||||
p="department-user-learn"
|
||||
onClick={() => null}
|
||||
disabled={null}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mr-24">
|
||||
|
14
src/pages/member/learn.module.less
Normal file
14
src/pages/member/learn.module.less
Normal file
@ -0,0 +1,14 @@
|
||||
.large-title {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.charts {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
box-sizing: border-box;
|
||||
}
|
348
src/pages/member/learn.tsx
Normal file
348
src/pages/member/learn.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styles from "./learn.module.less";
|
||||
import { Row, Image, Table } from "antd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { BackBartment, DurationText } from "../../compenents";
|
||||
import { dateFormat } from "../../utils/index";
|
||||
import { user as member } from "../../api/index";
|
||||
import * as echarts from "echarts";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { duration } from "moment";
|
||||
|
||||
interface DataType {
|
||||
id: React.Key;
|
||||
title: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
total_duration: number;
|
||||
finished_duration: number;
|
||||
is_finished: boolean;
|
||||
}
|
||||
|
||||
const MemberLearnPage = () => {
|
||||
let chartRef = useRef(null);
|
||||
const result = new URLSearchParams(useLocation().search);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [size, setSize] = useState(10);
|
||||
const [list, setList] = useState<any>([]);
|
||||
const [hours, setHours] = useState<any>({});
|
||||
const [total, setTotal] = useState(0);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [loading2, setLoading2] = useState<boolean>(false);
|
||||
const [page2, setPage2] = useState(1);
|
||||
const [size2, setSize2] = useState(10);
|
||||
const [list2, setList2] = useState<any>([]);
|
||||
const [courses, setCourses] = useState<any>({});
|
||||
const [total2, setTotal2] = useState(0);
|
||||
const [refresh2, setRefresh2] = useState(false);
|
||||
const [uid, setUid] = useState(Number(result.get("id")));
|
||||
|
||||
useEffect(() => {
|
||||
getZxtData();
|
||||
return () => {
|
||||
window.onresize = null;
|
||||
};
|
||||
}, [uid]);
|
||||
|
||||
useEffect(() => {
|
||||
getLearnHours();
|
||||
}, [refresh, page, size]);
|
||||
|
||||
useEffect(() => {
|
||||
getLearnCourses();
|
||||
}, [refresh2, page2, size2]);
|
||||
|
||||
const getZxtData = () => {
|
||||
member.learnStats(uid).then((res: any) => {
|
||||
renderView(res.data);
|
||||
});
|
||||
};
|
||||
|
||||
const minuteFormat = (duration: number) => {
|
||||
if (duration === 0) {
|
||||
return "0小时0分0秒";
|
||||
}
|
||||
let h = Math.trunc(duration / 3600);
|
||||
let m = Math.trunc((duration % 3600) / 60);
|
||||
let s = Math.trunc((duration % 3600) % 60);
|
||||
return h + "小时" + m + "分" + s + "秒";
|
||||
};
|
||||
|
||||
const renderView = (params: any) => {
|
||||
const timeData: any = [];
|
||||
const valueData: any = [];
|
||||
params.map((item: any) => {
|
||||
timeData.push(item.key);
|
||||
valueData.push(item.value / 1000);
|
||||
});
|
||||
let dom: any = chartRef.current;
|
||||
let myChart = echarts.init(dom);
|
||||
myChart.setOption({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: function (params: any) {
|
||||
// 只粘贴formatter了
|
||||
let relVal = params[0].axisValueLabel;
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
relVal +=
|
||||
"<br/>" +
|
||||
params[i].marker +
|
||||
params[i].seriesName +
|
||||
": " +
|
||||
minuteFormat(params[i].value);
|
||||
}
|
||||
return relVal;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ["每日学习时长"],
|
||||
x: "right",
|
||||
},
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "4%",
|
||||
bottom: "3%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
data: timeData,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "每日学习时长",
|
||||
type: "line",
|
||||
data: valueData,
|
||||
color: "#ff4d4f",
|
||||
},
|
||||
],
|
||||
});
|
||||
window.onresize = () => {
|
||||
myChart.resize();
|
||||
};
|
||||
};
|
||||
|
||||
const getLearnHours = () => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
member
|
||||
.learnHours(uid, page, size, {
|
||||
sort_field: "",
|
||||
sort_algo: "",
|
||||
is_finished: "",
|
||||
})
|
||||
.then((res: any) => {
|
||||
setList(res.data.data);
|
||||
setHours(res.data.hours);
|
||||
setTotal(res.data.total);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getLearnCourses = () => {
|
||||
if (loading2) {
|
||||
return;
|
||||
}
|
||||
setLoading2(true);
|
||||
member
|
||||
.learnCourses(uid, page2, size2, {
|
||||
sort_field: "",
|
||||
sort_algo: "",
|
||||
is_finished: "",
|
||||
})
|
||||
.then((res: any) => {
|
||||
setList2(res.data.data);
|
||||
setCourses(res.data.courses);
|
||||
setTotal2(res.data.total);
|
||||
setLoading2(false);
|
||||
});
|
||||
};
|
||||
|
||||
const paginationProps = {
|
||||
current: page, //当前页码
|
||||
pageSize: size,
|
||||
total: total, // 总条数
|
||||
onChange: (page: number, pageSize: number) =>
|
||||
handlePageChange(page, pageSize), //改变页码的函数
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
setPage(page);
|
||||
setSize(pageSize);
|
||||
};
|
||||
|
||||
const paginationProps2 = {
|
||||
current: page2, //当前页码
|
||||
pageSize: size2,
|
||||
total: total2, // 总条数
|
||||
onChange: (page: number, pageSize: number) =>
|
||||
handlePageChange2(page, pageSize), //改变页码的函数
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
const handlePageChange2 = (page: number, pageSize: number) => {
|
||||
setPage2(page);
|
||||
setSize2(pageSize);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: "课时标题",
|
||||
dataIndex: "title",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<span>{hours[record.hour_id].title}</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "课时类型",
|
||||
dataIndex: "type",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<span>{hours[record.hour_id].type}</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "总时长",
|
||||
dataIndex: "total_duration",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<DurationText duration={record.total_duration}></DurationText>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "已学习时长",
|
||||
dataIndex: "finished_duration",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<DurationText duration={record.finished_duration || 0}></DurationText>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_finished",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
{record.is_finished === 1 ? <span>已学完</span> : <span>未学完</span>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "created_at",
|
||||
render: (text: string) => <span>{dateFormat(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const column2: ColumnsType<DataType> = [
|
||||
{
|
||||
title: "课程名称",
|
||||
dataIndex: "title",
|
||||
render: (_, record: any) => (
|
||||
<div className="d-flex">
|
||||
<Image
|
||||
src={courses[record.course_id].thumb}
|
||||
preview={false}
|
||||
width={80}
|
||||
height={60}
|
||||
style={{ borderRadius: 6 }}
|
||||
/>
|
||||
<span className="ml-8">{courses[record.course_id].title}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "课程进度",
|
||||
dataIndex: "total_duration",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<span>
|
||||
已完成课时:{record.finished_count} / {record.hour_count}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "第一次学习时间",
|
||||
dataIndex: "created_at",
|
||||
render: (text: string) => <span>{dateFormat(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: "学习完成时间",
|
||||
dataIndex: "finished_at",
|
||||
render: (text: string) => <span>{dateFormat(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: "学习进度",
|
||||
dataIndex: "is_finished",
|
||||
render: (_, record: any) => (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
Math.floor((record.finished_count / record.hour_count) * 100) >=
|
||||
100
|
||||
? "c-green"
|
||||
: "c-red"
|
||||
}
|
||||
>
|
||||
{Math.floor((record.finished_count / record.hour_count) * 100)}%
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="playedu-main-top mb-24">
|
||||
<div className="float-left mb-24">
|
||||
<BackBartment title="学员学习" />
|
||||
</div>
|
||||
<div className={styles["charts"]}>
|
||||
<div
|
||||
ref={chartRef}
|
||||
style={{
|
||||
width: "100% !important",
|
||||
height: 300,
|
||||
position: "relative",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="float-left mt-24">
|
||||
<Table
|
||||
columns={column2}
|
||||
dataSource={list2}
|
||||
loading={loading2}
|
||||
pagination={paginationProps2}
|
||||
rowKey={(record) => record.id}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
{/* <div className="playedu-main-top mb-24">
|
||||
<div className={styles["large-title"]}>课时学习记录</div>
|
||||
<div className="float-left mt-24">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
loading={loading}
|
||||
pagination={paginationProps}
|
||||
rowKey={(record) => record.id}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default MemberLearnPage;
|
@ -164,7 +164,7 @@ const ResourceImagesPage = () => {
|
||||
</div>
|
||||
<div className="right-box">
|
||||
<div className="d-flex playedu-main-title float-left mb-24">
|
||||
图片 / {selLabel}
|
||||
图片 | {selLabel}
|
||||
</div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={24}>
|
||||
|
@ -184,7 +184,7 @@ const ResourceVideosPage = () => {
|
||||
</div>
|
||||
<div className="right-box">
|
||||
<div className="d-flex playedu-main-title float-left mb-24">
|
||||
视频 / {selLabel}
|
||||
视频 | {selLabel}
|
||||
</div>
|
||||
<div className="float-left mb-24">
|
||||
<UploadVideoButton
|
||||
|
@ -23,6 +23,7 @@ const SystemConfigPage = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [logo, setLogo] = useState<string>("");
|
||||
const [thumb, setThumb] = useState<string>("");
|
||||
const [avatar, setAvatar] = useState<string>("");
|
||||
const [tabKey, setTabKey] = useState(1);
|
||||
const [nameChecked, setNameChecked] = useState(false);
|
||||
const [emailChecked, setEmailChecked] = useState(false);
|
||||
@ -103,6 +104,11 @@ const SystemConfigPage = () => {
|
||||
form.setFieldsValue({
|
||||
"system.pc_index_footer_msg": configData[i].key_value,
|
||||
});
|
||||
} else if (configData[i].key_name === "member.default_avatar") {
|
||||
setAvatar(configData[i].key_value);
|
||||
form.setFieldsValue({
|
||||
"member.default_avatar": configData[i].key_value,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -192,7 +198,7 @@ const SystemConfigPage = () => {
|
||||
style={{ marginBottom: 30 }}
|
||||
label="网站Logo"
|
||||
name="system.logo"
|
||||
labelCol={{ style: { marginTop: 8, marginLeft: 54 } }}
|
||||
labelCol={{ style: { marginTop: 4, marginLeft: 54 } }}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<Image preview={false} height={40} src={logo} />
|
||||
@ -411,6 +417,79 @@ const SystemConfigPage = () => {
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
label: `学员设置`,
|
||||
children: (
|
||||
<Form
|
||||
form={form}
|
||||
name="m-basic"
|
||||
labelCol={{ span: 3 }}
|
||||
wrapperCol={{ span: 21 }}
|
||||
style={{ width: 1000, paddingTop: 30 }}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={onFinishFailed}
|
||||
autoComplete="off"
|
||||
>
|
||||
{avatar && (
|
||||
<Form.Item
|
||||
style={{ marginBottom: 30 }}
|
||||
label="学员默认头像"
|
||||
name="member.default_avatar"
|
||||
labelCol={{ style: { marginTop: 14, marginLeft: 28 } }}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<Image
|
||||
preview={false}
|
||||
width={60}
|
||||
height={60}
|
||||
src={avatar}
|
||||
style={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<div className="d-flex ml-24">
|
||||
<UploadImageButton
|
||||
text="更换头像"
|
||||
onSelected={(url) => {
|
||||
setAvatar(url);
|
||||
form.setFieldsValue({ "member.default_avatar": url });
|
||||
}}
|
||||
></UploadImageButton>
|
||||
<div className="helper-text ml-24">(新学员的默认头像)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!avatar && (
|
||||
<Form.Item
|
||||
style={{ marginBottom: 30 }}
|
||||
label="学员默认头像"
|
||||
name="member.default_avatar"
|
||||
>
|
||||
<div className="d-flex">
|
||||
<div className="d-flex">
|
||||
<UploadImageButton
|
||||
text="更换头像"
|
||||
onSelected={(url) => {
|
||||
setAvatar(url);
|
||||
form.setFieldsValue({ "member.default_avatar": url });
|
||||
}}
|
||||
></UploadImageButton>
|
||||
<div className="helper-text ml-24">(新学员的默认头像)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
style={{ marginBottom: 30 }}
|
||||
wrapperCol={{ offset: 3, span: 21 }}
|
||||
>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onChange = (key: string) => {
|
||||
|
@ -16,6 +16,8 @@ import CoursePage from "../pages/course/index";
|
||||
import CourseUserPage from "../pages/course/user";
|
||||
import MemberPage from "../pages/member";
|
||||
import MemberImportPage from "../pages/member/import";
|
||||
import MemberLearnPage from "../pages/member/learn";
|
||||
import MemberDepartmentProgressPage from "../pages/member/departmentUser";
|
||||
import SystemConfigPage from "../pages/system/config";
|
||||
import SystemAdministratorPage from "../pages/system/administrator";
|
||||
import SystemAdminrolesPage from "../pages/system/adminroles";
|
||||
@ -98,6 +100,14 @@ const routes: RouteObject[] = [
|
||||
path: "/member/import",
|
||||
element: <MemberImportPage />,
|
||||
},
|
||||
{
|
||||
path: "/member/learn",
|
||||
element: <MemberLearnPage />,
|
||||
},
|
||||
{
|
||||
path: "/member/departmentUser",
|
||||
element: <MemberDepartmentProgressPage />,
|
||||
},
|
||||
{
|
||||
path: "/system/config/index",
|
||||
element: <SystemConfigPage />,
|
||||
|
@ -13,10 +13,16 @@ export function clearToken() {
|
||||
}
|
||||
|
||||
export function dateFormat(dateStr: string) {
|
||||
if (!dateStr) {
|
||||
return "-";
|
||||
}
|
||||
return moment(dateStr).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
export function timeFormat(dateStr: number) {
|
||||
if (!dateStr) {
|
||||
return "-";
|
||||
}
|
||||
var d = moment.duration(dateStr, "seconds");
|
||||
let value =
|
||||
Math.floor(d.asDays()) +
|
||||
|
Loading…
x
Reference in New Issue
Block a user