“mitao”
2026-03-27 2f05b4e14515e0a7dd7e76e8bf0e40ec1d8ef8e2
fix:会员列表
2个文件已添加
8个文件已修改
310 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/controller/UserController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/MemberUserExcelRow.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/UserMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/UserListRow.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/vo/UserListItemVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/mapper/UserMapper.xml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/list-page.tsx 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/user/style.less 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/controller/UserController.java
@@ -49,10 +49,14 @@
            @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
        ));
    }
@@ -64,9 +68,15 @@
            @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}")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/excel/MemberUserExcelRow.java
New file
@@ -0,0 +1,25 @@
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;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/mapper/UserMapper.java
@@ -20,6 +20,9 @@
                                      @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);
@@ -28,6 +31,9 @@
                                     @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);
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/row/UserListRow.java
@@ -22,5 +22,6 @@
    private Long clinicId;
    private String clinicName;
    private Integer isMember;
    private LocalDateTime memberExpireAt;
    private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/service/UserAppService.java
@@ -10,6 +10,7 @@
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;
@@ -41,6 +42,9 @@
    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;
@@ -61,8 +65,12 @@
                                             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),
@@ -70,6 +78,9 @@
                clinicId,
                status,
                isMember,
                normalizedMemberStatus,
                memberExpireFrom,
                memberExpireTo,
                registeredFrom,
                registeredTo
        );
@@ -85,6 +96,7 @@
            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);
@@ -213,12 +225,17 @@
                       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(
@@ -227,9 +244,28 @@
                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();
@@ -275,6 +311,18 @@
        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 ? "诊所" : "个人";
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/member/vo/UserListItemVO.java
@@ -19,6 +19,7 @@
    private Integer age;
    private Integer gender;
    private Boolean isMember;
    private LocalDateTime memberExpireAt;
    private Integer status;
    private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/resources/mapper/UserMapper.xml
@@ -23,6 +23,21 @@
        <if test="isMember != null and isMember == 0">
            AND (s.member_expire_at IS NULL OR s.member_expire_at &lt;= 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 &gt; NOW()
        </if>
        <if test="memberStatus != null and memberStatus == 'EXPIRED'">
            AND s.member_expire_at IS NOT NULL AND s.member_expire_at &lt;= NOW()
        </if>
        <if test="memberExpireFrom != null">
            AND s.member_expire_at &gt;= #{memberExpireFrom}
        </if>
        <if test="memberExpireTo != null">
            AND s.member_expire_at &lt;= #{memberExpireTo}
        </if>
        <if test="registeredFrom != null">
            AND s.created_at &gt;= #{registeredFrom}
        </if>
@@ -47,6 +62,7 @@
                WHEN s.member_expire_at IS NOT NULL AND s.member_expire_at &gt; 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
@@ -70,6 +86,7 @@
                WHEN s.member_expire_at IS NOT NULL AND s.member_expire_at &gt; 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
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/member/service/UserAppServiceTest.java
New file
@@ -0,0 +1,91 @@
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());
    }
}
tcm-training-admin/src/pages/member/user/list-page.tsx
@@ -19,6 +19,7 @@
import './style.less';
type UserListMode = 'all' | 'member';
type MemberStatusValue = 'ALL' | 'ACTIVE' | 'EXPIRED';
type SearchValues = {
  nickname?: string;
@@ -26,6 +27,8 @@
  clinicId?: number;
  status?: number;
  isMember?: number;
  memberStatus?: MemberStatusValue;
  memberExpireRange?: Dayjs[];
  registeredRange?: Dayjs[];
};
@@ -39,6 +42,7 @@
  age?: number;
  gender?: number;
  isMember: boolean;
  memberExpireAt?: string | null;
  status: number;
  createdAt: string;
};
@@ -88,6 +92,21 @@
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;
@@ -103,6 +122,17 @@
    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) => {
@@ -231,33 +261,49 @@
      ),
    },
    {
      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
      ? [
@@ -273,13 +319,14 @@
      title: '状态',
      dataIndex: 'status',
      width: 100,
      render: (value) => (value === 1 ? '启用' : '禁用'),
            render: (value: number) => (value === 1 ? '启用' : '禁用'),
    },
    {
      title: '注册时间',
      dataIndex: 'createdAt',
      width: 180,
    },
        ]),
    {
      title: '操作',
      width: 140,
@@ -305,13 +352,30 @@
      <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="请输入"
@@ -319,6 +383,12 @@
                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="请选择"
@@ -329,6 +399,7 @@
                ]}
              />
            </Form.Item>
            )}
            {config.showIsMemberFilter ? (
              <Form.Item label="是否会员" name="isMember">
                <Select
@@ -341,9 +412,11 @@
                />
              </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={() => {
@@ -373,7 +446,7 @@
            columns={columns}
            dataSource={list}
            pagination={false}
            scroll={{ x: 1320 }}
            scroll={{ x: mode === 'member' ? 920 : 1320 }}
          />
          <div className="member-user-pagination">
            <Pagination
tcm-training-admin/src/pages/member/user/style.less
@@ -31,6 +31,10 @@
  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;
@@ -193,6 +197,10 @@
  .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) {