diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..57d3105 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +/node_modules +/build +/dist \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6336b7b..c7ea6b3 100644 --- a/Dockerfile +++ b/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 \ No newline at end of file diff --git a/src/AutoTop.ts b/src/AutoTop.ts new file mode 100644 index 0000000..fc8efa6 --- /dev/null +++ b/src/AutoTop.ts @@ -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; diff --git a/src/api/user.ts b/src/api/user.ts index 169dd87..c1165e1 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -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, + }); +} diff --git a/src/compenents/header/index.tsx b/src/compenents/header/index.tsx index 8953bd2..9295311 100644 --- a/src/compenents/header/index.tsx +++ b/src/compenents/header/index.tsx @@ -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") { diff --git a/src/compenents/tree-department/index.tsx b/src/compenents/tree-department/index.tsx index 573105a..3da48c5 100644 --- a/src/compenents/tree-department/index.tsx +++ b/src/compenents/tree-department/index.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [selectKey, setSelectKey] = useState([]); + 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 + ")" : ""} {treeData.length > 0 && ( @@ -15,7 +16,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( theme={{ token: { colorPrimary: "#ff4d4f" } }} > - + + + diff --git a/src/pages/course/index.tsx b/src/pages/course/index.tsx index ff12382..ad94cbb 100644 --- a/src/pages/course/index.tsx +++ b/src/pages/course/index.tsx @@ -89,6 +89,8 @@ const CoursePage = () => { children: (
{ @@ -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 = () => {
- 课程/{selLabel} + 线上课 | {selLabel}
diff --git a/src/pages/course/user.tsx b/src/pages/course/user.tsx index 68b46db..fb74011 100644 --- a/src/pages/course/user.tsx +++ b/src/pages/course/user.tsx @@ -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([]); const [users, setUsers] = useState([]); const [refresh, setRefresh] = useState(false); @@ -42,10 +43,11 @@ const CourseUserPage = () => { const [email, setEmail] = useState(""); const [idCard, setIdCard] = useState(""); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [title, setTitle] = useState(String(result.get("title"))); const columns: ColumnsType = [ { - title: "学员名称", + title: "学员", render: (_, record: any) => (
{ ), }, - { - title: "学习进度", - dataIndex: "progress", - render: (progress: number) => {progress / 100}%, - }, { title: "第一次学习时间", dataIndex: "created_at", @@ -85,6 +82,15 @@ const CourseUserPage = () => { dataIndex: "finished_at", render: (text: string) => {dateFormat(text)}, }, + { + title: "学习进度", + dataIndex: "progress", + render: (progress: number) => ( + = 10000 ? "c-green" : "c-red"}> + {progress / 100}% + + ), + }, ]; useEffect(() => { @@ -178,7 +184,7 @@ const CourseUserPage = () => {
- +
@@ -215,7 +221,7 @@ const CourseUserPage = () => { placeholder="请输入学员邮箱" />
-
+ {/*
身份证号: { style={{ width: 160 }} placeholder="请输入身份证号" /> -
+
*/}
+ +
+
+
+
+ record.id} + scroll={{ x: 1200 }} + > + ( + <> + + {record.name} + + )} + /> + {courses.map((item: any) => ( + ( + <> + {records[record.id] && records[record.id][item.id] ? ( + records[record.id][item.id].is_finished === 1 ? ( + 已完成 + ) : ( + <> + + {records[record.id][item.id].finished_count} + {" "} + / {item.class_hour} + + ) + ) : ( + <> + 0 / {item.class_hour} + + )} + + )} + /> + ))} + ( + <> + {getFinishedHours(records[record.id])} /{" "} + {totalHour} + + )} + /> +
+
+
+ ); +}; +export default MemberDepartmentProgressPage; diff --git a/src/pages/member/index.tsx b/src/pages/member/index.tsx index 34b5641..e08267c 100644 --- a/src/pages/member/index.tsx +++ b/src/pages/member/index.tsx @@ -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 = [ { - title: "学员姓名", + title: "学员", dataIndex: "name", render: (_, record: any) => ( <> @@ -97,32 +103,73 @@ const MemberPage = () => { key: "action", fixed: "right", width: 160, - render: (_, record: any) => ( - - { - setMid(Number(record.id)); - setUpdateVisible(true); - }} - disabled={null} - /> -
- delUser(record.id)} - disabled={null} - /> -
- ), + render: (_, record: any) => { + const items: MenuProps["items"] = [ + { + key: "1", + label: ( + { + setMid(Number(record.id)); + setUpdateVisible(true); + }} + disabled={null} + /> + ), + }, + { + key: "2", + label: ( + delUser(record.id)} + disabled={null} + /> + ), + }, + ]; + + return ( + + + null} + disabled={null} + /> + +
+ + + +
+ ); + }, }, ]; @@ -200,17 +247,25 @@ const MemberPage = () => {
{ setDepIds(keys); - setLabel(title); + var index = title.indexOf("("); + if (index !== -1) { + var resolve = title.substring(0, index); + setLabel(resolve); + } else { + setLabel(title); + } }} />
- 学员/{selLabel} + 学员 | {selLabel}
@@ -223,17 +278,37 @@ const MemberPage = () => { onClick={() => setCreateVisible(true)} disabled={null} /> - - null} - disabled={null} - /> - + {dep_ids.length === 0 && ( + + null} + disabled={null} + /> + + )} + {dep_ids.length > 0 && ( + + null} + disabled={null} + /> + + )}
diff --git a/src/pages/member/learn.module.less b/src/pages/member/learn.module.less new file mode 100644 index 0000000..c1354f0 --- /dev/null +++ b/src/pages/member/learn.module.less @@ -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; +} diff --git a/src/pages/member/learn.tsx b/src/pages/member/learn.tsx new file mode 100644 index 0000000..ec3d42a --- /dev/null +++ b/src/pages/member/learn.tsx @@ -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(false); + const [page, setPage] = useState(1); + const [size, setSize] = useState(10); + const [list, setList] = useState([]); + const [hours, setHours] = useState({}); + const [total, setTotal] = useState(0); + const [refresh, setRefresh] = useState(false); + const [loading2, setLoading2] = useState(false); + const [page2, setPage2] = useState(1); + const [size2, setSize2] = useState(10); + const [list2, setList2] = useState([]); + const [courses, setCourses] = useState({}); + 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 += + "
" + + 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 = [ + { + title: "课时标题", + dataIndex: "title", + render: (_, record: any) => ( + <> + {hours[record.hour_id].title} + + ), + }, + { + title: "课时类型", + dataIndex: "type", + render: (_, record: any) => ( + <> + {hours[record.hour_id].type} + + ), + }, + { + title: "总时长", + dataIndex: "total_duration", + render: (_, record: any) => ( + <> + + + ), + }, + { + title: "已学习时长", + dataIndex: "finished_duration", + render: (_, record: any) => ( + <> + + + ), + }, + { + title: "状态", + dataIndex: "is_finished", + render: (_, record: any) => ( + <> + {record.is_finished === 1 ? 已学完 : 未学完} + + ), + }, + { + title: "时间", + dataIndex: "created_at", + render: (text: string) => {dateFormat(text)}, + }, + ]; + + const column2: ColumnsType = [ + { + title: "课程名称", + dataIndex: "title", + render: (_, record: any) => ( +
+ + {courses[record.course_id].title} +
+ ), + }, + { + title: "课程进度", + dataIndex: "total_duration", + render: (_, record: any) => ( + <> + + 已完成课时:{record.finished_count} / {record.hour_count} + + + ), + }, + { + title: "第一次学习时间", + dataIndex: "created_at", + render: (text: string) => {dateFormat(text)}, + }, + { + title: "学习完成时间", + dataIndex: "finished_at", + render: (text: string) => {dateFormat(text)}, + }, + { + title: "学习进度", + dataIndex: "is_finished", + render: (_, record: any) => ( + <> + = + 100 + ? "c-green" + : "c-red" + } + > + {Math.floor((record.finished_count / record.hour_count) * 100)}% + + + ), + }, + ]; + + return ( + <> + +
+ +
+
+
+
+
+ record.id} + /> + + + {/*
+
课时学习记录
+
+
record.id} + /> + + */} + + ); +}; +export default MemberLearnPage; diff --git a/src/pages/resource/images/index.tsx b/src/pages/resource/images/index.tsx index a17c7ca..4ca1430 100644 --- a/src/pages/resource/images/index.tsx +++ b/src/pages/resource/images/index.tsx @@ -164,7 +164,7 @@ const ResourceImagesPage = () => {
- 图片 / {selLabel} + 图片 | {selLabel}
diff --git a/src/pages/resource/videos/index.tsx b/src/pages/resource/videos/index.tsx index a033d92..6d5a363 100644 --- a/src/pages/resource/videos/index.tsx +++ b/src/pages/resource/videos/index.tsx @@ -184,7 +184,7 @@ const ResourceVideosPage = () => {
- 视频 / {selLabel} + 视频 | {selLabel}
{ const [loading, setLoading] = useState(false); const [logo, setLogo] = useState(""); const [thumb, setThumb] = useState(""); + const [avatar, setAvatar] = useState(""); 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 } }} >
@@ -411,6 +417,79 @@ const SystemConfigPage = () => { ), }, + { + key: "3", + label: `学员设置`, + children: ( +
+ {avatar && ( + +
+ +
+ { + setAvatar(url); + form.setFieldsValue({ "member.default_avatar": url }); + }} + > +
(新学员的默认头像)
+
+
+
+ )} + {!avatar && ( + +
+
+ { + setAvatar(url); + form.setFieldsValue({ "member.default_avatar": url }); + }} + > +
(新学员的默认头像)
+
+
+
+ )} + + + + + ), + }, ]; const onChange = (key: string) => { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 42e9f92..2f1ee5d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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"; @@ -32,7 +34,7 @@ if (getToken()) { try { let configRes: any = await system.getSystemConfig(); let userRes: any = await login.getUser(); - + resolve({ default: ( @@ -98,6 +100,14 @@ const routes: RouteObject[] = [ path: "/member/import", element: , }, + { + path: "/member/learn", + element: , + }, + { + path: "/member/departmentUser", + element: , + }, { path: "/system/config/index", element: , diff --git a/src/utils/index.ts b/src/utils/index.ts index 42aaba3..816b890 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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()) + @@ -117,4 +123,4 @@ export function ValidataCredentials(value: any) { return true; } } -} \ No newline at end of file +}