diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..87de82a --- /dev/null +++ b/.env.development @@ -0,0 +1,11 @@ +# 开发环境 + +# 前端请求的基础路径,配合 vite.config.js 的 proxy 使用 +# 前端代码中所有接口都会以 /api 开头,由 vite dev server 转发到 YApi +VITE_API_BASE_URL=/api + +# YApi Mock 服务地址(必填) +# 示例:https://yapi.xxx.com/mock/123 +# 其中 123 为你的 YApi 项目 ID,可在 YApi 项目首页 URL 中找到 +# 注意:地址末尾不要带斜杠 / +VITE_YAPI_MOCK=http://124.222.159.10:8090 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..f722dd2 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +# 生产环境 +# 部署后实际接口地址,按需修改 +VITE_API_BASE_URL=/api diff --git a/.gitignore b/.gitignore index 3c3629e..a9e3b25 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ node_modules + +# 环境变量本地覆盖文件(存放个人的 YApi 地址等,不提交) +.env.local +.env.*.local + +# 构建产物 +dist +.vite diff --git a/src/App.jsx b/src/App.jsx index 89ffd34..42f82be 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,10 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom' import Login from './pages/Login' import Layout from './pages/Layout' import MemberList from './pages/MemberList' +import { getToken } from './api/request' function App() { const isLoggedIn = () => { - return localStorage.getItem('token') === 'admin' + return !!getToken() } return ( @@ -15,7 +16,8 @@ function App() { path="/" element={isLoggedIn() ? : } > - } /> + } /> + } /> ) diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..254c0f1 --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,30 @@ +import http from './request' + +/** + * 认证相关接口 + * 后端域名为 /api,这里路径不带 /api 前缀 + */ + +/** + * 登录 + * 完整路径:/api/v1/crm/auth/login + * @param {{ username: string, password: string }} data + * @returns {Promise<{ token: string, userInfo?: object }>} + */ +export function login(data) { + return http.post('/v1/crm/auth/login', data) +} + +/** + * 退出登录 + */ +export function logout() { + return http.post('/v1/crm/auth/logout') +} + +/** + * 获取当前登录用户信息 + */ +export function getUserInfo() { + return http.get('/v1/crm/auth/userInfo') +} diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..0c2e5b4 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,16 @@ +/** + * API 入口聚合 + * + * 使用示例: + * import { authApi, memberApi } from '@/api' + * authApi.login({ username, password }) + * memberApi.fetchMemberList({ current: 1, pageSize: 10 }) + * + * 或按需引入: + * import { login } from '@/api/auth' + */ + +export * as authApi from './auth' +export * as memberApi from './member' + +export { default as http, request, getToken, setToken, removeToken } from './request' diff --git a/src/api/member.js b/src/api/member.js new file mode 100644 index 0000000..cea6d7e --- /dev/null +++ b/src/api/member.js @@ -0,0 +1,296 @@ +import http from './request' + +/** + * 会员相关接口 + */ + +/** + * 单位/组织类型枚举与中文标签映射 + * 对应后端枚举:PUBLIC_INSTITUTION, ENTERPRISE, OTHER + */ +export const UNIT_OR_ORG_TYPE_MAP = { + PUBLIC_INSTITUTION: '事业单位', + ENTERPRISE: '企业', + OTHER: '其他', +} + +export const UNIT_OR_ORG_TYPE_OPTIONS = Object.entries(UNIT_OR_ORG_TYPE_MAP).map( + ([value, label]) => ({ value, label }) +) + +/** + * 单位或组织会员-分页查询 + * 完整路径:/api/v1/crm/unit-or-org-members/page + * @param {object} params + * @param {number} [params.current=1] - 当前页 + * @param {number} [params.size=10] - 每页大小 + * @param {string} [params.name] - 单位或组织名称(模糊查询) + * @returns {Promise<{ records: Array, total: number, current: number, size: number, pages: number }>} + */ +export function fetchUnitOrOrgMemberPage(params) { + return http.get('/v1/crm/unit-or-org-members/page', params) +} + +/** + * 单位/组织会员-新增 + * 完整路径:POST /api/v1/crm/unit-or-org-members + * @param {object} data - 严格按协议字段提交 + * @returns {Promise} + */ +export function createUnitOrOrgMember(data) { + return http.post('/v1/crm/unit-or-org-members', data) +} + +/* ==================== 个人会员枚举与接口 ==================== */ + +/** 性别 */ +export const GENDER_MAP = { + MALE: '男', + FEMALE: '女', + OTHER: '其他', +} + +/** 政治面貌 */ +export const POLITICAL_STATUS_MAP = { + CPC_MEMBER: '中共党员', + CYL_MEMBER: '共青团员', + RCCK_MEMBER: '民革党员', + CDL_MEMBER: '民盟盟员', + CDNCA_MEMBER: '民建会员', + CADP_MEMBER: '民进会员', + CPWDP_MEMBER: '农工党党员', + CZDP_MEMBER: '致公党党员', + JSSS_MEMBER: '九三学社社员', + TAIWAN_LEAGUE_MEMBER: '台盟盟员', + NON_PARTISAN: '无党派人士', + DEMOCRATIC_PARTIES: '民主党派', + MASSES: '群众', + OTHER: '其他', +} + +/** 学历 */ +export const EDUCATION_LEVEL_MAP = { + DOCTORAL: '博士研究生', + MASTER: '硕士研究生', + BACHELOR: '大学本科', + COLLEGE_AND_BELOW: '大专及以下', + OTHER: '其他', +} + +/** 职称 */ +export const TITLE_MAP = { + CHIEF_PHYSICIAN: '主任(中)医师', + DEPUTY_CHIEF_PHYSICIAN: '副主任(中)医师', + ATTENDING_PHYSICIAN: '主治(中)医师', + LICENSED_PHYSICIAN: '执业(中)医师', + ASSISTANT_PHYSICIAN: '助理(中)医师', + CHIEF_PHARMACIST: '主任(中)药师', + DEPUTY_CHIEF_PHARMACIST: '副主任(中)药师', + SUPERVISOR_PHARMACIST: '主管(中)药师', + LICENSED_PHARMACIST: '执业(中)药师', + CHIEF_NURSE: '主任护师', + DEPUTY_CHIEF_NURSE: '副主任护师', + SUPERVISOR_NURSE: '主管护师', + LICENSED_NURSE: '执业护师', + RESEARCHER: '研究员', + DEPUTY_RESEARCHER: '副研究员', + ASSISTANT_RESEARCHER: '助理研究员', + CHIEF_TECHNICIAN: '主任技师', + DEPUTY_CHIEF_TECHNICIAN: '副主任技师', + SUPERVISOR_TECHNICIAN: '主管技师', + PROFESSOR: '教授', + ASSOCIATE_PROFESSOR: '副教授', + LECTURER: '讲师', + OTHER_SENIOR_TITLE: '其他高级职称', + OTHER_DEPUTY_SENIOR_TITLE: '其他副高职称', + OTHER_INTERMEDIATE_TITLE: '其他中级职称', + OTHER_JUNIOR_TITLE: '其他初级职称', +} + +/** 地区或单位(金华区域) */ +export const REGION_OR_UNIT_MAP = { + JINHUA_TCM_HOSPITAL: '金华市中医医院', + JINHUA_CENTRAL_HOSPITAL: '金华市中心医院', + JINHUA_PEOPLE_HOSPITAL: '金华市人民医院', + JINHUA_SECOND_HOSPITAL: '金华市第二医院', + JINHUA_FIFTH_HOSPITAL: '金华市第五医院', + WUCHENG_DISTRICT: '婺城区', + JINDONG_DISTRICT: '金东区', + DEVELOPMENT_ZONE: '开发区', + YIWU_CITY: '义乌市', + LANXI_CITY: '兰溪市', + DONGYANG_CITY: '东阳市', + YONGKANG_CITY: '永康市', + PUJIANG_COUNTY: '浦江县', + WUYI_COUNTY: '武义县', + PANAN_COUNTY: '磐安县', +} + +/** 个人会员类型 */ +export const PERSONAL_MEMBER_TYPE_MAP = { + SPECIAL_COMMITTEE_MEMBER: '专科分会委员', + ORDINARY_MEMBER: '普通会员', + REGIONAL_ASSOCIATION_LEADER: '地区中医药学(协)会会长、秘书长', +} + +export const GENDER_OPTIONS = Object.entries(GENDER_MAP).map(([value, label]) => ({ value, label })) +export const POLITICAL_STATUS_OPTIONS = Object.entries(POLITICAL_STATUS_MAP).map(([value, label]) => ({ value, label })) +export const EDUCATION_LEVEL_OPTIONS = Object.entries(EDUCATION_LEVEL_MAP).map(([value, label]) => ({ value, label })) +export const TITLE_OPTIONS = Object.entries(TITLE_MAP).map(([value, label]) => ({ value, label })) +export const REGION_OR_UNIT_OPTIONS = Object.entries(REGION_OR_UNIT_MAP).map(([value, label]) => ({ value, label })) +export const PERSONAL_MEMBER_TYPE_OPTIONS = Object.entries(PERSONAL_MEMBER_TYPE_MAP).map(([value, label]) => ({ value, label })) + +/** + * 个人会员-分页查询 + * 完整路径:/api/v1/crm/personal-members/page + */ +export function fetchPersonalMemberPage(params) { + return http.get('/v1/crm/personal-members/page', params) +} + +/** + * 个人会员-新增 + * 完整路径:POST /api/v1/crm/personal-members + * @param {object} data - 严格按协议字段提交 + * @returns {Promise} + */ +export function createPersonalMember(data) { + return http.post('/v1/crm/personal-members', data) +} + +/** + * 获取会员列表(分页 + 筛选)- 兼容旧接口 + * @deprecated 已切换为 fetchUnitOrOrgMemberPage / fetchPersonalMemberPage + */ +export function fetchMemberList(params) { + return http.get('/member/list', params) +} + +/** + * 获取会员详情 + * @param {string|number} id + */ +export function fetchMemberDetail(id) { + return http.get(`/member/${id}`) +} + +/** + * 新增会员 + * @param {object} data - 会员表单数据 + */ +export function createMember(data) { + return http.post('/member', data) +} + +/** + * 更新会员 + * @param {string|number} id + * @param {object} data + */ +export function updateMember(id, data) { + return http.put(`/member/${id}`, data) +} + +/** + * 删除会员 + * @param {string|number} id + */ +export function removeMember(id) { + return http.delete(`/member/${id}`) +} + +/** + * 批量删除会员 + * @param {Array} ids + */ +export function batchRemoveMembers(ids) { + return http.post('/member/batchDelete', { ids }) +} + +/** Excel MIME */ +const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +/** + * 触发浏览器下载一个 Blob + */ +function downloadBlob(blob, filename) { + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) +} + +/** + * 调用导出接口并返回可直接下载的 Blob + * 兼容两种后端返回形式: + * 1. 二进制流(content-type: application/vnd...sheet 或 octet-stream) -> 直接作为 Blob + * 2. JSON 字节数组(number[]) -> 转为 Uint8Array 后包成 Blob + */ +async function fetchExportBlob(url, params) { + const res = await http.get(url, params, { raw: true, timeout: 60000 }) + if (!res || !res.ok) { + throw new Error(`导出失败${res?.status ? ` (${res.status})` : ''}`) + } + + const contentType = (res.headers.get('content-type') || '').toLowerCase() + + // 1. 二进制流直接作为 Excel 下载 + const isBinary = + contentType.includes('spreadsheet') || + contentType.includes('excel') || + contentType.includes('octet-stream') || + (contentType.startsWith('application/vnd') && !contentType.includes('json')) + if (isBinary) { + const blob = await res.blob() + // 统一指定 Excel MIME,避免调用方拿到后使用出问题 + return blob.type ? blob : new Blob([blob], { type: EXCEL_MIME }) + } + + // 2. JSON 响应:number[] 或 包装对象或 业务异常 + if (contentType.includes('application/json')) { + const payload = await res.json() + let bytesArray = null + if (Array.isArray(payload)) { + bytesArray = payload + } else if (payload && Array.isArray(payload.data)) { + bytesArray = payload.data + } + if (bytesArray && bytesArray.length > 0) { + const uint8 = new Uint8Array(bytesArray) + return new Blob([uint8], { type: EXCEL_MIME }) + } + const msg = payload?.message || payload?.msg || '导出失败:返回数据为空' + throw new Error(msg) + } + + // 3. 其余:兑底当二进制处理 + const blob = await res.blob() + if (blob.size === 0) { + throw new Error('导出失败:返回数据为空') + } + return blob.type ? blob : new Blob([blob], { type: EXCEL_MIME }) +} + +/** + * 导出单位/组织会员列表 + * 后端路径:GET /api/v1/crm/members/export/unit-or-org + */ +export async function exportUnitOrOrgMembers(params) { + const blob = await fetchExportBlob('/v1/crm/members/export/unit-or-org', params) + const filename = `单位会员列表_${new Date().toISOString().slice(0, 10)}.xlsx` + downloadBlob(blob, filename) +} + +/** + * 导出个人会员列表 + * 后端路径:GET /api/v1/crm/members/export/personal + */ +export async function exportPersonalMembers(params) { + const blob = await fetchExportBlob('/v1/crm/members/export/personal', params) + const filename = `个人会员列表_${new Date().toISOString().slice(0, 10)}.xlsx` + downloadBlob(blob, filename) +} diff --git a/src/api/request.js b/src/api/request.js new file mode 100644 index 0000000..bf1e80b --- /dev/null +++ b/src/api/request.js @@ -0,0 +1,214 @@ +import { message } from 'antd' + +/** + * 统一请求封装 + * 基于原生 fetch,支持: + * - baseURL(通过 vite 环境变量 VITE_API_BASE_URL 配置) + * - 自动注入 token + * - 统一响应解析(约定 { code, data, message }) + * - 统一错误提示 + * - 超时控制 + */ + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api' +const DEFAULT_TIMEOUT = 15000 + +const TOKEN_KEY = 'token' + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token) { + localStorage.setItem(TOKEN_KEY, token) +} + +export function removeToken() { + localStorage.removeItem(TOKEN_KEY) +} + +/** + * 拼接 query 参数 + */ +function buildQuery(params) { + if (!params || typeof params !== 'object') return '' + const usp = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return + if (Array.isArray(value)) { + value.forEach((v) => usp.append(key, v)) + } else { + usp.append(key, value) + } + }) + const str = usp.toString() + return str ? `?${str}` : '' +} + +/** + * fetch 超时封装 + */ +function fetchWithTimeout(url, options, timeout) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) + return fetch(url, { ...options, signal: controller.signal }).finally(() => + clearTimeout(timer) + ) +} + +/** + * 统一错误处理 + */ +function handleError(error) { + if (error?.name === 'AbortError') { + message.error('请求超时,请稍后重试') + } else if (error?.silent) { + // 静默错误,不弹提示 + } else { + message.error(error?.message || '网络异常') + } + return Promise.reject(error) +} + +/** + * 处理未授权 + */ +function handleUnauthorized() { + removeToken() + message.error('登录已失效,请重新登录') + // 避免循环跳转 + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } +} + +/** + * 核心请求方法 + * @param {string} url - 接口路径 + * @param {object} options - 配置项 + * @param {'GET'|'POST'|'PUT'|'DELETE'|'PATCH'} options.method + * @param {object} options.params - URL 参数 + * @param {object|FormData} options.data - 请求体 + * @param {object} options.headers - 自定义 headers + * @param {number} options.timeout - 超时时间(ms) + * @param {boolean} options.raw - 为 true 时返回完整响应不做业务解析 + * @param {boolean} options.silent - 为 true 时静默错误(不弹提示) + */ +export async function request(url, options = {}) { + const { + method = 'GET', + params, + data, + headers = {}, + timeout = DEFAULT_TIMEOUT, + raw = false, + silent = false, + } = options + + const token = getToken() + const finalHeaders = { + Accept: 'application/json', + ...headers, + } + if (token) { + finalHeaders.Authorization = `Bearer ${token}` + } + + let body + if (data !== undefined && data !== null) { + if (data instanceof FormData) { + body = data + // FormData 由浏览器自动设置 Content-Type + } else { + finalHeaders['Content-Type'] = + finalHeaders['Content-Type'] || 'application/json;charset=UTF-8' + body = JSON.stringify(data) + } + } + + const fullUrl = `${BASE_URL}${url}${buildQuery(params)}` + + try { + const res = await fetchWithTimeout( + fullUrl, + { + method, + headers: finalHeaders, + body, + credentials: 'include', + }, + timeout + ) + + // 401 未授权 + if (res.status === 401) { + handleUnauthorized() + return Promise.reject({ message: '未授权', silent: true }) + } + + if (raw) { + return res + } + + const contentType = res.headers.get('content-type') || '' + let payload + if (contentType.includes('application/json')) { + payload = await res.json() + } else { + payload = await res.text() + } + + if (!res.ok) { + const err = { + message: payload?.message || `请求失败 (${res.status})`, + status: res.status, + data: payload, + silent, + } + return handleError(err) + } + + // 业务约定:{ success, code, data, message } 或 { code, data, message } + // - success === true 表示成功 + // - 或 code === 0 / 200 表示成功 + if (payload && typeof payload === 'object' && ('success' in payload || 'code' in payload)) { + const isSuccess = + payload.success === true || + payload.code === 0 || + payload.code === 200 || + payload.code === '0' || + payload.code === '200' + if (isSuccess) { + return payload.data + } + return handleError({ + message: payload.message || payload.msg || '操作失败', + code: payload.code, + data: payload.data, + silent, + }) + } + + // 非约定结构直接返回 + return payload + } catch (error) { + if (error && error.silent === undefined) { + error.silent = silent + } + return handleError(error) + } +} + +/** + * 快捷方法 + */ +export const http = { + get: (url, params, options = {}) => request(url, { ...options, method: 'GET', params }), + post: (url, data, options = {}) => request(url, { ...options, method: 'POST', data }), + put: (url, data, options = {}) => request(url, { ...options, method: 'PUT', data }), + patch: (url, data, options = {}) => request(url, { ...options, method: 'PATCH', data }), + delete: (url, params, options = {}) => + request(url, { ...options, method: 'DELETE', params }), +} + +export default http diff --git a/src/components/MemberDetailModal/index.jsx b/src/components/MemberDetailModal/index.jsx index c53bf54..230ba2b 100644 --- a/src/components/MemberDetailModal/index.jsx +++ b/src/components/MemberDetailModal/index.jsx @@ -1,5 +1,40 @@ import { Modal, Descriptions, Tag, Divider } from 'antd' +/** 时间字段渲染:兼容 ISO/时间戳/字符串 */ +const formatDateTime = (v) => { + if (v === undefined || v === null || v === '') return '-' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return v + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} +const dash = (v) => (v === undefined || v === null || v === '' ? '-' : v) + +/** 过期状态识别:剩 1 个月或以内为快过期,已过过期时间为已过期 */ +const ONE_MONTH_MS = 30 * 24 * 60 * 60 * 1000 +const getExpireStatus = (v) => { + if (v === undefined || v === null || v === '') return 'unknown' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return 'unknown' + const diff = d.getTime() - Date.now() + if (diff <= 0) return 'expired' + if (diff <= ONE_MONTH_MS) return 'soon' + return 'normal' +} + +/** 详情弹窗过期时间渲染 */ +const renderExpireTime = (v) => { + const status = getExpireStatus(v) + const text = formatDateTime(v) + if (status === 'expired') { + return {text}(已过期) + } + if (status === 'soon') { + return {text}(即将过期) + } + return text +} + function MemberDetailModal({ open, member, onCancel }) { if (!member) return null @@ -40,6 +75,10 @@ function MemberDetailModal({ open, member, onCancel }) { {member.memberTypeLabel} {member.name} {member.phone} + {dash(member.invoiceTitle)} + {dash(member.invoiceTaxNo)} + {formatDateTime(member.registerTime)} + {renderExpireTime(member.expireTime)} {member.createTime} diff --git a/src/components/MemberFormModal/index.jsx b/src/components/MemberFormModal/index.jsx new file mode 100644 index 0000000..08860fb --- /dev/null +++ b/src/components/MemberFormModal/index.jsx @@ -0,0 +1,843 @@ +import { useEffect, useState } from 'react' +import { Modal, Form, Input, Select, Radio, Row, Col, Divider, DatePicker, message } from 'antd' +import dayjs from 'dayjs' +import { + GENDER_OPTIONS, + POLITICAL_STATUS_OPTIONS, + EDUCATION_LEVEL_OPTIONS, + TITLE_OPTIONS, + REGION_OR_UNIT_OPTIONS, + PERSONAL_MEMBER_TYPE_OPTIONS, + UNIT_OR_ORG_TYPE_OPTIONS, + GENDER_MAP, + POLITICAL_STATUS_MAP, + EDUCATION_LEVEL_MAP, + TITLE_MAP, + REGION_OR_UNIT_MAP, + PERSONAL_MEMBER_TYPE_MAP, + UNIT_OR_ORG_TYPE_MAP, + createPersonalMember, + createUnitOrOrgMember, +} from '../../api/member' + +const { Option } = Select + +/** 个人会员类型枚举常量(与 H5 注册表单一致) */ +const PERSONAL_MEMBER_TYPE = { + SPECIAL_COMMITTEE_MEMBER: 'SPECIAL_COMMITTEE_MEMBER', + ORDINARY_MEMBER: 'ORDINARY_MEMBER', + REGIONAL_ASSOCIATION_LEADER: 'REGIONAL_ASSOCIATION_LEADER', +} + +/** 专科分会选项(对应 H5 PersonalRegister 中的 branchCommittee) */ +const BRANCH_COMMITTEE_OPTIONS = [ + '内科分会', + '外科分会', + '针灸分会', + '推拿分会', +] + +/** 地区选项(对应 H5 PersonalRegister 中的 cityAssociation) */ +const CITY_ASSOCIATION_OPTIONS = [ + '杭州市', '宁波市', '湖州市', '嘉兴市', '绍兴市', '舟山市', + '温州市', '台州市', '丽水市', '金华市', '衢州市', +] + +/** 随机后缀,避免重复提交冲突 */ +const rand = () => Math.floor(Math.random() * 10000) + +/** 个人会员 mock 预填数据 */ +const getPersonalMockData = () => { + const suffix = rand() + return { + memberCategory: 'personal', + name: `测试会员${suffix}`, + phone: `138${String(10000000 + suffix).slice(-8)}`, + password: 'Test@123456', + email: `member${suffix}@example.com`, + zipCode: '321000', + gender: 'MALE', + identityCard: '330702199001011234', + politicalStatus: 'CPC_MEMBER', + educationLevel: 'MASTER', + title: 'CHIEF_PHYSICIAN', + position: '科室负责人', + regionOrUnit: 'JINHUA_TCM_HOSPITAL', + unitName: '金华市中医医院', + unitAddress: '金华市婺城区中医药路88号', + memberType: 'SPECIAL_COMMITTEE_MEMBER', + specialCommitteeMemberType: '内科分会', + cityAssociation: '金华市', + registerTime: dayjs(), + } +} + +/** 单位/组织会员 mock 预填数据 */ +const getUnitMockData = () => { + const suffix = rand() + return { + memberCategory: 'unit', + name: `测试企业${suffix}有限公司`, + phone: '0579-88886666', + password: 'Test@123456', + unitOrOrgType: 'PUBLIC_INSTITUTION', + address: '金华市金东区发展路168号', + zipCode: '321000', + legalPersonName: '法人测试', + legalPersonPhone: `139${String(10000000 + suffix).slice(-8)}`, + legalPersonEmail: `legal${suffix}@example.com`, + referrerName: '推荐人测试', + referrerPhone: `137${String(10000000 + suffix).slice(-8)}`, + referrerEmail: `referrer${suffix}@example.com`, + registerTime: dayjs(), + } +} + +function MemberFormModal({ open, onCancel, onOk }) { + const [form] = Form.useForm() + const memberCategory = Form.useWatch('memberCategory', form) + const [personalMemberType, setPersonalMemberType] = useState( + PERSONAL_MEMBER_TYPE.SPECIAL_COMMITTEE_MEMBER, + ) + + useEffect(() => { + if (open) { + form.resetFields() + // 默认以个人会员 mock 预填 + const mock = getPersonalMockData() + form.setFieldsValue(mock) + setPersonalMemberType(mock.memberType) + } + }, [open, form]) + + // 会员类别切换时自动填充对应的 mock 数据 + useEffect(() => { + if (!open || !memberCategory) return + const current = form.getFieldsValue() + if (memberCategory === 'unit' && !current.unitOrOrgType) { + form.resetFields() + form.setFieldsValue(getUnitMockData()) + } else if (memberCategory === 'personal' && !current.gender) { + form.resetFields() + const mock = getPersonalMockData() + form.setFieldsValue(mock) + setPersonalMemberType(mock.memberType) + } + }, [memberCategory, open, form]) + + const handleMemberTypeChange = (value) => { + setPersonalMemberType(value) + if (value !== PERSONAL_MEMBER_TYPE.SPECIAL_COMMITTEE_MEMBER) { + form.setFieldValue('specialCommitteeMemberType', undefined) + } + if (value !== PERSONAL_MEMBER_TYPE.REGIONAL_ASSOCIATION_LEADER) { + form.setFieldValue('cityAssociation', undefined) + } + } + + const [submitting, setSubmitting] = useState(false) + + /** 按协议拼装个人会员接口入参,剩下未填字段会被过滤 */ + const buildPersonalMemberPayload = (values) => { + const registerDay = values.registerTime ? dayjs(values.registerTime) : null + // 个人会员有效期 5 年 + const expireDay = registerDay ? registerDay.add(5, 'year') : null + const payload = { + name: values.name, + phone: values.phone, + password: values.password, + email: values.email, + zipCode: values.zipCode, + gender: values.gender, + identityCard: values.identityCard, + politicalStatus: values.politicalStatus, + educationLevel: values.educationLevel, + title: values.title, + position: values.position, + regionOrUnit: values.regionOrUnit, + unitName: values.unitName, + unitAddress: values.unitAddress, + memberType: values.memberType, + specialCommitteeMemberType: values.specialCommitteeMemberType, + registerTime: registerDay ? registerDay.format('YYYY-MM-DD HH:mm:ss') : undefined, + expireTime: expireDay ? expireDay.format('YYYY-MM-DD HH:mm:ss') : undefined, + } + // 过滤 undefined/null/空字符串 + Object.keys(payload).forEach((k) => { + if (payload[k] === undefined || payload[k] === null || payload[k] === '') { + delete payload[k] + } + }) + return payload + } + + /** 按协议拼装单位/组织会员接口入参 */ + const buildUnitOrOrgMemberPayload = (values) => { + const registerDay = values.registerTime ? dayjs(values.registerTime) : null + // 单位会员有效期 1 年 + const expireDay = registerDay ? registerDay.add(1, 'year') : null + const payload = { + name: values.name, + phone: values.phone, + unitOrOrgType: values.unitOrOrgType, + address: values.address, + zipCode: values.zipCode, + legalPersonName: values.legalPersonName, + legalPersonPhone: values.legalPersonPhone, + legalPersonEmail: values.legalPersonEmail, + referrerName: values.referrerName, + referrerPhone: values.referrerPhone, + referrerEmail: values.referrerEmail, + password: values.password, + registerTime: registerDay ? registerDay.format('YYYY-MM-DD HH:mm:ss') : undefined, + expireTime: expireDay ? expireDay.format('YYYY-MM-DD HH:mm:ss') : undefined, + } + Object.keys(payload).forEach((k) => { + if (payload[k] === undefined || payload[k] === null || payload[k] === '') { + delete payload[k] + } + }) + return payload + } + + const handleOk = async () => { + try { + const values = await form.validateFields() + const isPersonal = values.memberCategory === 'personal' + + const base = { + id: Date.now(), + memberCategory: values.memberCategory, + memberCategoryLabel: isPersonal ? '个人会员' : '单位/组织会员', + createTime: new Date().toISOString().slice(0, 10), + } + + let newMember + if (isPersonal) { + // 个人会员 - 调用创建接口 + const payload = buildPersonalMemberPayload(values) + setSubmitting(true) + try { + await createPersonalMember(payload) + } catch { + setSubmitting(false) + return + } + setSubmitting(false) + + // 本地列表兑现用(后端不返回实体,重新拉列表即可) + newMember = { + ...base, + ...payload, + genderLabel: GENDER_MAP[payload.gender], + politicalStatusLabel: POLITICAL_STATUS_MAP[payload.politicalStatus], + educationLevelLabel: EDUCATION_LEVEL_MAP[payload.educationLevel], + titleLabel: TITLE_MAP[payload.title], + regionOrUnitLabel: REGION_OR_UNIT_MAP[payload.regionOrUnit], + memberTypeLabel: PERSONAL_MEMBER_TYPE_MAP[payload.memberType], + // UI 辅助字段 + cityAssociation: values.cityAssociation, + } + } else { + // 单位/组织会员 - 调用创建接口 + const payload = buildUnitOrOrgMemberPayload(values) + setSubmitting(true) + try { + await createUnitOrOrgMember(payload) + } catch { + setSubmitting(false) + return + } + setSubmitting(false) + + // 本地列表兑现用 + newMember = { + ...base, + ...payload, + unitOrOrgTypeLabel: UNIT_OR_ORG_TYPE_MAP[payload.unitOrOrgType], + // 兼容列表/详情已有字段 + unitName: payload.name, + unitAddress: payload.address, + unitType: payload.unitOrOrgType, + unitTypeLabel: UNIT_OR_ORG_TYPE_MAP[payload.unitOrOrgType], + legalName: payload.legalPersonName, + legalPhone: payload.legalPersonPhone, + legalEmail: payload.legalPersonEmail, + referrer: payload.referrerName, + } + } + + onOk?.(newMember) + message.success('新增会员成功') + form.resetFields() + } catch { + // 校验失败或接口错误不处理 + } + } + + const isPersonal = memberCategory === 'personal' + + return ( + 新增会员} + open={open} + onCancel={onCancel} + onOk={handleOk} + confirmLoading={submitting} + okText="确定" + cancelText="取消" + width={780} + destroyOnHidden + styles={{ + body: { maxHeight: '72vh', overflow: 'auto', padding: '20px 24px' }, + }} + style={{ top: 40 }} + > +
+ + 会员类别 + + + + + + + 个人会员 + 单位/组织会员 + + + + + + {isPersonal ? ( + <> + {/* 基本信息 */} + + 基本信息 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 会员类型 */} + + 会员类型 + + + + + + + + + {/* 专科分会委员 - 条件显示,对应协议 specialCommitteeMemberType */} + {personalMemberType === PERSONAL_MEMBER_TYPE.SPECIAL_COMMITTEE_MEMBER && ( + + + + + + )} + + {/* 地区中医药学(协)会会长、秘书长 - 条件显示 */} + {personalMemberType === PERSONAL_MEMBER_TYPE.REGIONAL_ASSOCIATION_LEADER && ( + + + + + + )} + + + {/* 设置密码 */} + + 设置密码 + + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的密码不一致')) + }, + }), + ]} + > + + + + + + ) : ( + <> + {/* 基本信息 */} + + 基本信息 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 法人代表信息 */} + + 法人代表信息 + + + + + + + + + + + + + + + + + + + + {/* 推荐人信息 */} + + 推荐人信息 + + + + + + + + + + + + + + + + + + + + {/* 设置密码 */} + + 设置密码 + + + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的密码不一致')) + }, + }), + ]} + > + + + + + + )} +
+
+ ) +} + +export default MemberFormModal diff --git a/src/pages/Layout/index.jsx b/src/pages/Layout/index.jsx index 848d86c..cf1a81f 100644 --- a/src/pages/Layout/index.jsx +++ b/src/pages/Layout/index.jsx @@ -1,8 +1,6 @@ import { Outlet, useNavigate } from 'react-router-dom' import { Layout as AntLayout, Menu, Button, message, Avatar, Space } from 'antd' -import { TeamOutlined, LogoutOutlined, FileExcelOutlined, MedicineBoxFilled, UserOutlined } from '@ant-design/icons' -import { exportMembersToExcel } from '../../utils/exportExcel' -import { mockMembers } from '../../data/mockData' +import { TeamOutlined, LogoutOutlined, MedicineBoxFilled, UserOutlined } from '@ant-design/icons' const { Header, Sider, Content } = AntLayout @@ -15,11 +13,6 @@ function LayoutPage() { navigate('/login') } - const handleExport = () => { - exportMembersToExcel(mockMembers) - message.success('导出成功') - } - return ( - } style={{ backgroundColor: '#1890ff' }} /> 管理员 diff --git a/src/pages/Login/index.jsx b/src/pages/Login/index.jsx index f95cacf..8b3d142 100644 --- a/src/pages/Login/index.jsx +++ b/src/pages/Login/index.jsx @@ -2,23 +2,43 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { Form, Input, Button, Card, message } from 'antd' import { UserOutlined, LockOutlined, MedicineBoxFilled } from '@ant-design/icons' +import { login } from '../../api/auth' +import { setToken } from '../../api/request' function Login() { const [loading, setLoading] = useState(false) const navigate = useNavigate() - const onFinish = (values) => { + const onFinish = async (values) => { setLoading(true) - setTimeout(() => { - if (values.username === 'admin' && values.password === 'admin123') { - localStorage.setItem('token', 'admin') - message.success('登录成功') - navigate('/') - } else { - message.error('用户名或密码错误') + try { + const res = await login({ + username: values.username, + password: values.password, + }) + // request.js 已解析 success/code,返回的 res 即 payload.data + // 后端约定:data.accessToken 为需要放入请求头的 token + const token = + res?.accessToken || + res?.token || + res?.data?.accessToken || + res?.data?.token || + (typeof res === 'string' ? res : null) + + if (!token) { + message.error('登录响应中未获取到 accessToken') + return } + setToken(token) + message.success('登录成功') + // 登录成功后跳转到会员列表页,使用 replace 避免返回登录页 + navigate('/members', { replace: true }) + } catch (err) { + // request.js 已统一提示错误,这里仅需要兑现 catch + console.warn('login failed:', err) + } finally { setLoading(false) - }, 500) + } } return ( @@ -109,7 +129,7 @@ function Login() { name="login" onFinish={onFinish} autoComplete="off" - initialValues={{ username: 'admin', password: 'admin123' }} + initialValues={{ username: 'crm_admin', password: 'ChangeMe@123' }} style={{ marginTop: 16 }} > (value) => map[value] || value || '-' +const dash = (v) => (v === undefined || v === null || v === '' ? '-' : v) + +/** 时间字段渲染:支持 ISO/时间戳/字符串,超出范围返回 '-' */ +const formatDateTime = (v) => { + if (v === undefined || v === null || v === '') return '-' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return v + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +/** 过期状态识别:剩 1 个月或以内为快过期,已过过期时间为已过期 */ +const ONE_MONTH_MS = 30 * 24 * 60 * 60 * 1000 +const getExpireStatus = (v) => { + if (v === undefined || v === null || v === '') return 'unknown' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return 'unknown' + const diff = d.getTime() - Date.now() + if (diff <= 0) return 'expired' + if (diff <= ONE_MONTH_MS) return 'soon' + return 'normal' +} + +/** 过期时间列渲染:剩 1 个月以内 / 已过期 高亮显示 */ +const renderExpireTime = (v) => { + const status = getExpireStatus(v) + const text = formatDateTime(v) + if (status === 'expired') { + return {text}(已过期) + } + if (status === 'soon') { + return {text}(即将过期) + } + return text +} + +/** UnitOrOrgMemberVO → 前端结构 */ +function adaptUnitOrOrgMember(item) { + return { + ...item, + memberCategory: 'unit', + memberCategoryLabel: '单位/组织会员', + unitType: item.unitOrOrgType, + unitTypeLabel: UNIT_OR_ORG_TYPE_MAP[item.unitOrOrgType] || item.unitOrOrgType || '-', + unitName: item.name, + unitAddress: item.address, + legalName: item.legalPersonName, + legalPhone: item.legalPersonPhone, + legalEmail: item.legalPersonEmail, + referrer: item.referrerName, + } +} + +/** PersonalMemberVO → 前端结构(保留原字段 + 弹窗别名) */ +function adaptPersonalMember(item) { + return { + ...item, + memberCategory: 'personal', + memberCategoryLabel: '个人会员', + memberTypeLabel: PERSONAL_MEMBER_TYPE_MAP[item.memberType] || item.memberType || '-', + genderLabel: GENDER_MAP[item.gender] || item.gender || '-', + politicalStatusLabel: POLITICAL_STATUS_MAP[item.politicalStatus] || item.politicalStatus || '-', + educationLevelLabel: EDUCATION_LEVEL_MAP[item.educationLevel] || item.educationLevel || '-', + regionOrUnitLabel: REGION_OR_UNIT_MAP[item.regionOrUnit] || item.regionOrUnit || '-', + // 兼容详情弹窗已有字段 + idCard: item.identityCard, + politicalStatus: item.politicalStatus, + education: EDUCATION_LEVEL_MAP[item.educationLevel] || item.educationLevel, + region: item.regionOrUnit, + regionLabel: REGION_OR_UNIT_MAP[item.regionOrUnit] || item.regionOrUnit, + } +} + function MemberList() { + const [activeTab, setActiveTab] = useState(MEMBER_CATEGORY.UNIT) + const [filters, setFilters] = useState({ name: '', - phone: '', + unitOrOrgType: undefined, memberType: undefined, }) const [appliedFilters, setAppliedFilters] = useState({}) - const [pagination, setPagination] = useState({ - current: 1, - pageSize: 10, - }) + const [pagination, setPagination] = useState({ current: 1, pageSize: 10 }) + const [loading, setLoading] = useState(false) + const [tableData, setTableData] = useState([]) + const [total, setTotal] = useState(0) + const [modalVisible, setModalVisible] = useState(false) const [selectedMember, setSelectedMember] = useState(null) + const [formModalVisible, setFormModalVisible] = useState(false) - const filteredData = useMemo(() => { - return mockMembers.filter((item) => { - const matchName = !appliedFilters.name || item.name.includes(appliedFilters.name) - const matchPhone = !appliedFilters.phone || item.phone.includes(appliedFilters.phone) - const matchType = !appliedFilters.memberType || item.memberType === appliedFilters.memberType - return matchName && matchPhone && matchType - }) - }, [appliedFilters]) + /** 加载列表(按 tab 调用不同接口) */ + const loadList = useCallback(async () => { + setLoading(true) + try { + const params = { + current: pagination.current, + size: pagination.pageSize, + } + if (appliedFilters.name) params.name = appliedFilters.name - const paginatedData = useMemo(() => { - const start = (pagination.current - 1) * pagination.pageSize - const end = start + pagination.pageSize - return filteredData.slice(start, end) - }, [filteredData, pagination]) + const fetcher = + activeTab === MEMBER_CATEGORY.UNIT + ? fetchUnitOrOrgMemberPage + : fetchPersonalMemberPage + + const res = await fetcher(params) + const records = + res?.records || res?.data?.records || (Array.isArray(res) ? res : []) + const totalCount = res?.total ?? res?.data?.total ?? records.length + + let list = + activeTab === MEMBER_CATEGORY.UNIT + ? records.map(adaptUnitOrOrgMember) + : records.map(adaptPersonalMember) + + // 客户端二次过滤(接口暂未提供该参数) + if (activeTab === MEMBER_CATEGORY.UNIT && appliedFilters.unitOrOrgType) { + list = list.filter((it) => it.unitOrOrgType === appliedFilters.unitOrOrgType) + } + if (activeTab === MEMBER_CATEGORY.PERSONAL && appliedFilters.memberType) { + list = list.filter((it) => it.memberType === appliedFilters.memberType) + } + + setTableData(list) + setTotal(totalCount) + } catch (err) { + console.warn('fetch member page failed:', err) + setTableData([]) + setTotal(0) + } finally { + setLoading(false) + } + }, [pagination.current, pagination.pageSize, appliedFilters, activeTab]) + + useEffect(() => { + loadList() + }, [loadList]) const handleFilterChange = (key, value) => { setFilters((prev) => ({ ...prev, [key]: value })) } - const handleSearch = () => { setAppliedFilters(filters) setPagination((prev) => ({ ...prev, current: 1 })) } - const handleReset = () => { - const empty = { name: '', phone: '', memberType: undefined } + const empty = { name: '', unitOrOrgType: undefined, memberType: undefined } setFilters(empty) setAppliedFilters(empty) setPagination((prev) => ({ ...prev, current: 1 })) } - + const handleTabChange = (key) => { + setActiveTab(key) + setFilters({ name: '', unitOrOrgType: undefined, memberType: undefined }) + setAppliedFilters({}) + setPagination({ current: 1, pageSize: 10 }) + } const handleTableChange = (newPagination) => { setPagination({ current: newPagination.current, pageSize: newPagination.pageSize, }) } - const handleViewDetail = (record) => { setSelectedMember(record) setModalVisible(true) } + const handleAddMember = () => { + setFormModalVisible(false) + setPagination((prev) => ({ ...prev, current: 1 })) + loadList() + } - const columns = [ + const [exporting, setExporting] = useState(false) + /** 导出 Excel(根据当前 Tab 调用不同接口) */ + const handleExport = async () => { + setExporting(true) + try { + if (activeTab === MEMBER_CATEGORY.UNIT) { + await exportUnitOrOrgMembers() + } else { + await exportPersonalMembers() + } + message.success('导出成功') + } catch (err) { + message.error(err?.message || '导出失败') + } finally { + setExporting(false) + } + } + + /** 单位会员列 —— 对齐协议 UnitOrOrgMemberVO + 新增会员表单字段 */ + const unitColumns = [ { - title: '序号', - key: 'index', - width: 70, - align: 'center', + title: '序号', key: 'index', width: 60, align: 'center', fixed: 'left', render: (_, __, index) => (pagination.current - 1) * pagination.pageSize + index + 1, }, + { title: 'ID', dataIndex: 'id', key: 'id', width: 80, fixed: 'left' }, + { title: '单位名称', dataIndex: 'name', key: 'name', width: 200, fixed: 'left', ellipsis: true }, + { title: '联系电话', dataIndex: 'phone', key: 'phone', width: 130 }, { - title: '姓名', - dataIndex: 'name', - key: 'name', - width: 100, + title: '单位类型', dataIndex: 'unitOrOrgType', key: 'unitOrOrgType', width: 170, + render: (value) => ( + + {UNIT_OR_ORG_TYPE_MAP[value] || value || '-'} + + ), }, + { title: '通讯地址', dataIndex: 'address', key: 'address', width: 220, ellipsis: true, render: dash }, + { title: '邮编', dataIndex: 'zipCode', key: 'zipCode', width: 90, render: dash }, + { title: '法人姓名', dataIndex: 'legalPersonName', key: 'legalPersonName', width: 120, render: dash }, + { title: '法人电话', dataIndex: 'legalPersonPhone', key: 'legalPersonPhone', width: 140, render: dash }, + { title: '法人邮箱', dataIndex: 'legalPersonEmail', key: 'legalPersonEmail', width: 200, ellipsis: true, render: dash }, + { title: '推荐人', dataIndex: 'referrerName', key: 'referrerName', width: 110, render: dash }, + { title: '推荐人电话', dataIndex: 'referrerPhone', key: 'referrerPhone', width: 140, render: dash }, + { title: '推荐人邮箱', dataIndex: 'referrerEmail', key: 'referrerEmail', width: 200, ellipsis: true, render: dash }, { - title: '手机号', - dataIndex: 'phone', - key: 'phone', - width: 140, + title: '发票抬头', dataIndex: 'invoiceTitle', key: 'invoiceTitle', width: 200, ellipsis: true, + render: (v) => (v ? {v} : '-'), }, + { title: '纳税人识别号', dataIndex: 'invoiceTaxNo', key: 'invoiceTaxNo', width: 200, ellipsis: true, render: dash }, { - title: '会员类别', - dataIndex: 'memberCategoryLabel', - key: 'memberCategoryLabel', - width: 100, - align: 'center', - render: (text, record) => ( - - {text} + title: '是否支付', dataIndex: 'isPayed', key: 'isPayed', width: 100, align: 'center', + render: (v) => ( + + {v ? '已支付' : '未支付'} ), }, { - title: '会员类型', - dataIndex: 'memberTypeLabel', - key: 'memberTypeLabel', - width: 220, + title: '是否激活', dataIndex: 'isActived', key: 'isActived', width: 100, align: 'center', + render: (v) => ( + + {v ? '已激活' : '未激活'} + + ), }, + { title: '注册时间', dataIndex: 'registerTime', key: 'registerTime', width: 180, render: formatDateTime }, + { title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', width: 220, render: renderExpireTime }, { - title: '单位名称', - dataIndex: 'unitName', - key: 'unitName', - ellipsis: true, - }, - { - title: '创建时间', - dataIndex: 'createTime', - key: 'createTime', - width: 120, - align: 'center', - }, - { - title: '操作', - key: 'action', - width: 100, - align: 'center', - fixed: 'right', + title: '操作', key: 'action', width: 90, align: 'center', fixed: 'right', render: (_, record) => ( - ), }, ] + /** 个人会员列 —— 对齐协议 PersonalMemberVO */ + const personalColumns = [ + { + title: '序号', key: 'index', width: 60, align: 'center', fixed: 'left', + render: (_, __, index) => (pagination.current - 1) * pagination.pageSize + index + 1, + }, + { title: 'ID', dataIndex: 'id', key: 'id', width: 80, fixed: 'left' }, + { title: '姓名', dataIndex: 'name', key: 'name', width: 100, fixed: 'left' }, + { + title: '性别', dataIndex: 'gender', key: 'gender', width: 70, align: 'center', + render: renderEnum(GENDER_MAP), + }, + { title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, + { title: '邮箱', dataIndex: 'email', key: 'email', width: 200, ellipsis: true, render: dash }, + { title: '身份证号', dataIndex: 'identityCard', key: 'identityCard', width: 190, ellipsis: true, render: dash }, + { + title: '会员类型', dataIndex: 'memberType', key: 'memberType', width: 200, + render: (v) => ( + + {PERSONAL_MEMBER_TYPE_MAP[v] || v || '-'} + + ), + }, + { + title: '政治面貌', dataIndex: 'politicalStatus', key: 'politicalStatus', width: 130, + render: renderEnum(POLITICAL_STATUS_MAP), + }, + { + title: '学历', dataIndex: 'educationLevel', key: 'educationLevel', width: 120, + render: renderEnum(EDUCATION_LEVEL_MAP), + }, + { title: '职称', dataIndex: 'title', key: 'title', width: 120, render: dash }, + { title: '职务', dataIndex: 'position', key: 'position', width: 120, render: dash }, + { + title: '地区/单位', dataIndex: 'regionOrUnit', key: 'regionOrUnit', width: 160, + render: renderEnum(REGION_OR_UNIT_MAP), + }, + { + title: '单位名称', dataIndex: 'unitName', key: 'unitName', width: 200, ellipsis: true, + render: (v) => (v ? {v} : '-'), + }, + { + title: '单位地址', dataIndex: 'unitAddress', key: 'unitAddress', width: 220, ellipsis: true, + render: (v) => (v ? {v} : '-'), + }, + { title: '邮编', dataIndex: 'zipCode', key: 'zipCode', width: 90, render: dash }, + { + title: '发票抬头', dataIndex: 'invoiceTitle', key: 'invoiceTitle', width: 200, ellipsis: true, + render: (v) => (v ? {v} : '-'), + }, + { title: '纳税人识别号', dataIndex: 'invoiceTaxNo', key: 'invoiceTaxNo', width: 200, ellipsis: true, render: dash }, + { + title: '是否支付', dataIndex: 'isPayed', key: 'isPayed', width: 100, align: 'center', + render: (v) => ( + + {v ? '已支付' : '未支付'} + + ), + }, + { + title: '是否激活', dataIndex: 'isActived', key: 'isActived', width: 100, align: 'center', + render: (v) => ( + + {v ? '已激活' : '未激活'} + + ), + }, + { title: '注册时间', dataIndex: 'registerTime', key: 'registerTime', width: 180, render: formatDateTime }, + { title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', width: 220, render: renderExpireTime }, + { + title: '操作', key: 'action', width: 90, align: 'center', fixed: 'right', + render: (_, record) => ( + + ), + }, + ] + + const isUnitTab = activeTab === MEMBER_CATEGORY.UNIT + const columns = isUnitTab ? unitColumns : personalColumns + const scrollX = isUnitTab ? 3000 : 3200 + return (
@@ -154,54 +377,51 @@ function MemberList() { 筛选条件
- + handleFilterChange('name', e.target.value)} + onPressEnter={handleSearch} allowClear - style={{ width: 170, borderRadius: 6 }} + style={{ width: 220, borderRadius: 6 }} /> - - handleFilterChange('phone', e.target.value)} - allowClear - style={{ width: 190, borderRadius: 6 }} - /> - - - - + {isUnitTab ? ( + + + + ) : ( + + + + )} - - @@ -211,33 +431,58 @@ function MemberList() { - 会员列表 - - 共 {filteredData.length} 条数据 - -
- } + styles={{ body: { paddingTop: 0 } }} > + + + + + 共 {total} 条数据 + +
+ } + items={[ + { key: MEMBER_CATEGORY.UNIT, label: '单位会员' }, + { key: MEMBER_CATEGORY.PERSONAL, label: '个人会员' }, + ]} + /> + `共 ${total} 条`, + showTotal: (t) => `共 ${t} 条`, pageSizeOptions: [10, 20, 50, 100], }} onChange={handleTableChange} - scroll={{ x: 900 }} + scroll={{ x: scrollX }} size="middle" /> @@ -247,6 +492,12 @@ function MemberList() { member={selectedMember} onCancel={() => setModalVisible(false)} /> + + setFormModalVisible(false)} + onOk={handleAddMember} + /> ) } diff --git a/vite.config.js b/vite.config.js index 8b0f57b..a7c067d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,36 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' +import path from 'node:path' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + // YApi Mock 地址,在 .env.development / .env.development.local 中配置 VITE_YAPI_MOCK + // 例:https://yapi.xxx.com/mock/123 + const yapiMock = 'http://124.222.159.10:8090' + + return { + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + // 前端所有 /api 开头的请求都转发到 YApi Mock + // YApi 中接口路径本身带 /api 前缀,无需 rewrite + '/api': { + target: yapiMock, + changeOrigin: true, + secure: false, + // 如后续需去掉 /api 前缀,取消下面这行注释即可 + // rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, + } })