接口调试

This commit is contained in:
huyunkun 2026-05-24 14:38:10 +08:00
parent 87d4969c40
commit 76d12d5284
14 changed files with 1916 additions and 173 deletions

11
.env.development Normal file
View File

@ -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

3
.env.production Normal file
View File

@ -0,0 +1,3 @@
# 生产环境
# 部署后实际接口地址,按需修改
VITE_API_BASE_URL=/api

8
.gitignore vendored
View File

@ -1 +1,9 @@
node_modules node_modules
# 环境变量本地覆盖文件(存放个人的 YApi 地址等,不提交)
.env.local
.env.*.local
# 构建产物
dist
.vite

View File

@ -2,10 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import Login from './pages/Login' import Login from './pages/Login'
import Layout from './pages/Layout' import Layout from './pages/Layout'
import MemberList from './pages/MemberList' import MemberList from './pages/MemberList'
import { getToken } from './api/request'
function App() { function App() {
const isLoggedIn = () => { const isLoggedIn = () => {
return localStorage.getItem('token') === 'admin' return !!getToken()
} }
return ( return (
@ -15,7 +16,8 @@ function App() {
path="/" path="/"
element={isLoggedIn() ? <Layout /> : <Navigate to="/login" />} element={isLoggedIn() ? <Layout /> : <Navigate to="/login" />}
> >
<Route index element={<MemberList />} /> <Route index element={<Navigate to="/members" replace />} />
<Route path="members" element={<MemberList />} />
</Route> </Route>
</Routes> </Routes>
) )

30
src/api/auth.js Normal file
View File

@ -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')
}

16
src/api/index.js Normal file
View File

@ -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'

296
src/api/member.js Normal file
View File

@ -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<boolean>}
*/
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<boolean>}
*/
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<string|number>} 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)
}

214
src/api/request.js Normal file
View File

@ -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

View File

