| | |
| | | @RequestParam(required = false) Long clinicId, |
| | | @RequestParam(required = false) Integer status, |
| | | @RequestParam(required = false) Integer isMember, |
| | | @RequestParam(required = false) String memberStatus, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime memberExpireFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime memberExpireTo, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime registeredFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime registeredTo) { |
| | | return ApiResponse.ok(userAppService.page( |
| | | page, pageSize, nickname, phone, clinicId, status, isMember, registeredFrom, registeredTo |
| | | page, pageSize, nickname, phone, clinicId, status, isMember, |
| | | memberStatus, memberExpireFrom, memberExpireTo, registeredFrom, registeredTo |
| | | )); |
| | | } |
| | | |
| | |
| | | @RequestParam(required = false) Long clinicId, |
| | | @RequestParam(required = false) Integer status, |
| | | @RequestParam(required = false) Integer isMember, |
| | | @RequestParam(required = false) String memberStatus, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime memberExpireFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime memberExpireTo, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime registeredFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime registeredTo) throws IOException { |
| | | userAppService.export(nickname, phone, clinicId, status, isMember, registeredFrom, registeredTo, response); |
| | | userAppService.export( |
| | | nickname, phone, clinicId, status, isMember, |
| | | memberStatus, memberExpireFrom, memberExpireTo, registeredFrom, registeredTo, response |
| | | ); |
| | | } |
| | | |
| | | @GetMapping("/users/{id}") |
| New file |
| | |
| | | package com.tcm.training.admin.member.excel; |
| | | |
| | | import cn.idev.excel.annotation.ExcelProperty; |
| | | import lombok.Getter; |
| | | import lombok.Setter; |
| | | |
| | | @Getter |
| | | @Setter |
| | | public class MemberUserExcelRow { |
| | | |
| | | @ExcelProperty("序号") |
| | | private Long id; |
| | | |
| | | @ExcelProperty("用户") |
| | | private String nickname; |
| | | |
| | | @ExcelProperty("电话") |
| | | private String phone; |
| | | |
| | | @ExcelProperty("会员状态") |
| | | private String memberStatus; |
| | | |
| | | @ExcelProperty("到期时间") |
| | | private String memberExpireAt; |
| | | } |
| | |
| | | @Param("clinicId") Long clinicId, |
| | | @Param("status") Integer status, |
| | | @Param("isMember") Integer isMember, |
| | | @Param("memberStatus") String memberStatus, |
| | | @Param("memberExpireFrom") LocalDateTime memberExpireFrom, |
| | | @Param("memberExpireTo") LocalDateTime memberExpireTo, |
| | | @Param("registeredFrom") LocalDateTime registeredFrom, |
| | | @Param("registeredTo") LocalDateTime registeredTo); |
| | | |
| | |
| | | @Param("clinicId") Long clinicId, |
| | | @Param("status") Integer status, |
| | | @Param("isMember") Integer isMember, |
| | | @Param("memberStatus") String memberStatus, |
| | | @Param("memberExpireFrom") LocalDateTime memberExpireFrom, |
| | | @Param("memberExpireTo") LocalDateTime memberExpireTo, |
| | | @Param("registeredFrom") LocalDateTime registeredFrom, |
| | | @Param("registeredTo") LocalDateTime registeredTo); |
| | | |
| | |
| | | private Long clinicId; |
| | | private String clinicName; |
| | | private Integer isMember; |
| | | private LocalDateTime memberExpireAt; |
| | | private LocalDateTime createdAt; |
| | | } |
| | |
| | | import com.tcm.training.admin.live.entity.Clinic; |
| | | import com.tcm.training.admin.member.entity.AppUser; |
| | | import com.tcm.training.admin.live.mapper.ClinicMapper; |
| | | import com.tcm.training.admin.member.excel.MemberUserExcelRow; |
| | | import com.tcm.training.admin.member.dto.UserResetPasswordRequest; |
| | | import com.tcm.training.admin.member.dto.UserStatusRequest; |
| | | import com.tcm.training.admin.member.dto.UserUpdateRequest; |
| | |
| | | private static final Pattern PASSWORD_PATTERN = Pattern.compile( |
| | | "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{12,64}$" |
| | | ); |
| | | private static final String MEMBER_STATUS_ALL = "ALL"; |
| | | private static final String MEMBER_STATUS_ACTIVE = "ACTIVE"; |
| | | private static final String MEMBER_STATUS_EXPIRED = "EXPIRED"; |
| | | |
| | | private final UserMapper userMapper; |
| | | private final ClinicMapper clinicMapper; |
| | |
| | | Long clinicId, |
| | | Integer status, |
| | | Integer isMember, |
| | | String memberStatus, |
| | | LocalDateTime memberExpireFrom, |
| | | LocalDateTime memberExpireTo, |
| | | LocalDateTime registeredFrom, |
| | | LocalDateTime registeredTo) { |
| | | String normalizedMemberStatus = normalizeMemberStatus(memberStatus); |
| | | IPage<UserListRow> result = userMapper.selectUserPage( |
| | | new Page<UserListRow>(page, pageSize), |
| | | trimToNull(nickname), |
| | |
| | | clinicId, |
| | | status, |
| | | isMember, |
| | | normalizedMemberStatus, |
| | | memberExpireFrom, |
| | | memberExpireTo, |
| | | registeredFrom, |
| | | registeredTo |
| | | ); |
| | |
| | | item.setAge(row.getAge()); |
| | | item.setGender(row.getGender()); |
| | | item.setIsMember(row.getIsMember() != null && row.getIsMember() == 1); |
| | | item.setMemberExpireAt(row.getMemberExpireAt()); |
| | | item.setStatus(row.getStatus()); |
| | | item.setCreatedAt(row.getCreatedAt()); |
| | | list.add(item); |
| | |
| | | Long clinicId, |
| | | Integer status, |
| | | Integer isMember, |
| | | String memberStatus, |
| | | LocalDateTime memberExpireFrom, |
| | | LocalDateTime memberExpireTo, |
| | | LocalDateTime registeredFrom, |
| | | LocalDateTime registeredTo, |
| | | HttpServletResponse response) throws IOException { |
| | | String normalizedMemberStatus = normalizeMemberStatus(memberStatus); |
| | | boolean memberMode = normalizedMemberStatus != null; |
| | | response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); |
| | | response.setCharacterEncoding("utf-8"); |
| | | String filename = URLEncoder.encode("用户列表", "UTF-8").replace("+", "%20"); |
| | | String filename = URLEncoder.encode(memberMode ? "会员用户列表" : "用户列表", "UTF-8").replace("+", "%20"); |
| | | response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx"); |
| | | |
| | | List<UserListRow> rows = userMapper.selectUserList( |
| | |
| | | clinicId, |
| | | status, |
| | | isMember, |
| | | normalizedMemberStatus, |
| | | memberExpireFrom, |
| | | memberExpireTo, |
| | | registeredFrom, |
| | | registeredTo |
| | | ); |
| | | if (memberMode) { |
| | | List<MemberUserExcelRow> memberExcelRows = new ArrayList<MemberUserExcelRow>(); |
| | | for (UserListRow row : rows) { |
| | | MemberUserExcelRow excel = new MemberUserExcelRow(); |
| | | excel.setId(row.getId()); |
| | | excel.setNickname(row.getNickname()); |
| | | excel.setPhone(PhoneMasker.mask(row.getPhone())); |
| | | excel.setMemberStatus(row.getIsMember() != null && row.getIsMember() == 1 ? "生效中" : "已失效"); |
| | | excel.setMemberExpireAt(DateTimes.format(row.getMemberExpireAt())); |
| | | memberExcelRows.add(excel); |
| | | } |
| | | FastExcel.write(response.getOutputStream(), MemberUserExcelRow.class) |
| | | .sheet("会员用户列表") |
| | | .doWrite(memberExcelRows); |
| | | return; |
| | | } |
| | | List<UserExcelRow> excelRows = new ArrayList<UserExcelRow>(); |
| | | for (UserListRow row : rows) { |
| | | UserExcelRow excel = new UserExcelRow(); |
| | |
| | | return trimmed.isEmpty() ? null : trimmed; |
| | | } |
| | | |
| | | private String normalizeMemberStatus(String memberStatus) { |
| | | String normalized = trimToNull(memberStatus); |
| | | if (normalized == null) { |
| | | return null; |
| | | } |
| | | String upper = normalized.toUpperCase(); |
| | | if (MEMBER_STATUS_ALL.equals(upper) || MEMBER_STATUS_ACTIVE.equals(upper) || MEMBER_STATUS_EXPIRED.equals(upper)) { |
| | | return upper; |
| | | } |
| | | throw new IllegalArgumentException("会员状态筛选不合法"); |
| | | } |
| | | |
| | | private String typeText(Integer type) { |
| | | return type != null && type == 1 ? "诊所" : "个人"; |
| | | } |
| | |
| | | private Integer age; |
| | | private Integer gender; |
| | | private Boolean isMember; |
| | | private LocalDateTime memberExpireAt; |
| | | private Integer status; |
| | | private LocalDateTime createdAt; |
| | | } |
| | |
| | | <if test="isMember != null and isMember == 0"> |
| | | AND (s.member_expire_at IS NULL OR s.member_expire_at <= NOW()) |
| | | </if> |
| | | <if test="memberStatus != null and memberStatus == 'ALL'"> |
| | | AND s.member_expire_at IS NOT NULL |
| | | </if> |
| | | <if test="memberStatus != null and memberStatus == 'ACTIVE'"> |
| | | AND s.member_expire_at IS NOT NULL AND s.member_expire_at > NOW() |
| | | </if> |
| | | <if test="memberStatus != null and memberStatus == 'EXPIRED'"> |
| | | AND s.member_expire_at IS NOT NULL AND s.member_expire_at <= NOW() |
| | | </if> |
| | | <if test="memberExpireFrom != null"> |
| | | AND s.member_expire_at >= #{memberExpireFrom} |
| | | </if> |
| | | <if test="memberExpireTo != null"> |
| | | AND s.member_expire_at <= #{memberExpireTo} |
| | | </if> |
| | | <if test="registeredFrom != null"> |
| | | AND s.created_at >= #{registeredFrom} |
| | | </if> |
| | |
| | | WHEN s.member_expire_at IS NOT NULL AND s.member_expire_at > NOW() THEN 1 |
| | | ELSE 0 |
| | | END AS isMember, |
| | | s.member_expire_at AS memberExpireAt, |
| | | s.created_at AS createdAt |
| | | FROM tcm_app_user s |
| | | LEFT JOIN tcm_clinic c ON c.id = s.clinic_id AND c.deleted = 0 |
| | |
| | | WHEN s.member_expire_at IS NOT NULL AND s.member_expire_at > NOW() THEN 1 |
| | | ELSE 0 |
| | | END AS isMember, |
| | | s.member_expire_at AS memberExpireAt, |
| | | s.created_at AS createdAt |
| | | FROM tcm_app_user s |
| | | LEFT JOIN tcm_clinic c ON c.id = s.clinic_id AND c.deleted = 0 |
| New file |
| | |
| | | package com.tcm.training.admin.member.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import com.tcm.training.admin.common.PageResponse; |
| | | import com.tcm.training.admin.live.mapper.ClinicMapper; |
| | | import com.tcm.training.admin.member.mapper.UserMapper; |
| | | import com.tcm.training.admin.member.row.UserListRow; |
| | | import com.tcm.training.admin.member.vo.UserListItemVO; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Collections; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.ArgumentMatchers.isNull; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | class UserAppServiceTest { |
| | | |
| | | @Mock |
| | | private UserMapper userMapper; |
| | | |
| | | @Mock |
| | | private ClinicMapper clinicMapper; |
| | | |
| | | @Mock |
| | | private BCryptPasswordEncoder passwordEncoder; |
| | | |
| | | @InjectMocks |
| | | private UserAppService userAppService; |
| | | |
| | | @Test |
| | | void pageShouldMapMemberExpireAtForMemberUserQuery() { |
| | | LocalDateTime memberExpireAt = LocalDateTime.of(2026, 6, 8, 3, 15); |
| | | UserListRow row = new UserListRow(); |
| | | row.setId(11L); |
| | | row.setNickname("王美丽"); |
| | | row.setPhone("14712346905"); |
| | | row.setAvatar("https://cdn.test/avatar.png"); |
| | | row.setIsMember(1); |
| | | row.setMemberExpireAt(memberExpireAt); |
| | | |
| | | IPage<UserListRow> pageResult = new Page<UserListRow>(1, 10, 1); |
| | | pageResult.setRecords(Collections.singletonList(row)); |
| | | when(userMapper.selectUserPage( |
| | | any(), |
| | | eq("王美丽"), |
| | | eq("14712346905"), |
| | | isNull(), |
| | | isNull(), |
| | | isNull(), |
| | | eq("ACTIVE"), |
| | | isNull(), |
| | | isNull(), |
| | | isNull(), |
| | | isNull() |
| | | )).thenReturn(pageResult); |
| | | |
| | | PageResponse<UserListItemVO> result = userAppService.page( |
| | | 1, 10, " 王美丽 ", "14712346905", null, null, null, |
| | | "active", null, null, null, null |
| | | ); |
| | | |
| | | assertEquals(1, result.getList().size()); |
| | | assertEquals(memberExpireAt, result.getList().get(0).getMemberExpireAt()); |
| | | assertEquals(Boolean.TRUE, result.getList().get(0).getIsMember()); |
| | | } |
| | | |
| | | @Test |
| | | void pageShouldRejectInvalidMemberStatus() { |
| | | IllegalArgumentException error = assertThrows( |
| | | IllegalArgumentException.class, |
| | | () -> userAppService.page( |
| | | 1, 10, null, null, null, null, null, |
| | | "INVALID", null, null, null, null |
| | | ) |
| | | ); |
| | | |
| | | assertEquals("会员状态筛选不合法", error.getMessage()); |
| | | } |
| | | } |
| | |
| | | import './style.less'; |
| | | |
| | | type UserListMode = 'all' | 'member'; |
| | | type MemberStatusValue = 'ALL' | 'ACTIVE' | 'EXPIRED'; |
| | | |
| | | type SearchValues = { |
| | | nickname?: string; |
| | |
| | | clinicId?: number; |
| | | status?: number; |
| | | isMember?: number; |
| | | memberStatus?: MemberStatusValue; |
| | | memberExpireRange?: Dayjs[]; |
| | | registeredRange?: Dayjs[]; |
| | | }; |
| | | |
| | |
| | | age?: number; |
| | | gender?: number; |
| | | isMember: boolean; |
| | | memberExpireAt?: string | null; |
| | | status: number; |
| | | createdAt: string; |
| | | }; |
| | |
| | | |
| | | const buildSearchParams = (values: SearchValues, mode: UserListMode) => { |
| | | const config = PAGE_CONFIG[mode]; |
| | | if (mode === 'member') { |
| | | const memberExpireFrom = values.memberExpireRange?.[0] |
| | | ? dayjs(values.memberExpireRange[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss') |
| | | : undefined; |
| | | const memberExpireTo = values.memberExpireRange?.[1] |
| | | ? dayjs(values.memberExpireRange[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss') |
| | | : undefined; |
| | | return { |
| | | nickname: values.nickname?.trim() || undefined, |
| | | phone: values.phone?.trim() || undefined, |
| | | memberStatus: values.memberStatus || 'ALL', |
| | | memberExpireFrom, |
| | | memberExpireTo, |
| | | }; |
| | | } |
| | | const registeredFrom = values.registeredRange?.[0] |
| | | ? dayjs(values.registeredRange[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss') |
| | | : undefined; |
| | |
| | | registeredFrom, |
| | | registeredTo, |
| | | }; |
| | | }; |
| | | |
| | | const formatMemberStatus = (record: UserItem, mode: UserListMode) => { |
| | | if (mode === 'member') { |
| | | return record.isMember ? '生效中' : '已失效'; |
| | | } |
| | | return record.isMember ? '是' : '否'; |
| | | }; |
| | | |
| | | const formatMemberExpireAt = (value?: string | null) => { |
| | | return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'; |
| | | }; |
| | | |
| | | const downloadBlob = (blob: Blob, filename: string) => { |
| | |
| | | ), |
| | | }, |
| | | { |
| | | title: '类型', |
| | | dataIndex: 'type', |
| | | width: 100, |
| | | render: (value) => (value === 1 ? '诊所' : '个人'), |
| | | }, |
| | | { |
| | | title: '电话', |
| | | dataIndex: 'phone', |
| | | width: 150, |
| | | }, |
| | | ...(mode === 'member' |
| | | ? [ |
| | | { |
| | | title: '会员状态', |
| | | dataIndex: 'isMember', |
| | | width: 120, |
| | | render: (_: boolean, record: UserItem) => formatMemberStatus(record, mode), |
| | | }, |
| | | { |
| | | title: '到期时间', |
| | | dataIndex: 'memberExpireAt', |
| | | width: 180, |
| | | render: (value: string | null | undefined) => formatMemberExpireAt(value), |
| | | }, |
| | | ] |
| | | : [ |
| | | { |
| | | title: '类型', |
| | | dataIndex: 'type', |
| | | width: 100, |
| | | render: (value: number) => (value === 1 ? '诊所' : '个人'), |
| | | }, |
| | | { |
| | | title: '绑定诊所', |
| | | dataIndex: 'clinicName', |
| | | width: 180, |
| | | render: (value) => value || '-', |
| | | render: (value: string | undefined) => value || '-', |
| | | }, |
| | | { |
| | | title: '年龄', |
| | | dataIndex: 'age', |
| | | width: 90, |
| | | render: (value) => value ?? '-', |
| | | render: (value: number | undefined) => value ?? '-', |
| | | }, |
| | | { |
| | | title: '性别', |
| | | dataIndex: 'gender', |
| | | width: 90, |
| | | render: (value) => (value === 1 ? '男' : value === 0 ? '女' : '-'), |
| | | render: (value: number | undefined) => (value === 1 ? '男' : value === 0 ? '女' : '-'), |
| | | }, |
| | | ...(config.showIsMemberColumn |
| | | ? [ |
| | |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | | width: 100, |
| | | render: (value) => (value === 1 ? '启用' : '禁用'), |
| | | render: (value: number) => (value === 1 ? '启用' : '禁用'), |
| | | }, |
| | | { |
| | | title: '注册时间', |
| | | dataIndex: 'createdAt', |
| | | width: 180, |
| | | }, |
| | | ]), |
| | | { |
| | | title: '操作', |
| | | width: 140, |
| | |
| | | <div className="member-user-page__panel"> |
| | | <div className="member-user-page__title">{config.title}</div> |
| | | <div className="member-user-search-card"> |
| | | <Form form={searchForm} layout="horizontal" className="member-user-search-form"> |
| | | <Form |
| | | form={searchForm} |
| | | layout="horizontal" |
| | | className={`member-user-search-form${mode === 'member' ? ' member-user-search-form--member' : ''}`} |
| | | > |
| | | <Form.Item label="用户名称" name="nickname"> |
| | | <Input placeholder="请输入" allowClear /> |
| | | </Form.Item> |
| | | <Form.Item label="用户电话" name="phone"> |
| | | <Input placeholder="请输入" allowClear /> |
| | | </Form.Item> |
| | | {mode === 'member' ? ( |
| | | <Form.Item label="会员状态" name="memberStatus"> |
| | | <Select |
| | | placeholder="请选择" |
| | | allowClear |
| | | options={[ |
| | | { label: '全部', value: 'ALL' }, |
| | | { label: '生效中', value: 'ACTIVE' }, |
| | | { label: '已失效', value: 'EXPIRED' }, |
| | | ]} |
| | | /> |
| | | </Form.Item> |
| | | ) : ( |
| | | <Form.Item label="绑定诊所" name="clinicId"> |
| | | <Select |
| | | placeholder="请输入" |
| | |
| | | options={clinics.map((item) => ({ label: item.name, value: item.id }))} |
| | | /> |
| | | </Form.Item> |
| | | )} |
| | | {mode === 'member' ? ( |
| | | <Form.Item label="到期时间" name="memberExpireRange"> |
| | | <DatePicker.RangePicker className="member-user-search-form__range" /> |
| | | </Form.Item> |
| | | ) : ( |
| | | <Form.Item label="状态" name="status"> |
| | | <Select |
| | | placeholder="请选择" |
| | |
| | | ]} |
| | | /> |
| | | </Form.Item> |
| | | )} |
| | | {config.showIsMemberFilter ? ( |
| | | <Form.Item label="是否会员" name="isMember"> |
| | | <Select |
| | |
| | | /> |
| | | </Form.Item> |
| | | ) : null} |
| | | {mode === 'member' ? null : ( |
| | | <Form.Item label="注册时间" name="registeredRange"> |
| | | <DatePicker.RangePicker className="member-user-search-form__range" /> |
| | | </Form.Item> |
| | | )} |
| | | <div className="member-user-search-form__actions"> |
| | | <Button |
| | | onClick={() => { |
| | |
| | | columns={columns} |
| | | dataSource={list} |
| | | pagination={false} |
| | | scroll={{ x: 1320 }} |
| | | scroll={{ x: mode === 'member' ? 920 : 1320 }} |
| | | /> |
| | | <div className="member-user-pagination"> |
| | | <Pagination |
| | |
| | | align-items: end; |
| | | } |
| | | |
| | | .member-user-search-form--member { |
| | | grid-template-columns: repeat(5, minmax(180px, 1fr)); |
| | | } |
| | | |
| | | .member-user-search-form .ant-form-item { |
| | | width: 100%; |
| | | margin-right: 0; |
| | |
| | | .member-user-search-form { |
| | | grid-template-columns: repeat(2, minmax(220px, 1fr)); |
| | | } |
| | | |
| | | .member-user-search-form--member { |
| | | grid-template-columns: repeat(2, minmax(220px, 1fr)); |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |