diff --git "a/public/assets/misc/onlinejudge\347\224\250\346\210\267\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" "b/public/assets/misc/onlinejudge\347\224\250\346\210\267\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" index 792017a..0ba91be 100644 Binary files "a/public/assets/misc/onlinejudge\347\224\250\346\210\267\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" and "b/public/assets/misc/onlinejudge\347\224\250\346\210\267\345\257\274\345\205\245\346\250\241\346\235\277.xlsx" differ diff --git a/src/@types/models.d.ts b/src/@types/models.d.ts index e40b20b..24c0b6b 100755 --- a/src/@types/models.d.ts +++ b/src/@types/models.d.ts @@ -5,6 +5,7 @@ interface ISession { avatar: string; permission: number; permissions: string[]; + type: number; } interface ISessionStatus { @@ -40,6 +41,8 @@ interface IUser { site?: string; settings?: any; verified?: boolean; + type?: number; + status?: number; coin?: number; solutionCalendar?: ISolutionCalendar; defaultLanguage?: string; @@ -94,6 +97,46 @@ interface IRatingHistoryItem { type IRatingHistory = IRatingHistoryItem[]; +interface IUserMember { + userId: number; + username: string; + nickname: string; + avatar: string | null; + bannerImage: string; + accepted: number; + submitted: number; + rating: number; + verified: boolean; + status: number; + createdAt: string; + updatedAt: string; +} + +interface IUserSelfJoinedTeam { + teamUserId: number; + selfMemberStatus: number; + selfJoinedAt: string; + username: string; + nickname: string; + avatar: string | null; + bannerImage: string; + status: number; + members: { + userId: number; + username: string; + nickname: string; + avatar: string | null; + bannerImage: string; + accepted: number; + submitted: number; + rating: number; + verified: boolean; + status: number; + createdAt: string; + updatedAt: string; + }[]; +} + interface IProblem { problemId: number; title: string; @@ -116,7 +159,7 @@ interface IProblem { display: boolean; spj: boolean; spConfig: any; - alias?: string; /** only in competition */ + alias?: string /** only in competition */; createdAt: ITimestamp; updatedAt: ITimestamp; } diff --git a/src/common b/src/common index 3a1ec92..f650bbc 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 3a1ec92573105a77c2940eb46969c5ad71a34446 +Subproject commit f650bbc5dc12d96930a08f76540d5626be03bd7f diff --git a/src/components/AddTeamMemberModal.tsx b/src/components/AddTeamMemberModal.tsx new file mode 100644 index 0000000..f62ce50 --- /dev/null +++ b/src/components/AddTeamMemberModal.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { connect } from 'dva'; +import { Form, Modal } from 'antd'; +import { FormProps, ReduxProps } from '@/@types/props'; +import UserSelect from './UserSelect'; + +export interface Props extends ReduxProps, FormProps { + invitationCode: string; + confirmLoading: boolean; + onAddMember: (userId: number) => Promise; +} + +interface State { + visible: boolean; +} + +class AddTeamMemberModal extends React.Component { + constructor(props) { + super(props); + this.state = { + visible: false, + }; + } + + handleOk = () => { + const { form } = this.props; + this.props.form.validateFields((err, values) => { + if (!err) { + const users = values.users; + const userIds = Array.isArray(users) ? users.map((v) => +v.key) : [+users.key]; + this.props.onAddMember(userIds[0]).then((success) => { + if (success) { + form.resetFields(); + this.handleHideModel(); + } + }); + } + }); + }; + + handleShowModel = (e) => { + if (e) { + e.stopPropagation(); + } + this.setState({ visible: true }); + }; + + handleHideModel = () => { + this.setState({ visible: false }); + }; + + render() { + const { children, confirmLoading, invitationCode, form } = this.props; + const { getFieldDecorator } = form; + + return ( + <> + {children} + +
+ + {getFieldDecorator('users', { + rules: [{ required: true, message: 'Please select a user' }], + })( + `${u.nickname} (UID: ${u.userId})`} + />, + )} + +
+

+ After being invited, the user still need to confirm through your code: +

+

+ {invitationCode} +

+
+ + ); + } +} + +function mapStateToProps(state) { + return {}; +} + +export default connect(mapStateToProps)(Form.create()(AddTeamMemberModal)); diff --git a/src/components/ImportUserModal.tsx b/src/components/ImportUserModal.tsx index ec30c2b..c75ad24 100644 --- a/src/components/ImportUserModal.tsx +++ b/src/components/ImportUserModal.tsx @@ -8,6 +8,7 @@ import ExcelSelectParser from './ExcelSelectParser'; import staticUrls from '@/configs/staticUrls'; import constants from '@/configs/constants'; import { withRouter } from 'react-router'; +import { EUserType } from '@/common/enums'; export interface Props extends RouteProps, ReduxProps, FormProps {} @@ -20,6 +21,7 @@ interface IImportUser { class: IUser['class']; grade: string; password: string; + type: IUser['type']; } interface State { @@ -49,6 +51,7 @@ class ImportUserModal extends React.Component { class: `${row[5] || ''}`, grade: `${row[6] || ''}`, password: `${row[7] || ''}`, + type: row[8] === 'Y' ? EUserType.team : EUserType.personal, })); for (const user of users) { if (!user.username || !user.nickname || !user.password) { @@ -198,6 +201,11 @@ class ImportUserModal extends React.Component { key="password" render={(text, record: IImportUser) => {record.password}} /> + {record.type === EUserType.personal ? '个人' : '团队'}} + /> diff --git a/src/components/Rating.tsx b/src/components/Rating.tsx index 83b296d..27323b3 100644 --- a/src/components/Rating.tsx +++ b/src/components/Rating.tsx @@ -176,7 +176,7 @@ class Rating extends React.Component { if (!rating) { return ( -

No Rating

+

No Rating

); } diff --git a/src/components/SelfTeamsModal.tsx b/src/components/SelfTeamsModal.tsx new file mode 100644 index 0000000..69004f5 --- /dev/null +++ b/src/components/SelfTeamsModal.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { connect } from 'dva'; +import { Form, Input, Modal, Table } from 'antd'; +import { FormProps, ReduxProps } from '@/@types/props'; +import msg from '@/utils/msg'; +import tracker from '@/utils/tracker'; +import { codeMsgs, Codes } from '@/common/codes'; +import TimeBar from './TimeBar'; +import UserBar from './UserBar'; + +export interface Props extends ReduxProps, FormProps { + userId: number; + teams: IUserSelfJoinedTeam[]; + confirmJoinLoading: boolean; +} + +interface State { + visible: boolean; + invitationCode: string; +} + +class SelfTeamsModal extends React.Component { + constructor(props) { + super(props); + this.state = { + visible: false, + invitationCode: '', + }; + } + + handleConfirmJoinTeam = () => { + const { dispatch, confirmJoinLoading } = this.props; + if (confirmJoinLoading) { + return; + } + const { invitationCode } = this.state; + if (!invitationCode) { + msg.error('Please input invitation code'); + return; + } + if (isNaN(+invitationCode)) { + msg.error(codeMsgs[Codes.USER_NOT_INVITED_TO_THIS_TEAM]); + return; + } + dispatch({ + type: 'users/confirmJoinTeam', + payload: { + teamUserId: +invitationCode || 0, + }, + }).then((ret) => { + msg.auto(ret); + if (ret.success) { + msg.success('Joined successfully'); + // this.handleHideModel(); + this.setState({ invitationCode: '' }); + tracker.event({ + category: 'users', + action: 'confirmJoinTeam', + }); + dispatch({ + type: 'users/getSelfJoinedTeams', + }); + } + }); + }; + + handleShowModel = (e) => { + if (e) { + e.stopPropagation(); + } + this.setState({ visible: true }); + }; + + handleHideModel = () => { + this.setState({ visible: false }); + }; + + render() { + const { children, loading, teams, form } = this.props; + + return ( + <> + {children} + +
+ + this.setState({ invitationCode: e.target.value })} + onSearch={this.handleConfirmJoinTeam} + /> + +
+ +

Joined Teams

+ + ( + + {record.nickname} +
+ Account: {record.username} +
+ )} + /> + ( + + {record.members.map((member) => ( + + ))} + + )} + /> + ( + + + + )} + /> +
+
+ + ); + } +} + +function mapStateToProps(state) { + return { + loading: !!state.loading.effects['users/getSelfJoinedTeams'], + teams: state.users.selfJoinedTeams, + confirmJoinLoading: !!state.loading.effects['users/confirmJoinTeam'], + }; +} + +export default connect(mapStateToProps)(Form.create()(SelfTeamsModal)); diff --git a/src/components/UserBar.tsx b/src/components/UserBar.tsx index e45e91b..60220f3 100644 --- a/src/components/UserBar.tsx +++ b/src/components/UserBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Avatar } from 'antd'; +import { Avatar, Tooltip } from 'antd'; import { Link } from 'react-router-dom'; import pages from '@/configs/pages'; import { formatAvatarUrl, urlf } from '@/utils/format'; @@ -15,6 +15,7 @@ export interface Props { disableJump?: boolean; showAsText?: boolean; showRating?: boolean; + useTooltip?: boolean; nameFormat?: (user: IUser) => string; } @@ -26,6 +27,7 @@ const UserBar: React.FC = ({ disableJump = false, showAsText = false, showRating = false, + useTooltip = false, nameFormat, className, }) => { @@ -59,7 +61,7 @@ const UserBar: React.FC = ({ to={urlf(pages.users.detail, { param: { id: user.userId } })} onClick={(e) => e.stopPropagation()} > - {inner} + {useTooltip ? {inner} : inner} ); }; diff --git a/src/components/UserSelect.tsx b/src/components/UserSelect.tsx index 5e0f11e..230cf60 100644 --- a/src/components/UserSelect.tsx +++ b/src/components/UserSelect.tsx @@ -95,7 +95,7 @@ class UserSelect extends React.Component { labelInValue > {data.map((d) => ( - + ))} diff --git a/src/dark.less b/src/dark.less index c34783f..b4e2c17 100644 --- a/src/dark.less +++ b/src/dark.less @@ -566,6 +566,12 @@ li.ant-calendar-time-picker-select-option-selected { } } +.u-team-member-card { + &:hover { + background-color: darken(@component-background, 2%); + } +} + .card-block-divider { border-left: 1px solid #555; } diff --git a/src/dark_duplicated4auto.less b/src/dark_duplicated4auto.less index c34783f..b4e2c17 100644 --- a/src/dark_duplicated4auto.less +++ b/src/dark_duplicated4auto.less @@ -566,6 +566,12 @@ li.ant-calendar-time-picker-select-option-selected { } } +.u-team-member-card { + &:hover { + background-color: darken(@component-background, 2%); + } +} + .card-block-divider { border-left: 1px solid #555; } diff --git a/src/global.less b/src/global.less index 511dcb5..2d80740 100644 --- a/src/global.less +++ b/src/global.less @@ -920,6 +920,19 @@ ul { //max-width: 500px; } +.u-team-member-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 8px; + transition: background-color ease-in-out .3s; + + &:hover { + background-color: darken(@component-background, 2.5%); + } +} + .u-avatar { position: absolute; top: -60px; diff --git a/src/layouts/components/JoinModal.tsx b/src/layouts/components/JoinModal.tsx index 63f1616..081e37c 100644 --- a/src/layouts/components/JoinModal.tsx +++ b/src/layouts/components/JoinModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'dva'; -import { Modal, Form, Input, Button, Alert, Badge } from 'antd'; +import { Modal, Form, Input, Button, Alert, Badge, Select } from 'antd'; import classNames from 'classnames'; import setStatePromise from '@/utils/setStatePromise'; import msg from '@/utils/msg'; @@ -12,6 +12,7 @@ import 'csshake'; import { Codes } from '@/common/codes'; import tracker from '@/utils/tracker'; import WeakPasswordChecker from '@/common/utils/weakpwd-check'; +import { EUserType } from '@/common/enums'; class JoinModal extends React.Component { private setStatePromise = setStatePromise.bind(this); @@ -60,6 +61,21 @@ class JoinModal extends React.Component { const passwordStatus = this.state.passwordStatus; return (
+ + {getFieldDecorator('type', { + rules: [ + { + required: true, + message: 'Please selected type', + }, + ], + initialValue: EUserType[EUserType.personal], + })()} + + {getFieldDecorator('email', { rules: [ @@ -545,6 +561,7 @@ class JoinModal extends React.Component { register = (data) => { const { dispatch, form } = this.props; data.code = +data.code; + data.type = EUserType[data.type]; dispatch({ type: 'users/register', payload: data, diff --git a/src/layouts/components/NavMenu.tsx b/src/layouts/components/NavMenu.tsx index 950b15e..86a0919 100644 --- a/src/layouts/components/NavMenu.tsx +++ b/src/layouts/components/NavMenu.tsx @@ -19,7 +19,7 @@ import tracker from '@/utils/tracker'; import { checkPerms } from '@/utils/permission'; import { EPerm } from '@/common/configs/perm.config'; import AchievementsModal from '@/components/AchievementsModal'; -import { EUserAchievementStatus } from '@/common/enums'; +import { EUserAchievementStatus, EUserType } from '@/common/enums'; // Reference https://github.com/id-kemo/responsive-menu-ant-design @@ -144,6 +144,7 @@ class NavMenu extends React.Component { activeLinkKey = pages.groups.index; } const from = location.query.from; + const isTeam = session.loggedIn && session.user.type === EUserType.team; return ( { title={ {this.renderAvatar()} - {session.user.nickname} + {session.user.nickname}{isTeam && } } > @@ -309,7 +310,7 @@ class NavMenu extends React.Component { className="float-right" > - {session.user.nickname} + {session.user.nickname}{isTeam && } diff --git a/src/models/session.ts b/src/models/session.ts index e501a5e..74e47a4 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -79,6 +79,9 @@ export default { yield put({ type: 'users/getSelfAchievedAchievements', }); + yield put({ + type: 'users/getSelfJoinedTeams', + }); yield put({ type: 'messages/getUnreadList', payload: { userId: ret.data.userId }, @@ -128,6 +131,9 @@ export default { yield put({ type: 'users/getSelfAchievedAchievements', }); + yield put({ + type: 'users/getSelfJoinedTeams', + }); yield put({ type: 'messages/getUnreadList', payload: { userId }, @@ -172,6 +178,9 @@ export default { yield put({ type: 'users/clearSelfAchievedAchievements', }); + yield put({ + type: 'users/clearSelfJoinedTeams', + }); yield put({ type: 'messages/clearAllMessages', }); diff --git a/src/pages/admin/users.tsx b/src/pages/admin/users.tsx index 7247da4..10758d9 100644 --- a/src/pages/admin/users.tsx +++ b/src/pages/admin/users.tsx @@ -8,7 +8,7 @@ import { ReduxProps, RouteProps } from '@/@types/props'; import PageAnimation from '@/components/PageAnimation'; import router from 'umi/router'; import tracker from '@/utils/tracker'; -import { Row, Col, Card, Table, Pagination, Button } from 'antd'; +import { Row, Col, Card, Table, Pagination, Button, Icon } from 'antd'; import limits from '@/configs/limits'; import FilterCard from '@/components/FilterCard'; import constants from '@/configs/constants'; @@ -25,6 +25,20 @@ import userPermission, { UserPermission } from '@/configs/userPermission'; import ImportUserModal from '@/components/ImportUserModal'; import { checkPerms } from '@/utils/permission'; import { EPerm } from '@/common/configs/perm.config'; +import { EUserType } from '@/common/enums'; + +const userTypeOptions = [ + { + id: EUserType.personal, + name: 'Personal', + value: EUserType.personal, + }, + { + id: EUserType.team, + name: 'Team', + value: EUserType.team, + }, +]; export interface Props extends RouteProps, ReduxProps { session: ISessionStatus; @@ -83,13 +97,25 @@ class AdminUserList extends React.Component { getUserDetailFormItems(userId?: number) { const { detailMap } = this.props; const detail = detailMap[userId]; - const items: IGeneralFormItem[] = [ + let items: IGeneralFormItem[] = [ + { + name: 'Account Type', + field: 'type', + component: 'select', + initialValue: `${detail?.type ?? EUserType.personal}`, + options: userTypeOptions.map((item) => ({ + value: item.id, + name: item.name, + })), + rules: [{ required: true }], + disabled: !!detail, + }, { name: 'Username', field: 'username', component: 'input', initialValue: detail?.username || '', - disabled: !!detail?.username, + disabled: !!detail, rules: [{ required: true, message: 'Please input the field' }], }, { @@ -158,17 +184,16 @@ class AdminUserList extends React.Component { rules: [{ required: true }], }, ]; - if (detail) { - items.splice(6, 1); - } else { - items.splice(7); - } + + const cannotEditFields = ['password']; + items = detail ? items.filter((item) => !cannotEditFields.includes(item.field)) : items; return items; } getHandledDataFromForm(values) { return { ...values, + type: +values.type, forbidden: +values.forbidden, permission: +values.permission, }; @@ -233,6 +258,9 @@ class AdminUserList extends React.Component { className={record.forbidden === UserForbidden.normal ? '' : 'text-secondary'} > {record.username} + {record.type === EUserType.team ? ( + + ) : null} )} /> @@ -290,6 +318,7 @@ class AdminUserList extends React.Component { const data = this.getHandledDataFromForm(values); delete data.username; delete data.password; + delete data.type; console.log('data', data); return dispatch({ type: 'admin/updateUserDetail', @@ -377,7 +406,7 @@ class AdminUserList extends React.Component { { }); }} > - + + + )} + + {members.length === 0 ? ( +

No Members

+ ) : ( + + {members.map((member) => ( + +
+ e.stopPropagation()} + > + +

{member.nickname}

+ + {editable && ( +
+
+ {member.status === EUserMemberStatus.pending ? ( + + ) : ( + + )} +
+ { + if (removeMemberLoading) { + return; + } + return this.handleRemoveMember(member.userId); + }} + > + + +
+ )} +
+ + ))} +
+ )} + {data.status !== EUserStatus.settled && ( +
+ +

You can confirm ready when all team members are accepted.

+

+ After this, the team account will be activated and{' '} + members cannot be changed. +

+ {allReady ? ( + + + + ) : ( + + + + )} +
+ )} + + ); + } + render() { const { loading, data: allData, session, match } = this.props; const { @@ -285,6 +509,7 @@ class UserDetail extends React.Component { const year = +d.date.split('-')[0]; solutionCalendarYears.add(year); }); + const isTeam = data.type === EUserType.team; let editProfileFormItems = [ { @@ -389,7 +614,10 @@ class UserDetail extends React.Component { )} -

{data.nickname}

+

+ {data.nickname} + {isTeam && } +

@@ -397,6 +625,7 @@ class UserDetail extends React.Component {
+ {isTeam && {this.renderTeam()}}

Rating

{ + {!isTeam && ( + + + + )} + { - + @@ -697,6 +932,11 @@ function mapStateToProps(state) { loading: !!state.loading.effects['users/getDetail'], data: state.users.detail, session: state.session, + members: state.users.members, + membersLoading: !!state.loading.effects['users/getMembers'], + addMemberLoading: !!state.loading.effects['users/addMember'], + removeMemberLoading: !!state.loading.effects['users/removeMember'], + confirmTeamSettlementLoading: !!state.loading.effects['users/confirmTeamSettlement'], }; } diff --git a/src/pages/users/models/users.ts b/src/pages/users/models/users.ts index 4c3b568..df14a19 100644 --- a/src/pages/users/models/users.ts +++ b/src/pages/users/models/users.ts @@ -7,7 +7,8 @@ import { formatListQuery } from '@/utils/format'; import { requestEffect } from '@/utils/effectInterceptor'; import { Results } from '@/configs/results'; import * as groupService from '../../groups/services/groups'; -import { IGetSelfAchievedAchievementsResp } from '@/common/contracts/user'; +import { IGetSelfAchievedAchievementsResp, IGetSelfJoinedTeamsResp, IGetUserMembersResp } from '@/common/contracts/user'; +import { EUserType } from '@/common/enums'; const initialState = { list: { @@ -22,6 +23,8 @@ const initialState = { attemptedProblemIds: [], }, achievedAchievements: [], + members: {}, + selfJoinedTeams: [], }; export default { @@ -62,6 +65,15 @@ export default { clearSelfAchievedAchievements(state) { state.achievedAchievements = []; }, + setMembers(state, { payload: { id, data } }) { + state.members[id] = [...data]; + }, + setSelfJoinedTeams(state, { payload: data }) { + state.selfJoinedTeams = [...data]; + }, + clearSelfJoinedTeams(state) { + state.selfJoinedTeams = []; + }, }, effects: { *getList({ payload: query }, { call, put, select }) { @@ -124,6 +136,14 @@ export default { data: detailRet.data, }, }); + if (detailRet.data.type === EUserType.team) { + yield put({ + type: 'getMembers', + payload: { + id, + }, + }); + } } return detailRet; }, @@ -220,6 +240,53 @@ export default { *receiveAchievement({ payload: { achievementKey } }, { call }) { return yield call(service.receiveAchievement, achievementKey); }, + *getMembers({ payload: { id } }, { call, put }) { + const ret: IApiResponse = yield call(service.getUserMembers, id); + if (ret.success) { + yield put({ + type: 'setMembers', + payload: { + id, + data: ret.data.rows, + }, + }); + } + return ret; + }, + *addMember({ payload: { memberUserId } }, { call }) { + return yield call(service.addUserMember, memberUserId); + }, + *removeMember({ payload: { memberUserId } }, { call }) { + return yield call(service.removeUserMember, memberUserId); + }, + *getSelfJoinedTeams(_, { call, put }) { + const ret: IApiResponse = yield call( + service.getSelfJoinedTeams, + ); + if (ret.success) { + yield put({ + type: 'setSelfJoinedTeams', + payload: ret.data.rows, + }); + } + return ret; + }, + *confirmJoinTeam({ payload: { teamUserId } }, { call }) { + return yield call(service.confirmJoinTeam, teamUserId); + }, + *confirmTeamSettlement(_, { call }) { + return yield call(service.confirmTeamSettlement); + }, + *getTeamData({ payload: { id } }, { call, all }) { + const [detailRet, membersRet]: IApiResponse[] = yield all([ + call(service.getDetail, id), + call(service.getUserMembers, id), + ]); + return { + detail: detailRet.data, + members: membersRet.data || [], + }; + }, }, subscriptions: { setup({ dispatch, history }) { diff --git a/src/pages/users/services/users.ts b/src/pages/users/services/users.ts index 43bff64..21289ea 100644 --- a/src/pages/users/services/users.ts +++ b/src/pages/users/services/users.ts @@ -20,6 +20,12 @@ import { IGetSelfAchievedAchievementsResp, IConfirmAchievementDeliveriedReq, IReceiveAchievementReq, + IGetUserMembersReq, + IGetUserMembersResp, + IAddUserMemberReq, + IRemoveUserMemberReq, + IConfirmJoinTeamReq, + IGetSelfJoinedTeamsResp, } from '@/common/contracts/user'; export function register(data) { @@ -113,3 +119,35 @@ export function receiveAchievement(achievementKey) { achievementKey, }); } + +export function getUserMembers(userId) { + return post(routesBe.getUserMembers.url, { + userId, + }); +} + +export function addUserMember(memberUserId) { + return post(routesBe.addUserMember.url, { + memberUserId, + }); +} + +export function removeUserMember(memberUserId) { + return post(routesBe.removeUserMember.url, { + memberUserId, + }); +} + +export function getSelfJoinedTeams() { + return post(routesBe.getSelfJoinedTeams.url); +} + +export function confirmJoinTeam(teamUserId) { + return post(routesBe.confirmJoinTeam.url, { + teamUserId, + }); +} + +export function confirmTeamSettlement() { + return post(routesBe.confirmTeamSettlement.url); +}