@ -1,5 +1,40 @@
import { Modal, Descriptions, Tag, Divider } from 'antd' 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 <Tag color="error" style={{ borderRadius: 4, fontWeight: 500 }}>{text}已过期</Tag>
}
if (status === 'soon') {
return <Tag color="warning" style={{ borderRadius: 4, fontWeight: 500 }}>{text}即将过期</Tag>
}
return text
}
function MemberDetailModal({ open, member, onCancel }) { function MemberDetailModal({ open, member, onCancel }) {
if (!member) return null if (!member) return null
@ -40,6 +75,10 @@ function MemberDetailModal({ open, member, onCancel }) {
<Descriptions.Item label="会员类型">{member.memberTypeLabel}</Descriptions.Item> <Descriptions.Item label="会员类型">{member.memberTypeLabel}</Descriptions.Item>
<Descriptions.Item label="姓名">{member.name}</Descriptions.Item> <Descriptions.Item label="姓名">{member.name}</Descriptions.Item>
<Descriptions.Item label="手机号">{member.phone}</Descriptions.Item> <Descriptions.Item label="手机号">{member.phone}</Descriptions.Item>
<Descriptions.Item label="发票抬头">{dash(member.invoiceTitle)}</Descriptions.Item>
<Descriptions.Item label="纳税人识别号">{dash(member.invoiceTaxNo)}</Descriptions.Item>
<Descriptions.Item label="注册时间">{formatDateTime(member.registerTime)}</Descriptions.Item>
<Descriptions.Item label="过期时间">{renderExpireTime(member.expireTime)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{member.createTime}</Descriptions.Item> <Descriptions.Item label="创建时间">{member.createTime}</Descriptions.Item>
</Descriptions> </Descriptions>

View File

@ -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 (
<Modal
title={<span style={{ fontWeight: 600, fontSize: 17 }}>新增会员</span>}
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 }}
>
<Form
form={form}
layout="vertical"
initialValues={{
memberCategory: 'personal',
memberType: PERSONAL_MEMBER_TYPE.SPECIAL_COMMITTEE_MEMBER,
}}
>
<Divider orientation="left" style={{ marginTop: 0 }}>
<span style={{ fontWeight: 600, color: '#1890ff' }}>会员类别</span>
</Divider>
<Row gutter={16}>
<Col span={24}>
<Form.Item
label="会员类别"
name="memberCategory"
rules={[{ required: true, message: '请选择会员类别' }]}
>
<Radio.Group>
<Radio value="personal">个人会员</Radio>
<Radio value="unit">单位/组织会员</Radio>
</Radio.Group>
</Form.Item>
</Col>
</Row>
{isPersonal ? (
<>
{/* 基本信息 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>基本信息</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="姓名"
name="name"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input placeholder="请输入姓名" allowClear />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="手机号"
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input placeholder="请输入手机号" allowClear maxLength={11} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="性别"
name="gender"
rules={[{ required: true, message: '请选择性别' }]}
>
<Select placeholder="请选择性别" allowClear>
{GENDER_OPTIONS.map((g) => (
<Option key={g.value} value={g.value}>{g.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="身份证"
name="identityCard"
rules={[
{ required: true, message: '请输入身份证号' },
{
pattern:
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
message: '请输入正确的 18 位身份证号',
},
]}
>
<Input placeholder="请输入身份证号" allowClear maxLength={18} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="政治面貌"
name="politicalStatus"
rules={[{ required: true, message: '请选择政治面貌' }]}
>
<Select
placeholder="请选择政治面貌"
allowClear
showSearch
optionFilterProp="children"
>
{POLITICAL_STATUS_OPTIONS.map((p) => (
<Option key={p.value} value={p.value}>{p.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="学历"
name="educationLevel"
rules={[{ required: true, message: '请选择学历' }]}
>
<Select placeholder="请选择学历" allowClear>
{EDUCATION_LEVEL_OPTIONS.map((e) => (
<Option key={e.value} value={e.value}>{e.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="职称"
name="title"
>
<Select
placeholder="请选择职称"
allowClear
showSearch
optionFilterProp="children"
>
{TITLE_OPTIONS.map((t) => (
<Option key={t.value} value={t.value}>{t.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="职务"
name="position"
rules={[{ max: 50, message: '职务最长 50 个字符' }]}
>
<Input placeholder="请输入职务" allowClear maxLength={50} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="所属地区/单位"
name="regionOrUnit"
rules={[{ required: true, message: '请选择所属地区/单位' }]}
>
<Select
placeholder="请选择所属地区/单位"
allowClear
showSearch
optionFilterProp="children"
>
{REGION_OR_UNIT_OPTIONS.map((r) => (
<Option key={r.value} value={r.value}>{r.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="单位名称"
name="unitName"
rules={[
{ required: true, message: '请输入单位名称' },
{ max: 100, message: '单位名称最长 100 个字符' },
]}
>
<Input placeholder="请输入单位名称" allowClear maxLength={100} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="单位地址"
name="unitAddress"
rules={[
{ required: true, message: '请输入单位地址' },
{ max: 200, message: '单位地址最长 200 个字符' },
]}
>
<Input placeholder="请输入单位地址" allowClear maxLength={200} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入正确的邮箱地址' },
{ max: 50, message: '邮箱最长 50 个字符' },
]}
>
<Input placeholder="请输入邮箱" allowClear maxLength={50} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="邮编"
name="zipCode"
rules={[{ pattern: /^\d{6}$/, message: '请输入正确的 6 位邮编' }]}
>
<Input placeholder="请输入邮编" allowClear maxLength={6} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="注册时间"
name="registerTime"
rules={[{ required: true, message: '请选择注册时间' }]}
extra="个人会员有效期 5 年,过期时间将自动计算"
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择注册时间"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
{/* 会员类型 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>会员类型</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="会员类型"
name="memberType"
rules={[{ required: true, message: '请选择会员类型' }]}
>
<Select
placeholder="请选择会员类型"
allowClear
onChange={handleMemberTypeChange}
>
{PERSONAL_MEMBER_TYPE_OPTIONS.map((t) => (
<Option key={t.value} value={t.value}>{t.label}</Option>
))}
</Select>
</Form.Item>
</Col>
{/* 专科分会委员 - 条件显示,对应协议 specialCommitteeMemberType */}
{personalMemberType === PERSONAL_MEMBER_TYPE.SPECIAL_COMMITTEE_MEMBER && (
<Col span={12}>
<Form.Item
label="专科分会"
name="specialCommitteeMemberType"
rules={[{ required: true, message: '请选择或输入专科分会' }]}
>
<Select
placeholder="请选择或搜索专科分会"
allowClear
showSearch
optionFilterProp="children"
>
{BRANCH_COMMITTEE_OPTIONS.map((b) => (
<Option key={b} value={b}>{b}</Option>
))}
</Select>
</Form.Item>
</Col>
)}
{/* 地区中医药学(协)会会长、秘书长 - 条件显示 */}
{personalMemberType === PERSONAL_MEMBER_TYPE.REGIONAL_ASSOCIATION_LEADER && (
<Col span={12}>
<Form.Item
label="地区中医药学(协)会"
name="cityAssociation"
rules={[{ required: true, message: '请选择地区' }]}
>
<Select placeholder="请选择地区" allowClear>
{CITY_ASSOCIATION_OPTIONS.map((c) => (
<Option key={c} value={c}>{c}</Option>
))}
</Select>
</Form.Item>
</Col>
)}
</Row>
{/* 设置密码 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>设置密码</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, max: 20, message: '密码长度为 6-20 位' },
{
pattern: /^[A-Za-z0-9!@#$%^&*()_\-+=.,?]+$/,
message: '密码仅支持字母、数字及常用符号',
},
]}
>
<Input.Password placeholder="请设置密码(6-20位)" allowClear maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password placeholder="请再次输入密码" allowClear />
</Form.Item>
</Col>
</Row>
</>
) : (
<>
{/* 基本信息 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>基本信息</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="联系人姓名"
name="name"
rules={[{ required: true, message: '请输入联系人姓名' }]}
>
<Input placeholder="请输入联系人姓名" allowClear />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="联系人手机号"
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input placeholder="请输入联系人手机号" allowClear maxLength={11} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="单位/组织类型"
name="unitOrOrgType"
rules={[{ required: true, message: '请选择单位/组织类型' }]}
>
<Select placeholder="请选择单位/组织类型" allowClear>
{UNIT_OR_ORG_TYPE_OPTIONS.map((u) => (
<Option key={u.value} value={u.value}>{u.label}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="邮编"
name="zipCode"
rules={[{ pattern: /^\d{6}$/, message: '请输入正确的 6 位邮编' }]}
>
<Input placeholder="请输入邮编" allowClear maxLength={6} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="单位地址"
name="address"
rules={[
{ required: true, message: '请输入单位地址' },
{ max: 200, message: '单位地址最长 200 个字符' },
]}
>
<Input placeholder="请输入单位地址" allowClear maxLength={200} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="注册时间"
name="registerTime"
rules={[{ required: true, message: '请选择注册时间' }]}
extra="单位会员有效期 1 年,过期时间将自动计算"
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择注册时间"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
{/* 法人代表信息 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>法人代表信息</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="法人代表姓名"
name="legalPersonName"
rules={[
{ required: true, message: '请输入法人代表姓名' },
{ min: 2, max: 20, message: '姓名长度应为 2-20 个字符' },
{
pattern: /^[\u4e00-\u9fa5A-Za-z·•\s]+$/,
message: '姓名仅支持中英文及·',
},
]}
>
<Input placeholder="请输入法人代表姓名" allowClear maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="法人代表手机"
name="legalPersonPhone"
rules={[
{ required: true, message: '请输入法人代表手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input placeholder="请输入法人代表手机号" allowClear maxLength={11} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="法人代表邮箱"
name="legalPersonEmail"
rules={[
{ required: true, message: '请输入法人代表邮箱' },
{ type: 'email', message: '请输入正确的邮箱地址' },
{ max: 50, message: '邮箱最长 50 个字符' },
]}
>
<Input placeholder="请输入法人代表邮箱" allowClear maxLength={50} />
</Form.Item>
</Col>
</Row>
{/* 推荐人信息 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>推荐人信息</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="推荐人姓名"
name="referrerName"
rules={[
{ min: 2, max: 20, message: '姓名长度应为 2-20 个字符' },
{
pattern: /^[\u4e00-\u9fa5A-Za-z·•\s]+$/,
message: '姓名仅支持中英文及·',
},
]}
>
<Input placeholder="请输入推荐人姓名" allowClear maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="推荐人手机"
name="referrerPhone"
rules={[{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }]}
>
<Input placeholder="请输入推荐人手机号" allowClear maxLength={11} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="推荐人邮箱"
name="referrerEmail"
rules={[
{ type: 'email', message: '请输入正确的邮箱地址' },
{ max: 50, message: '邮箱最长 50 个字符' },
]}
>
<Input placeholder="请输入推荐人邮箱" allowClear maxLength={50} />
</Form.Item>
</Col>
</Row>
{/* 设置密码 */}
<Divider orientation="left">
<span style={{ fontWeight: 600, color: '#1890ff' }}>设置密码</span>
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, max: 20, message: '密码长度为 6-20 位' },
{
pattern: /^[A-Za-z0-9!@#$%^&*()_\-+=.,?]+$/,
message: '密码仅支持字母、数字及常用符号',
},
]}
>
<Input.Password placeholder="请设置密码(6-20位)" allowClear maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password placeholder="请再次输入密码" allowClear />
</Form.Item>
</Col>
</Row>
</>
)}
</Form>
</Modal>
)
}
export default MemberFormModal

View File

@ -1,8 +1,6 @@
import { Outlet, useNavigate } from 'react-router-dom' import { Outlet, useNavigate } from 'react-router-dom'
import { Layout as AntLayout, Menu, Button, message, Avatar, Space } from 'antd' import { Layout as AntLayout, Menu, Button, message, Avatar, Space } from 'antd'
import { TeamOutlined, LogoutOutlined, FileExcelOutlined, MedicineBoxFilled, UserOutlined } from '@ant-design/icons' import { TeamOutlined, LogoutOutlined, MedicineBoxFilled, UserOutlined } from '@ant-design/icons'
import { exportMembersToExcel } from '../../utils/exportExcel'
import { mockMembers } from '../../data/mockData'
const { Header, Sider, Content } = AntLayout const { Header, Sider, Content } = AntLayout
@ -15,11 +13,6 @@ function LayoutPage() {
navigate('/login') navigate('/login')
} }
const handleExport = () => {
exportMembersToExcel(mockMembers)
message.success('导出成功')
}
return ( return (
<AntLayout style={{ minHeight: '100vh', background: '#f5f7fa' }}> <AntLayout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
<Sider <Sider
@ -79,18 +72,6 @@ function LayoutPage() {
</span> </span>
</Space> </Space>
<Space> <Space>
<Button
type="primary"
icon={<FileExcelOutlined />}
onClick={handleExport}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
borderColor: 'transparent',
}}
>
导出Excel
</Button>
<Space style={{ marginLeft: 16, paddingLeft: 16, borderLeft: '1px solid #f0f0f0' }}> <Space style={{ marginLeft: 16, paddingLeft: 16, borderLeft: '1px solid #f0f0f0' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} /> <Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<span style={{ color: '#595959', fontSize: 14 }}>管理员</span> <span style={{ color: '#595959', fontSize: 14 }}>管理员</span>

View File

@ -2,23 +2,43 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, message } from 'antd' import { Form, Input, Button, Card, message } from 'antd'
import { UserOutlined, LockOutlined, MedicineBoxFilled } from '@ant-design/icons' import { UserOutlined, LockOutlined, MedicineBoxFilled } from '@ant-design/icons'
import { login } from '../../api/auth'
import { setToken } from '../../api/request'
function Login() { function Login() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const onFinish = (values) => { const onFinish = async (values) => {
setLoading(true) setLoading(true)
setTimeout(() => { try {
if (values.username === 'admin' && values.password === 'admin123') { const res = await login({
localStorage.setItem('token', 'admin') username: values.username,
message.success('登录成功') password: values.password,
navigate('/') })
} else { // request.js success/code res payload.data
message.error('用户名或密码错误') // 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) setLoading(false)
}, 500) }
} }
return ( return (
@ -109,7 +129,7 @@ function Login() {
name="login" name="login"
onFinish={onFinish} onFinish={onFinish}
autoComplete="off" autoComplete="off"
initialValues={{ username: 'admin', password: 'admin123' }} initialValues={{ username: 'crm_admin', password: 'ChangeMe@123' }}
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
> >
<Form.Item <Form.Item

View File

@ -1,152 +1,375 @@
import { useState, useMemo } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Table, Input, Select, Button, Tag, Space, Form, Card, Typography } from 'antd' import { Table, Input, Select, Button, Tag, Space, Form, Card, Typography, Tabs, Tooltip, message } from 'antd'
import { EyeOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons' import { EyeOutlined, SearchOutlined, ReloadOutlined, PlusOutlined, FileExcelOutlined } from '@ant-design/icons'
import { mockMembers, memberTypes } from '../../data/mockData' import {
fetchUnitOrOrgMemberPage,
fetchPersonalMemberPage,
exportUnitOrOrgMembers,
exportPersonalMembers,
UNIT_OR_ORG_TYPE_MAP,
UNIT_OR_ORG_TYPE_OPTIONS,
GENDER_MAP,
POLITICAL_STATUS_MAP,
EDUCATION_LEVEL_MAP,
REGION_OR_UNIT_MAP,
PERSONAL_MEMBER_TYPE_MAP,
PERSONAL_MEMBER_TYPE_OPTIONS,
} from '../../api/member'
import MemberDetailModal from '../../components/MemberDetailModal' import MemberDetailModal from '../../components/MemberDetailModal'
import MemberFormModal from '../../components/MemberFormModal'
const { Option } = Select const { Option } = Select
const { Title } = Typography const { Title } = Typography
/** 会员类别 Tab */
const MEMBER_CATEGORY = {
UNIT: 'unit',
PERSONAL: 'personal',
}
/** 枚举值 → 标签的统一渲染 */
const renderEnum = (map) => (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 <Tag color="error" style={{ borderRadius: 4, fontWeight: 500 }}>{text}已过期</Tag>
}
if (status === 'soon') {
return <Tag color="warning" style={{ borderRadius: 4, fontWeight: 500 }}>{text}即将过期</Tag>
}
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() { function MemberList() {
const [activeTab, setActiveTab] = useState(MEMBER_CATEGORY.UNIT)
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
name: '', name: '',
phone: '', unitOrOrgType: undefined,
memberType: undefined, memberType: undefined,
}) })
const [appliedFilters, setAppliedFilters] = useState({}) const [appliedFilters, setAppliedFilters] = useState({})
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({ current: 1, pageSize: 10 })
current: 1, const [loading, setLoading] = useState(false)
pageSize: 10, const [tableData, setTableData] = useState([])
}) const [total, setTotal] = useState(0)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [selectedMember, setSelectedMember] = useState(null) const [selectedMember, setSelectedMember] = useState(null)
const [formModalVisible, setFormModalVisible] = useState(false)
const filteredData = useMemo(() => { /** 加载列表(按 tab 调用不同接口) */
return mockMembers.filter((item) => { const loadList = useCallback(async () => {
const matchName = !appliedFilters.name || item.name.includes(appliedFilters.name) setLoading(true)
const matchPhone = !appliedFilters.phone || item.phone.includes(appliedFilters.phone) try {
const matchType = !appliedFilters.memberType || item.memberType === appliedFilters.memberType const params = {
return matchName && matchPhone && matchType current: pagination.current,
}) size: pagination.pageSize,
}, [appliedFilters]) }
if (appliedFilters.name) params.name = appliedFilters.name
const paginatedData = useMemo(() => { const fetcher =
const start = (pagination.current - 1) * pagination.pageSize activeTab === MEMBER_CATEGORY.UNIT
const end = start + pagination.pageSize ? fetchUnitOrOrgMemberPage
return filteredData.slice(start, end) : fetchPersonalMemberPage
}, [filteredData, pagination])
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) => { const handleFilterChange = (key, value) => {
setFilters((prev) => ({ ...prev, [key]: value })) setFilters((prev) => ({ ...prev, [key]: value }))
} }
const handleSearch = () => { const handleSearch = () => {
setAppliedFilters(filters) setAppliedFilters(filters)
setPagination((prev) => ({ ...prev, current: 1 })) setPagination((prev) => ({ ...prev, current: 1 }))
} }
const handleReset = () => { const handleReset = () => {
const empty = { name: '', phone: '', memberType: undefined } const empty = { name: '', unitOrOrgType: undefined, memberType: undefined }
setFilters(empty) setFilters(empty)
setAppliedFilters(empty) setAppliedFilters(empty)
setPagination((prev) => ({ ...prev, current: 1 })) 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) => { const handleTableChange = (newPagination) => {
setPagination({ setPagination({
current: newPagination.current, current: newPagination.current,
pageSize: newPagination.pageSize, pageSize: newPagination.pageSize,
}) })
} }
const handleViewDetail = (record) => { const handleViewDetail = (record) => {
setSelectedMember(record) setSelectedMember(record)
setModalVisible(true) 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: '序号', title: '序号', key: 'index', width: 60, align: 'center', fixed: 'left',
key: 'index',
width: 70,
align: 'center',
render: (_, __, index) => (pagination.current - 1) * pagination.pageSize + index + 1, 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: '姓名', title: '单位类型', dataIndex: 'unitOrOrgType', key: 'unitOrOrgType', width: 170,
dataIndex: 'name', render: (value) => (
key: 'name', <Tag color="processing" style={{ borderRadius: 4 }}>
width: 100, {UNIT_OR_ORG_TYPE_MAP[value] || value || '-'}
</Tag>
),
}, },
{ 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: '手机号', title: '发票抬头', dataIndex: 'invoiceTitle', key: 'invoiceTitle', width: 200, ellipsis: true,
dataIndex: 'phone', render: (v) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
key: 'phone',
width: 140,
}, },
{ title: '纳税人识别号', dataIndex: 'invoiceTaxNo', key: 'invoiceTaxNo', width: 200, ellipsis: true, render: dash },
{ {
title: '会员类别', title: '是否支付', dataIndex: 'isPayed', key: 'isPayed', width: 100, align: 'center',
dataIndex: 'memberCategoryLabel', render: (v) => (
key: 'memberCategoryLabel', <Tag color={v ? 'gold' : 'default'} style={{ borderRadius: 4 }}>
width: 100, {v ? '已支付' : '未支付'}
align: 'center',
render: (text, record) => (
<Tag
color={record.memberCategory === 'personal' ? 'processing' : 'success'}
style={{ borderRadius: 4, fontWeight: 500 }}
>
{text}
</Tag> </Tag>
), ),
}, },
{ {
title: '会员类型', title: '是否激活', dataIndex: 'isActived', key: 'isActived', width: 100, align: 'center',
dataIndex: 'memberTypeLabel', render: (v) => (
key: 'memberTypeLabel', <Tag color={v ? 'success' : 'default'} style={{ borderRadius: 4 }}>
width: 220, {v ? '已激活' : '未激活'}
</Tag>
),
}, },
{ title: '注册时间', dataIndex: 'registerTime', key: 'registerTime', width: 180, render: formatDateTime },
{ title: '过期时间', dataIndex: 'expireTime', key: 'expireTime', width: 220, render: renderExpireTime },
{ {
title: '单位名称', title: '操作', key: 'action', width: 90, align: 'center', fixed: 'right',
dataIndex: 'unitName',
key: 'unitName',
ellipsis: true,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 120,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right',
render: (_, record) => ( render: (_, record) => (
<Button <Button type="primary" ghost size="small" icon={<EyeOutlined />}
type="primary" onClick={() => handleViewDetail(record)} style={{ borderRadius: 6 }}>
ghost
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
style={{ borderRadius: 6 }}
>
详情 详情
</Button> </Button>
), ),
}, },
] ]
/** 个人会员列 —— 对齐协议 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) => (
<Tag color="processing" style={{ borderRadius: 4 }}>
{PERSONAL_MEMBER_TYPE_MAP[v] || v || '-'}
</Tag>
),
},
{
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 ? <Tooltip title={v}>{v}</Tooltip> : '-'),
},
{
title: '单位地址', dataIndex: 'unitAddress', key: 'unitAddress', width: 220, ellipsis: true,
render: (v) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
},
{ title: '邮编', dataIndex: 'zipCode', key: 'zipCode', width: 90, render: dash },
{
title: '发票抬头', dataIndex: 'invoiceTitle', key: 'invoiceTitle', width: 200, ellipsis: true,
render: (v) => (v ? <Tooltip title={v}>{v}</Tooltip> : '-'),
},
{ title: '纳税人识别号', dataIndex: 'invoiceTaxNo', key: 'invoiceTaxNo', width: 200, ellipsis: true, render: dash },
{
title: '是否支付', dataIndex: 'isPayed', key: 'isPayed', width: 100, align: 'center',
render: (v) => (
<Tag color={v ? 'gold' : 'default'} style={{ borderRadius: 4 }}>
{v ? '已支付' : '未支付'}
</Tag>
),
},
{
title: '是否激活', dataIndex: 'isActived', key: 'isActived', width: 100, align: 'center',
render: (v) => (
<Tag color={v ? 'success' : 'default'} style={{ borderRadius: 4 }}>
{v ? '已激活' : '未激活'}
</Tag>
),
},
{ 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) => (
<Button type="primary" ghost size="small" icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)} style={{ borderRadius: 6 }}>
详情
</Button>
),
},
]
const isUnitTab = activeTab === MEMBER_CATEGORY.UNIT
const columns = isUnitTab ? unitColumns : personalColumns
const scrollX = isUnitTab ? 3000 : 3200
return ( return (
<div> <div>
<Card <Card
style={{ style={{
marginBottom: 24, marginBottom: 24, borderRadius: 12,
borderRadius: 12, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', border: '1px solid #f0f0f0',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
border: '1px solid #f0f0f0',
}} }}
styles={{ body: { paddingBottom: 8 } }} styles={{ body: { paddingBottom: 8 } }}
> >
@ -154,54 +377,51 @@ function MemberList() {
筛选条件 筛选条件
</Title> </Title>
<Form layout="inline"> <Form layout="inline">
<Form.Item label="姓名" style={{ marginBottom: 16 }}> <Form.Item label={isUnitTab ? '单位/组织名称' : '姓名'} style={{ marginBottom: 16 }}>
<Input <Input
placeholder="请输入姓名" placeholder={isUnitTab ? '请输入单位/组织名称' : '请输入姓名'}
value={filters.name} value={filters.name}
onChange={(e) => handleFilterChange('name', e.target.value)} onChange={(e) => handleFilterChange('name', e.target.value)}
onPressEnter={handleSearch}
allowClear allowClear
style={{ width: 170, borderRadius: 6 }} style={{ width: 220, borderRadius: 6 }}
/> />
</Form.Item> </Form.Item>
<Form.Item label="手机号" style={{ marginBottom: 16 }}> {isUnitTab ? (
<Input <Form.Item label="单位/组织类型" style={{ marginBottom: 16 }}>
placeholder="请输入手机号" <Select
value={filters.phone} placeholder="请选择"
onChange={(e) => handleFilterChange('phone', e.target.value)} value={filters.unitOrOrgType}
onChange={(value) => handleFilterChange('unitOrOrgType', value)}
allowClear allowClear
style={{ width: 190, borderRadius: 6 }} style={{ width: 200, borderRadius: 6 }}
/> >
{UNIT_OR_ORG_TYPE_OPTIONS.map((t) => (
<Option key={t.value} value={t.value}>{t.label}</Option>
))}
</Select>
</Form.Item> </Form.Item>
<Form.Item label="会员类别" style={{ marginBottom: 16 }}> ) : (
<Form.Item label="会员类型" style={{ marginBottom: 16 }}>
<Select <Select
placeholder="请选择" placeholder="请选择"
value={filters.memberType} value={filters.memberType}
onChange={(value) => handleFilterChange('memberType', value)} onChange={(value) => handleFilterChange('memberType', value)}
allowClear allowClear
style={{ width: 190, borderRadius: 6 }} style={{ width: 240, borderRadius: 6 }}
> >
{memberTypes.map((t) => ( {PERSONAL_MEMBER_TYPE_OPTIONS.map((t) => (
<Option key={t.value} value={t.value}> <Option key={t.value} value={t.value}>{t.label}</Option>
{t.label}
</Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
)}
<Form.Item style={{ marginBottom: 16 }}> <Form.Item style={{ marginBottom: 16 }}>
<Space> <Space>
<Button <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} style={{ borderRadius: 6 }}>
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
style={{ borderRadius: 6 }}
>
查询 查询
</Button> </Button>
<Button <Button icon={<ReloadOutlined />} onClick={handleReset} style={{ borderRadius: 6 }}>
icon={<ReloadOutlined />}
onClick={handleReset}
style={{ borderRadius: 6 }}
>
重置 重置
</Button> </Button>
</Space> </Space>
@ -211,33 +431,58 @@ function MemberList() {
<Card <Card
style={{ style={{
borderRadius: 12, borderRadius: 12, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', border: '1px solid #f0f0f0',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
border: '1px solid #f0f0f0',
}} }}
title={ styles={{ body: { paddingTop: 0 } }}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>会员列表</span>
<Tag color="default" style={{ borderRadius: 4 }}>
{filteredData.length} 条数据
</Tag>
</div>
}
> >
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
tabBarExtraContent={
<Space>
<Button
icon={<FileExcelOutlined />}
onClick={handleExport}
loading={exporting}
style={{
borderRadius: 6,
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
borderColor: 'transparent',
color: '#fff',
}}
>
{isUnitTab ? '导出单位会员' : '导出个人会员'}
</Button>
<Button type="primary" icon={<PlusOutlined />}
onClick={() => setFormModalVisible(true)} style={{ borderRadius: 6 }}>
新增会员
</Button>
<Tag color="default" style={{ borderRadius: 4 }}>
{total} 条数据
</Tag>
</Space>
}
items={[
{ key: MEMBER_CATEGORY.UNIT, label: '单位会员' },
{ key: MEMBER_CATEGORY.PERSONAL, label: '个人会员' },
]}
/>
<Table <Table
columns={columns} columns={columns}
dataSource={paginatedData} dataSource={tableData}
rowKey="id" rowKey="id"
loading={loading}
pagination={{ pagination={{
current: pagination.current, current: pagination.current,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
total: filteredData.length, total,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total}`, showTotal: (t) => `${t}`,
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
}} }}
onChange={handleTableChange} onChange={handleTableChange}
scroll={{ x: 900 }} scroll={{ x: scrollX }}
size="middle" size="middle"
/> />
</Card> </Card>
@ -247,6 +492,12 @@ function MemberList() {
member={selectedMember} member={selectedMember}
onCancel={() => setModalVisible(false)} onCancel={() => setModalVisible(false)}
/> />
<MemberFormModal
open={formModalVisible}
onCancel={() => setFormModalVisible(false)}
onOk={handleAddMember}
/>
</div> </div>
) )
} }

View File

@ -1,7 +1,36 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'node:path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ 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()], 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/, ''),
},
},
},
}
}) })