接口调试
This commit is contained in:
parent
87d4969c40
commit
76d12d5284
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# 生产环境
|
||||||
|
# 部署后实际接口地址,按需修改
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# 环境变量本地覆盖文件(存放个人的 YApi 地址等,不提交)
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
allowClear
|
onChange={(value) => handleFilterChange('unitOrOrgType', value)}
|
||||||
style={{ width: 190, borderRadius: 6 }}
|
allowClear
|
||||||
/>
|
style={{ width: 200, borderRadius: 6 }}
|
||||||
</Form.Item>
|
>
|
||||||
<Form.Item label="会员类别" style={{ marginBottom: 16 }}>
|
{UNIT_OR_ORG_TYPE_OPTIONS.map((t) => (
|
||||||
<Select
|
<Option key={t.value} value={t.value}>{t.label}</Option>
|
||||||
placeholder="请选择"
|
))}
|
||||||
value={filters.memberType}
|
</Select>
|
||||||
onChange={(value) => handleFilterChange('memberType', value)}
|
</Form.Item>
|
||||||
allowClear
|
) : (
|
||||||
style={{ width: 190, borderRadius: 6 }}
|
<Form.Item label="会员类型" style={{ marginBottom: 16 }}>
|
||||||
>
|
<Select
|
||||||
{memberTypes.map((t) => (
|
placeholder="请选择"
|
||||||
<Option key={t.value} value={t.value}>
|
value={filters.memberType}
|
||||||
{t.label}
|
onChange={(value) => handleFilterChange('memberType', value)}
|
||||||
</Option>
|
allowClear
|
||||||
))}
|
style={{ width: 240, borderRadius: 6 }}
|
||||||
</Select>
|
>
|
||||||
</Form.Item>
|
{PERSONAL_MEMBER_TYPE_OPTIONS.map((t) => (
|
||||||
|
<Option key={t.value} value={t.value}>{t.label}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
plugins: [react()],
|
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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue