调试接口

This commit is contained in:
huyunkun 2026-05-23 09:33:16 +08:00
parent 0b684a8419
commit 36fe597d86
26 changed files with 1360 additions and 590 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -7,6 +7,7 @@ import CompanyRegister from './pages/CompanyRegister'
import Payment from './pages/Payment' import Payment from './pages/Payment'
import MemberValid from './pages/MemberValid' import MemberValid from './pages/MemberValid'
import MemberRules from './pages/MemberRules' import MemberRules from './pages/MemberRules'
import Invoice from './pages/Invoice'
function App() { function App() {
return ( return (
@ -19,6 +20,7 @@ function App() {
<Route path="/payment" element={<Payment />} /> <Route path="/payment" element={<Payment />} />
<Route path="/member-valid" element={<MemberValid />} /> <Route path="/member-valid" element={<MemberValid />} />
<Route path="/member-rules" element={<MemberRules />} /> <Route path="/member-rules" element={<MemberRules />} />
<Route path="/invoice" element={<Invoice />} />
</Routes> </Routes>
) )
} }

View File

@ -78,43 +78,100 @@ export const EducationLevelLabel: Record<EducationLevel, string> = {
[EducationLevel.OTHER]: '其他', [EducationLevel.OTHER]: '其他',
} }
/** 所属地区/单位 */ /** 职称 */
export enum ProfessionalTitle {
CHIEF_PHYSICIAN = 'CHIEF_PHYSICIAN',
DEPUTY_CHIEF_PHYSICIAN = 'DEPUTY_CHIEF_PHYSICIAN',
ATTENDING_PHYSICIAN = 'ATTENDING_PHYSICIAN',
LICENSED_PHYSICIAN = 'LICENSED_PHYSICIAN',
ASSISTANT_PHYSICIAN = 'ASSISTANT_PHYSICIAN',
CHIEF_PHARMACIST = 'CHIEF_PHARMACIST',
DEPUTY_CHIEF_PHARMACIST = 'DEPUTY_CHIEF_PHARMACIST',
SUPERVISOR_PHARMACIST = 'SUPERVISOR_PHARMACIST',
LICENSED_PHARMACIST = 'LICENSED_PHARMACIST',
CHIEF_NURSE = 'CHIEF_NURSE',
DEPUTY_CHIEF_NURSE = 'DEPUTY_CHIEF_NURSE',
SUPERVISOR_NURSE = 'SUPERVISOR_NURSE',
LICENSED_NURSE = 'LICENSED_NURSE',
RESEARCHER = 'RESEARCHER',
DEPUTY_RESEARCHER = 'DEPUTY_RESEARCHER',
ASSISTANT_RESEARCHER = 'ASSISTANT_RESEARCHER',
CHIEF_TECHNICIAN = 'CHIEF_TECHNICIAN',
DEPUTY_CHIEF_TECHNICIAN = 'DEPUTY_CHIEF_TECHNICIAN',
SUPERVISOR_TECHNICIAN = 'SUPERVISOR_TECHNICIAN',
PROFESSOR = 'PROFESSOR',
ASSOCIATE_PROFESSOR = 'ASSOCIATE_PROFESSOR',
LECTURER = 'LECTURER',
OTHER_SENIOR_TITLE = 'OTHER_SENIOR_TITLE',
OTHER_DEPUTY_SENIOR_TITLE = 'OTHER_DEPUTY_SENIOR_TITLE',
OTHER_INTERMEDIATE_TITLE = 'OTHER_INTERMEDIATE_TITLE',
OTHER_JUNIOR_TITLE = 'OTHER_JUNIOR_TITLE',
}
export const ProfessionalTitleLabel: Record<ProfessionalTitle, string> = {
[ProfessionalTitle.CHIEF_PHYSICIAN]: '主任(中)医师',
[ProfessionalTitle.DEPUTY_CHIEF_PHYSICIAN]: '副主任(中)医师',
[ProfessionalTitle.ATTENDING_PHYSICIAN]: '主治(中)医师',
[ProfessionalTitle.LICENSED_PHYSICIAN]: '执业(中)医师',
[ProfessionalTitle.ASSISTANT_PHYSICIAN]: '助理(中)医师',
[ProfessionalTitle.CHIEF_PHARMACIST]: '主任(中)药师',
[ProfessionalTitle.DEPUTY_CHIEF_PHARMACIST]: '副主任(中)药师',
[ProfessionalTitle.SUPERVISOR_PHARMACIST]: '主管(中)药师',
[ProfessionalTitle.LICENSED_PHARMACIST]: '执业(中)药师',
[ProfessionalTitle.CHIEF_NURSE]: '主任护师',
[ProfessionalTitle.DEPUTY_CHIEF_NURSE]: '副主任护师',
[ProfessionalTitle.SUPERVISOR_NURSE]: '主管护师',
[ProfessionalTitle.LICENSED_NURSE]: '执业护师',
[ProfessionalTitle.RESEARCHER]: '研究员',
[ProfessionalTitle.DEPUTY_RESEARCHER]: '副研究员',
[ProfessionalTitle.ASSISTANT_RESEARCHER]: '助理研究员',
[ProfessionalTitle.CHIEF_TECHNICIAN]: '主任技师',
[ProfessionalTitle.DEPUTY_CHIEF_TECHNICIAN]: '副主任技师',
[ProfessionalTitle.SUPERVISOR_TECHNICIAN]: '主管技师',
[ProfessionalTitle.PROFESSOR]: '教授',
[ProfessionalTitle.ASSOCIATE_PROFESSOR]: '副教授',
[ProfessionalTitle.LECTURER]: '讲师',
[ProfessionalTitle.OTHER_SENIOR_TITLE]: '其他高级职称',
[ProfessionalTitle.OTHER_DEPUTY_SENIOR_TITLE]: '其他副高职称',
[ProfessionalTitle.OTHER_INTERMEDIATE_TITLE]: '其他中级职称',
[ProfessionalTitle.OTHER_JUNIOR_TITLE]: '其他初级职称',
}
/** 所属地区/单位 (金华市内区县与医院) */
export enum RegionOrUnit { export enum RegionOrUnit {
HANGZHOU = 'HANGZHOU', JINHUA_TCM_HOSPITAL = 'JINHUA_TCM_HOSPITAL',
NINGBO = 'NINGBO', JINHUA_CENTRAL_HOSPITAL = 'JINHUA_CENTRAL_HOSPITAL',
HUZHOU = 'HUZHOU', JINHUA_PEOPLE_HOSPITAL = 'JINHUA_PEOPLE_HOSPITAL',
JIAXING = 'JIAXING', JINHUA_SECOND_HOSPITAL = 'JINHUA_SECOND_HOSPITAL',
SHAOXING = 'SHAOXING', JINHUA_FIFTH_HOSPITAL = 'JINHUA_FIFTH_HOSPITAL',
JINHUA = 'JINHUA', WUCHENG_DISTRICT = 'WUCHENG_DISTRICT',
QUZHOU = 'QUZHOU', JINDONG_DISTRICT = 'JINDONG_DISTRICT',
ZHOUSHAN = 'ZHOUSHAN', DEVELOPMENT_ZONE = 'DEVELOPMENT_ZONE',
TAIZHOU = 'TAIZHOU', YIWU_CITY = 'YIWU_CITY',
LISHUI = 'LISHUI', LANXI_CITY = 'LANXI_CITY',
ZJCMU = 'ZJCMU', DONGYANG_CITY = 'DONGYANG_CITY',
ZJTDH = 'ZJTDH', YONGKANG_CITY = 'YONGKANG_CITY',
ZJTCMH = 'ZJTCMH', PUJIANG_COUNTY = 'PUJIANG_COUNTY',
ZJXH = 'ZJXH', WUYI_COUNTY = 'WUYI_COUNTY',
ZJZS = 'ZJZS', PANAN_COUNTY = 'PANAN_COUNTY',
OTHER = 'OTHER',
} }
export const RegionOrUnitLabel: Record<RegionOrUnit, string> = { export const RegionOrUnitLabel: Record<RegionOrUnit, string> = {
[RegionOrUnit.HANGZHOU]: '杭州', [RegionOrUnit.JINHUA_TCM_HOSPITAL]: '金华市中医医院',
[RegionOrUnit.NINGBO]: '宁波', [RegionOrUnit.JINHUA_CENTRAL_HOSPITAL]: '金华市中心医院',
[RegionOrUnit.HUZHOU]: '湖州', [RegionOrUnit.JINHUA_PEOPLE_HOSPITAL]: '金华市人民医院',
[RegionOrUnit.JIAXING]: '嘉兴', [RegionOrUnit.JINHUA_SECOND_HOSPITAL]: '金华市第二医院',
[RegionOrUnit.SHAOXING]: '绍兴', [RegionOrUnit.JINHUA_FIFTH_HOSPITAL]: '金华市第五医院',
[RegionOrUnit.JINHUA]: '金华', [RegionOrUnit.WUCHENG_DISTRICT]: '婺城区',
[RegionOrUnit.QUZHOU]: '衢州', [RegionOrUnit.JINDONG_DISTRICT]: '金东区',
[RegionOrUnit.ZHOUSHAN]: '舟山', [RegionOrUnit.DEVELOPMENT_ZONE]: '开发区',
[RegionOrUnit.TAIZHOU]: '台州', [RegionOrUnit.YIWU_CITY]: '义乌市',
[RegionOrUnit.LISHUI]: '丽水', [RegionOrUnit.LANXI_CITY]: '兰溪市',
[RegionOrUnit.ZJCMU]: '浙江中医药大学', [RegionOrUnit.DONGYANG_CITY]: '东阳市',
[RegionOrUnit.ZJTDH]: '浙江省立同德医院', [RegionOrUnit.YONGKANG_CITY]: '永康市',
[RegionOrUnit.ZJTCMH]: '浙江省中医院', [RegionOrUnit.PUJIANG_COUNTY]: '浦江县',
[RegionOrUnit.ZJXH]: '浙江省新华医院', [RegionOrUnit.WUYI_COUNTY]: '武义县',
[RegionOrUnit.ZJZS]: '浙江省中山医院', [RegionOrUnit.PANAN_COUNTY]: '磐安县',
[RegionOrUnit.OTHER]: '其他',
} }
/** 会员类型 (个人会员注册专用) */ /** 会员类型 (个人会员注册专用) */
@ -132,12 +189,8 @@ export const PersonalMemberTypeLabel: Record<PersonalMemberType, string> = {
/** 单位/组织类型 */ /** 单位/组织类型 */
export enum UnitOrOrgType { export enum UnitOrOrgType {
/** 高等院校 */ /** 事业单位 (含医院、高校、科研院所等) */
COLLEGE = 'COLLEGE', PUBLIC_INSTITUTION = 'PUBLIC_INSTITUTION',
/** 三级医疗机构 */
TERTIARY_MEDICAL = 'TERTIARY_MEDICAL',
/** 二级及以下医疗机构 */
SECONDARY_AND_BELOW_MEDICAL = 'SECONDARY_AND_BELOW_MEDICAL',
/** 企业 */ /** 企业 */
ENTERPRISE = 'ENTERPRISE', ENTERPRISE = 'ENTERPRISE',
/** 其他 */ /** 其他 */
@ -145,9 +198,7 @@ export enum UnitOrOrgType {
} }
export const UnitOrOrgTypeLabel: Record<UnitOrOrgType, string> = { export const UnitOrOrgTypeLabel: Record<UnitOrOrgType, string> = {
[UnitOrOrgType.COLLEGE]: '高等院校', [UnitOrOrgType.PUBLIC_INSTITUTION]: '事业单位',
[UnitOrOrgType.TERTIARY_MEDICAL]: '三级医疗机构',
[UnitOrOrgType.SECONDARY_AND_BELOW_MEDICAL]: '二级及以下医疗机构',
[UnitOrOrgType.ENTERPRISE]: '企业', [UnitOrOrgType.ENTERPRISE]: '企业',
[UnitOrOrgType.OTHER]: '其他', [UnitOrOrgType.OTHER]: '其他',
} }
@ -176,11 +227,15 @@ export interface LoginResult {
userId?: string userId?: string
/** 会员ID */ /** 会员ID */
memberId?: string memberId?: string
/** 会员编号 (后端主会员业务编号,会员权益页展示及发票接口路径参数均使用此值) */
memberNo?: string
/** 账号类型 */ /** 账号类型 */
accountType?: AccountType accountType?: AccountType
/** 用户昵称/单位名称 */ /** 用户昵称/单位名称 */
nickname?: string nickname?: string
/** 是否已支付 */ /** 是否已充值/开通会员 (后端主字段,用于登录后跳转判断) */
isVip?: boolean
/** 是否已支付 (旧字段,保留兼容) */
isPaid?: boolean isPaid?: boolean
/** 是否已过期 */ /** 是否已过期 */
isExpired?: boolean isExpired?: boolean
@ -206,8 +261,8 @@ export interface PersonalRegisterParams {
politicalStatus?: PoliticalStatus politicalStatus?: PoliticalStatus
/** 学历 */ /** 学历 */
educationLevel?: EducationLevel educationLevel?: EducationLevel
/** 职称 */ /** 职称 (枚举) */
title?: string title?: ProfessionalTitle
/** 职务 */ /** 职务 */
position?: string position?: string
/** 所属地区/单位 */ /** 所属地区/单位 */
@ -218,6 +273,8 @@ export interface PersonalRegisterParams {
unitAddress?: string unitAddress?: string
/** 会员类型 */ /** 会员类型 */
memberType?: PersonalMemberType memberType?: PersonalMemberType
/** 专科分会委员类型 (memberType = SPECIAL_COMMITTEE_MEMBER 时填写) */
specialCommitteeMemberType?: string
/** 密码 */ /** 密码 */
password?: string password?: string
} }

View File

@ -41,6 +41,7 @@ export {
getMemberStatus, getMemberStatus,
getMemberById, getMemberById,
renewMember, renewMember,
submitInvoiceTitle,
} from './member' } from './member'
export { export {

View File

@ -50,6 +50,14 @@ export interface RenewParams {
duration?: number duration?: number
} }
/** 发票抬头提交参数 (接口体) */
export interface InvoiceTitleDTO {
/** 发票抬头 (个人姓名 或 单位全称) */
invoiceTitle?: string
/** 纳税人识别号 (单位发票填写) */
invoiceTaxNo?: string
}
/** 获取当前登录会员的信息 */ /** 获取当前登录会员的信息 */
export const getCurrentMember = () => request.get<MemberInfo>('/member/current') export const getCurrentMember = () => request.get<MemberInfo>('/member/current')
@ -63,3 +71,15 @@ export const getMemberById = (memberId: string) =>
/** 申请续费,返回新订单ID */ /** 申请续费,返回新订单ID */
export const renewMember = (data: RenewParams) => export const renewMember = (data: RenewParams) =>
request.post<{ orderId: string; amount: number }>('/member/renew', data) request.post<{ orderId: string; amount: number }>('/member/renew', data)
/**
*
* @url PUT /api/v1/wechat/official-account/members/{memberId}/invoice-title
* @param memberId ( memberId, userId)
* @param data { invoiceTitle, invoiceTaxNo }
* @returns boolean (data ,true )
*/
export const submitInvoiceTitle = (
memberId: string,
data: InvoiceTitleDTO,
) => request.put<boolean>(`/members/${memberId}/invoice-title`, data)

View File

@ -64,6 +64,44 @@ export interface PayResult {
export const createAlipayOrder = (data: CreateOrderParams) => export const createAlipayOrder = (data: CreateOrderParams) =>
request.post<CreateOrderResult>('/payment/alipay/create', data) request.post<CreateOrderResult>('/payment/alipay/create', data)
/** 创建支付宝充值 H5 订单参数 */
export interface RechargeCreateDTO {
/** 充值金额,示例值(99) */
amount: number
/** 订单标题,示例值(会员充值) */
subject?: string
}
/**
* H5
* :
* - 字符串: 直接是支付宝表单 HTML URL
* - : { payUrl?, body?, form?, orderId?, ... }
* ,
*/
export type RechargeAlipayH5OrderResult =
| string
| {
payUrl?: string
payURL?: string
url?: string
body?: string
form?: string
orderId?: string
tradeNo?: string
[key: string]: unknown
}
/**
* H5
* POST /api/v1/wechat/official-account/recharges/alipay/h5/orders
*/
export const createRechargeAlipayH5Order = (data: RechargeCreateDTO) =>
request.post<RechargeAlipayH5OrderResult>(
'/recharges/alipay/h5/orders',
data,
)
/** 查询订单支付结果(用于轮询) */ /** 查询订单支付结果(用于轮询) */
export const queryPayResult = (orderId: string) => export const queryPayResult = (orderId: string) =>
request.get<PayResult>(`/payment/result/${orderId}`) request.get<PayResult>(`/payment/result/${orderId}`)

View File

@ -1,3 +1,40 @@
/**
* 全站设计令牌(Design Tokens)
* 所有页面/按钮/卡片/链接 统一引用以下变量,确保品牌一致性
*/
:root {
/* 品牌主色 - 酒红(中医药学会主题色) */
--brand-color: #8b2632;
--brand-color-dark: #5d1a22;
--brand-color-light: #fbeaec;
--brand-gradient: linear-gradient(135deg, #8b2632 0%, #5d1a22 100%);
--brand-gradient-hover: linear-gradient(135deg, #a32a39 0%, #6f1f29 100%);
/* 页面背景 */
--page-bg: linear-gradient(180deg, #fbf6ee 0%, #f5ebd8 100%);
--page-bg-flat: #fbf6ee;
--surface-bg: #ffffff;
/* 文字 */
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #999999;
--text-on-brand: #ffffff;
/* 提示 */
--warning-bg: #fff7ed;
--warning-border: #fde0c4;
--warning-text: #ad6800;
--danger-color: #ff4d4f;
/* 圆角/阴影 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 4px 12px rgba(139, 38, 50, 0.08);
--shadow-elevated: 0 8px 24px rgba(139, 38, 50, 0.12);
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -7,12 +44,14 @@
html, body { html, body {
width: 100%; width: 100%;
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial,
sans-serif; "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5; background: var(--page-bg-flat);
overflow-x: hidden; overflow-x: hidden;
} }

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, message, Card } from 'antd' import { Form, Input, Button, message, Card } from 'antd'
import { ArrowLeftOutlined, BankOutlined, LockOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, BankOutlined, LockOutlined } from '@ant-design/icons'
import { loginCompany } from '../api/auth' import { loginCompany } from '../api/auth'
import { setToken } from '../utils/request' import { setToken, setUserId, setMemberNo } from '../utils/request'
import './CompanyLogin.css' import './CompanyLogin.css'
const CompanyLogin = () => { const CompanyLogin = () => {
@ -27,41 +27,45 @@ const CompanyLogin = () => {
setToken(accessToken) setToken(accessToken)
} }
// TODO: 待 yapi 返回字段明确后,调用 /member/status 查询会员状态决定跳转 // 保存后端返回的 userId (会员权益页面需要展示)
// 当前调试阶段,使用 mock 状态检查逻辑 const userId = (result?.userId as string) || ''
const memberStatus = { if (userId) {
isPaid: result?.isPaid ?? false, setUserId(userId)
isExpired: result?.isExpired ?? false, }
memberId: (result?.memberId as string) || 'COMP20260510001',
// 保存后端返回的 memberNo (会员编号,发票接口路径参数也使用)
const memberNo = (result?.memberNo as string) || ''
if (memberNo) {
setMemberNo(memberNo)
}
// 根据后端返回的 isVip 判断会员状态
// - isVip = true: 已充值会员 -> 会员权益页(/member-valid)
// - isVip = false/未返回: 未充值 -> 支付页(/payment)
const isVip = result?.isVip === true
const memberInfo = {
userId,
memberNo,
memberId: (result?.memberId as string) || '',
memberType: '单位会员', memberType: '单位会员',
amount: 999.0, amount: 999.0,
validPeriod: '1年', validPeriod: '1年',
expireDate: '2027-05-10',
} }
message.success('登录成功!') message.success('登录成功!')
// 根据会员状态跳转
setTimeout(() => { setTimeout(() => {
if (!memberStatus.isPaid || memberStatus.isExpired) { if (isVip) {
// 未支付或已过期,跳转至待支付页面 // 已充值 -> 会员权益页
navigate('/payment', { navigate('/member-valid', {
state: { state: memberInfo,
memberId: memberStatus.memberId,
memberType: memberStatus.memberType,
amount: memberStatus.amount,
validPeriod: memberStatus.validPeriod,
isRenew: memberStatus.isExpired,
},
}) })
} else { } else {
// 会员有效,跳转至会员有效页面 // 未充值 -> 支付页
navigate('/member-valid', { navigate('/payment', {
state: { state: {
memberId: memberStatus.memberId, ...memberInfo,
memberType: memberStatus.memberType, isRenew: false,
amount: memberStatus.amount,
validPeriod: memberStatus.validPeriod,
}, },
}) })
} }

View File

@ -46,20 +46,11 @@ const CompanyRegister = () => {
setToken(accessToken) setToken(accessToken)
} }
message.success('注册成功!') message.success('注册成功,请登录!')
// 跳转到支付页面 // 注册成功后跳转到单位会员登录页,由用户主动登录
setTimeout(() => { setTimeout(() => {
navigate('/payment', { navigate('/company-login')
state: {
memberId: result?.memberId,
phone: payload.phone,
memberType: '单位会员',
amount: 999.0,
validPeriod: '1年',
isRenew: false,
},
})
}, 500) }, 500)
} catch (error) { } catch (error) {
// 请求拦截器已统一 message.error // 请求拦截器已统一 message.error

152
src/pages/Invoice.css Normal file
View File

@ -0,0 +1,152 @@
.invoice-page {
width: 100%;
min-height: 100vh;
background: var(--page-bg);
display: flex;
flex-direction: column;
}
/* 顶部 Header */
.invoice-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--brand-gradient);
color: var(--text-on-brand);
box-shadow: 0 2px 8px rgba(139, 38, 50, 0.18);
}
.invoice-header .back-btn {
width: 36px;
height: 36px;
color: #fff !important;
font-size: 18px;
border: none;
background: transparent !important;
}
.invoice-header .back-btn:hover {
background: rgba(255, 255, 255, 0.12) !important;
}
.invoice-title {
flex: 1;
margin: 0 8px;
text-align: center;
font-size: 18px;
font-weight: 600;
letter-spacing: 2px;
color: #fff;
}
.header-spacer {
width: 36px;
height: 36px;
}
/* 表单容器 */
.invoice-container {
flex: 1;
padding: 20px 16px;
}
.invoice-form {
background: var(--surface-bg);
border-radius: var(--radius-md);
padding: 20px 16px;
box-shadow: var(--shadow-card);
}
.invoice-form .ant-form-item-label > label {
font-weight: 500;
color: #333;
}
/* 发票类型按钮组 */
.invoice-type-group {
display: flex !important;
width: 100%;
}
.invoice-type-group .ant-radio-button-wrapper {
flex: 1;
text-align: center;
height: 44px;
line-height: 42px;
font-size: 15px;
}
.invoice-type-group
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
color: var(--brand-color) !important;
border-color: var(--brand-color) !important;
background: var(--brand-color-light) !important;
}
.invoice-type-group
.ant-radio-button-wrapper-checked:not(
.ant-radio-button-wrapper-disabled
)::before {
background-color: var(--brand-color) !important;
}
.invoice-type-group .ant-radio-button-wrapper:hover {
color: var(--brand-color) !important;
}
/* 输入框 */
.invoice-form .ant-input,
.invoice-form .ant-input-affix-wrapper {
height: 44px;
font-size: 15px;
border-radius: 8px;
}
.invoice-form .ant-input-affix-wrapper .ant-input {
height: auto;
}
/* 无需发票提示 */
.invoice-tip {
background: var(--warning-bg);
border: 1px solid var(--warning-border);
color: var(--warning-text);
border-radius: var(--radius-sm);
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
margin-bottom: 24px;
}
/* 提交按钮 */
.submit-item {
margin-top: 8px;
margin-bottom: 0;
}
.submit-btn {
height: 48px !important;
font-size: 16px !important;
font-weight: 600;
border-radius: var(--radius-sm) !important;
background: var(--brand-gradient) !important;
border: none !important;
color: var(--text-on-brand) !important;
letter-spacing: 4px;
}
.submit-btn:hover,
.submit-btn:focus {
background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
}
/* iPhone 安全区 */
@supports (padding: max(0px)) {
.invoice-container {
padding-bottom: max(20px, env(safe-area-inset-bottom));
}
}

189
src/pages/Invoice.tsx Normal file
View File

@ -0,0 +1,189 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Button, Form, Input, Radio, message } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { submitInvoiceTitle } from '../api/member'
import { getUserId } from '../utils/request'
import './Invoice.css'
/** 发票类型枚举 */
export enum InvoiceType {
/** 个人发票 */
PERSONAL = 'PERSONAL',
/** 单位发票 */
UNIT = 'UNIT',
}
/** 发票表单字段 */
interface InvoiceFormValues {
invoiceType: InvoiceType
/** 个人抬头(invoiceType=PERSONAL) */
personalTitle?: string
/** 单位全称(invoiceType=UNIT) */
unitName?: string
/** 税号(invoiceType=UNIT) */
taxNumber?: string
}
const Invoice = () => {
const navigate = useNavigate()
const location = useLocation()
// 发票接口路径参数 {memberId} 实际使用 userId 填充
// 优先读取路由透传,其次读取 localStorage 兑底
const stateData = (location.state || {}) as { userId?: string }
const userId = stateData.userId || getUserId()
const [form] = Form.useForm<InvoiceFormValues>()
const [submitting, setSubmitting] = useState(false)
const [invoiceType, setInvoiceType] = useState<InvoiceType>(
InvoiceType.PERSONAL
)
/** 切换发票类型, 清空已不再需要的字段 */
const handleTypeChange = (value: InvoiceType) => {
setInvoiceType(value)
if (value === InvoiceType.PERSONAL) {
form.setFieldsValue({ unitName: undefined, taxNumber: undefined })
} else if (value === InvoiceType.UNIT) {
form.setFieldsValue({ personalTitle: undefined })
}
}
const handleSubmit = async (values: InvoiceFormValues) => {
if (!userId) {
message.error('未获取到用户信息,请重新登录后重试')
return
}
// 根据发票类型组装接口请求体
// - 个人发票: invoiceTitle = personalTitle, invoiceTaxNo 不传
// - 单位发票: invoiceTitle = unitName, invoiceTaxNo = taxNumber
const payload =
values.invoiceType === InvoiceType.PERSONAL
? { invoiceTitle: values.personalTitle }
: {
invoiceTitle: values.unitName,
invoiceTaxNo: values.taxNumber,
}
setSubmitting(true)
try {
const ok = await submitInvoiceTitle(userId, payload)
console.log('[Invoice submit]', { userId, payload, ok })
// 后端约定: data 字段为 boolean,true 表示提交成功
if (ok === false) {
message.error('提交失败,请稍后重试')
return
}
message.success('发票信息提交成功!')
setTimeout(() => navigate(-1), 600)
} catch (e) {
// 请求拦截器已统一 message.error,这里不重复提示
console.error('[Invoice submit error]', e)
} finally {
setSubmitting(false)
}
}
return (
<div className="invoice-page">
<div className="invoice-header">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
className="back-btn"
/>
<h1 className="invoice-title"></h1>
<span className="header-spacer" />
</div>
<div className="invoice-container">
<Form<InvoiceFormValues>
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ invoiceType: InvoiceType.PERSONAL }}
className="invoice-form"
>
<Form.Item
label="发票类型"
name="invoiceType"
rules={[{ required: true, message: '请选择发票类型' }]}
>
<Radio.Group
onChange={(e) => handleTypeChange(e.target.value)}
className="invoice-type-group"
>
<Radio.Button value={InvoiceType.PERSONAL}></Radio.Button>
<Radio.Button value={InvoiceType.UNIT}></Radio.Button>
</Radio.Group>
</Form.Item>
{/* 个人发票: 个人抬头 */}
{invoiceType === InvoiceType.PERSONAL && (
<Form.Item
label="个人抬头"
name="personalTitle"
rules={[
{ required: true, message: '请输入个人抬头' },
{ max: 50, message: '最多 50 个字符' },
]}
>
<Input placeholder="请输入个人抬头(如本人姓名)" allowClear />
</Form.Item>
)}
{/* 单位发票: 单位全称 + 税号 */}
{invoiceType === InvoiceType.UNIT && (
<>
<Form.Item
label="单位全称"
name="unitName"
rules={[
{ required: true, message: '请输入单位全称' },
{ max: 100, message: '最多 100 个字符' },
]}
>
<Input placeholder="请输入单位全称(发票抬头)" allowClear />
</Form.Item>
<Form.Item
label="税号"
name="taxNumber"
rules={[
{ required: true, message: '请输入纳税人识别号' },
{
pattern: /^[A-Z0-9]{15,20}$/,
message: '税号为 15-20 位数字或大写字母',
},
]}
normalize={(v: string) => (v ? v.toUpperCase() : v)}
>
<Input
placeholder="请输入纳税人识别号(15-20 位)"
maxLength={20}
allowClear
/>
</Form.Item>
</>
)}
<Form.Item className="submit-item">
<Button
type="primary"
htmlType="submit"
block
size="large"
loading={submitting}
className="submit-btn"
>
</Button>
</Form.Item>
</Form>
</div>
</div>
)
}
export default Invoice

View File

@ -1,7 +1,7 @@
.login-page { .login-page {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background: #f5f5f5; background: var(--page-bg);
} }
.login-header { .login-header {
@ -79,22 +79,34 @@
height: 48px; height: 48px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 8px; border-radius: var(--radius-sm);
margin-top: 8px; margin-top: 8px;
background: var(--brand-gradient) !important;
border: none !important;
color: var(--text-on-brand) !important;
}
.login-btn:hover,
.login-btn:focus {
background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
} }
.register-btn { .register-btn {
height: 44px; height: 44px;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
border-radius: 8px; border-radius: var(--radius-sm);
border-color: #667eea; border-color: var(--brand-color) !important;
color: #667eea; color: var(--brand-color) !important;
background: var(--surface-bg) !important;
} }
.register-btn:hover { .register-btn:hover,
border-color: #764ba2; .register-btn:focus {
color: #764ba2; border-color: var(--brand-color-dark) !important;
color: var(--brand-color-dark) !important;
background: var(--brand-color-light) !important;
} }
/* 移动端适配 */ /* 移动端适配 */

View File

@ -1,7 +1,7 @@
.member-rules-page { .member-rules-page {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background: #f5f5f5; background: var(--page-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -14,13 +14,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
background: linear-gradient(135deg, #8b2632 0%, #5d1a22 100%); background: var(--brand-gradient);
color: #fff; color: var(--text-on-brand);
box-shadow: 0 2px 8px rgba(139, 38, 50, 0.18); box-shadow: 0 2px 8px rgba(139, 38, 50, 0.18);
} }
.rules-header .back-btn, .rules-header .back-btn {
.rules-header .reload-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
color: #fff !important; color: #fff !important;
@ -29,8 +28,7 @@
background: transparent !important; background: transparent !important;
} }
.rules-header .back-btn:hover, .rules-header .back-btn:hover {
.rules-header .reload-btn:hover {
background: rgba(255, 255, 255, 0.12) !important; background: rgba(255, 255, 255, 0.12) !important;
} }
@ -44,136 +42,206 @@
color: #fff; color: #fff;
} }
/* 提示工具栏 */ .header-spacer {
.rules-toolbar { width: 36px;
padding: 10px 16px; height: 36px;
background: #fff7ed;
border-bottom: 1px solid #fde0c4;
font-size: 12px;
color: #ad6800;
text-align: center;
} }
.rules-toolbar-tip { /* 文档主体 */
display: inline-flex; .rules-doc {
flex-wrap: wrap;
justify-content: center;
gap: 4px;
}
.toolbar-link {
color: #8b2632;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
}
.toolbar-link:hover {
opacity: 0.85;
}
/* PDF 预览容器 */
.rules-viewer {
flex: 1; flex: 1;
margin: 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-height: calc(100vh - 220px);
display: flex;
}
.rules-iframe {
width: 100%; width: 100%;
height: 100%; max-width: 760px;
min-height: calc(100vh - 220px);
border: none;
background: #fff;
}
/* 兜底视图 */
.rules-fallback {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 24px;
text-align: center;
}
.fallback-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.55;
}
.fallback-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
}
.fallback-desc {
font-size: 14px;
color: #888;
margin: 0 0 24px;
line-height: 1.6;
max-width: 320px;
}
.fallback-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
/* 底部操作 */
.rules-actions {
display: flex;
gap: 12px;
padding: 12px 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
position: sticky;
bottom: 0;
}
.rules-actions .ant-btn {
height: 44px;
font-size: 15px;
border-radius: 8px;
}
.rules-actions .ant-btn-primary {
background: #8b2632;
border-color: #8b2632;
}
.rules-actions .ant-btn-primary:hover {
background: #a32a39 !important;
border-color: #a32a39 !important;
}
/* iPhone 安全区域 */
@supports (padding: max(0px)) {
.rules-actions {
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
}
/* 大屏适配 */
@media screen and (min-width: 768px) {
.rules-viewer {
margin: 16px auto; margin: 16px auto;
max-width: 960px; padding: 28px 22px 40px;
width: calc(100% - 32px); background: var(--surface-bg);
} border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
font-size: 15px;
line-height: 1.85;
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "STSong", "Songti SC", serif;
}
.rules-title { /* 公文头(标题 + 文号) */
font-size: 20px; .doc-banner {
text-align: center;
padding-bottom: 14px;
border-bottom: 2.5px solid var(--brand-color);
margin-bottom: 24px;
}
.banner-line {
font-size: 24px;
font-weight: 700;
color: var(--brand-color);
letter-spacing: 4px;
line-height: 1.4;
}
.banner-no {
font-size: 14px;
color: var(--brand-color-dark);
margin-top: 6px;
letter-spacing: 1px;
}
/* 决议标题 */
.doc-decision-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
line-height: 1.6;
margin: 24px 0 18px;
letter-spacing: 1px;
}
/* 普通段落 */
.doc-paragraph {
margin: 0 0 12px 0;
text-indent: 2em;
color: var(--text-primary);
}
/* 落款 */
.doc-sign {
margin: 24px 0 8px;
text-align: right;
line-height: 1.9;
font-size: 15px;
color: var(--text-primary);
padding-right: 12px;
}
/* 分隔线 */
.doc-divider {
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(139, 38, 50, 0.25) 50%,
transparent 100%
);
margin: 32px 0;
}
/* 正文大标题 */
.doc-title {
font-size: 22px;
font-weight: 700;
color: var(--brand-color-dark);
text-align: center;
letter-spacing: 3px;
margin: 0 0 24px;
line-height: 1.5;
}
/* 章节 */
.doc-chapter {
margin-bottom: 18px;
}
.chapter-title {
font-size: 17px;
font-weight: 700;
color: var(--brand-color);
text-align: center;
letter-spacing: 2px;
margin: 22px 0 14px;
line-height: 1.5;
}
/* 条款 */
.doc-article {
margin-bottom: 10px;
}
.article-text {
margin: 0;
text-indent: 2em;
color: var(--text-primary);
}
.article-no {
font-weight: 600;
color: var(--brand-color-dark);
margin-right: 2px;
}
/* 子条款列表 */
.article-items {
list-style: none;
padding: 4px 0 4px 2em;
margin: 4px 0 8px;
}
.article-items li {
padding: 2px 0;
color: var(--text-primary);
text-indent: 0;
}
/* 文末 */
.doc-footer-tip {
margin-top: 32px;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
letter-spacing: 4px;
}
/* iPhone 安全区 */
@supports (padding: max(0px)) {
.rules-doc {
margin-bottom: max(16px, env(safe-area-inset-bottom));
}
}
/* 移动端窄屏 */
@media screen and (max-width: 375px) {
.rules-doc {
margin: 12px;
padding: 22px 16px 32px;
font-size: 14px;
}
.banner-line {
font-size: 20px;
letter-spacing: 2px;
}
.doc-title {
font-size: 19px;
letter-spacing: 2px;
}
.chapter-title {
font-size: 16px;
}
.doc-decision-title {
font-size: 16px;
}
}
/* 大屏 */
@media screen and (min-width: 768px) {
.rules-doc {
padding: 40px 56px 56px;
font-size: 16px;
}
.banner-line {
font-size: 28px;
}
.doc-title {
font-size: 26px;
}
.chapter-title {
font-size: 19px;
} }
} }

View File

@ -1,80 +1,226 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Button, message } from 'antd' import { Button } from 'antd'
import { import { ArrowLeftOutlined } from '@ant-design/icons'
ArrowLeftOutlined,
DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import './MemberRules.css' import './MemberRules.css'
/** /** 章节数据结构 */
* PDF interface Article {
* - public/member-rules.pdf , /member-rules.pdf 访 /** 条款编号(用于显示) */
* - url query ,便: /member-rules?src=https://xxx.pdf no: string
*/ /** 条款标题/正文(可含子条款) */
const DEFAULT_PDF_URL = '/member-rules.pdf' text: string
/** 子条款 */
items?: string[]
}
interface Chapter {
/** 章节编号 */
no: string
/** 章节标题 */
title: string
articles: Article[]
}
const CHAPTERS: Chapter[] = [
{
no: '第一章',
title: '总则',
articles: [
{
no: '第一条',
text: '为规范金华市中医药学会(以下简称"学会")会员管理,根据《金华市中医药学会章程》的有关规定,结合实际情况,特制定本管理办法。',
},
{ no: '第二条', text: '学会会员的入会、退会和日常活动的管理,适用本办法。' },
{ no: '第三条', text: '学会会员设个人会员和单位会员。' },
{
no: '第四条',
text: '学会会员管理,遵循严格审批、入会自愿、退会自由的原则。',
},
{
no: '第五条',
text: '学会会员遵守国家法律法规和《金华市中医药学会章程》的规定,积极参加学会活动,维护学会名誉,为促进中医药科技进步和健康中国作贡献。',
},
],
},
{
no: '第二章',
title: '个人会员',
articles: [
{
no: '第六条',
text: '申请加入本会的个人会员,必须具备下列条件:',
items: [
'(一)自愿加入本会,拥护本会章程;',
'(二)积极参加本会组织的各项活动;',
'(三)在中医药医疗、教学、科研、生产、经营等部门取得中级或以上技术职称者;',
'(四)获得执业中医(药)师,从事中医药工作三年以上并有一定的学术水平、工作经验和一技之长者;',
'(五)西医学习中医以及其他科技人员,从事中医药工作三年以上并有一定成绩者;',
'(六)从事中医药管理工作者;',
'(七)中医药事业爱好者。',
],
},
{
no: '第七条',
text: '个人会员入会程序',
items: [
'(一)填写入会申请表(线上注册),申请入会;',
'(二)缴纳会费;',
'(三)经金华市中医药学会审核通过。',
],
},
{
no: '第八条',
text: '个人会员享有以下权利',
items: [
'(一)本会的选举权、被选举权和表决权;',
'(二)获得本会服务的优先权;',
'(三)对本会工作的批评建议权和监督权;',
'(四)优先参加本会和专科分会组织的各类学术活动及基层培训;',
'(五)优先取得本会有关学术资料和会议信息;',
'(六)优先取得本会的科研成果、科技奖励等各类奖项的申报权;',
'(七)免费获得本会编辑出版的学术资料、内部刊物和学术活动计划;',
'(八)优先推荐专科分会委员、青年委员候选人;',
'(九)入会自愿,退会自由。',
],
},
{
no: '第九条',
text: '个人会员应履行以下义务',
items: [
'(一)遵守本会章程,执行本会的决议、决定;',
'(二)维护本会合法权益;',
'(三)按规定缴纳会费;',
'(四)完成本会交办的工作;',
'(五)向本会反映情况,提供有关资料;',
'(六)参加本会组织的各项学术、咨询、义诊和科普活动。',
],
},
],
},
{
no: '第三章',
title: '单位会员',
articles: [
{
no: '第十条',
text: '凡具有一定数量中级以上职称的科技人员的中医药医疗、教育、科研、药品生产以及健康服务等相关领域的企、事业单位和依法登记的社会组织,承认本会章程,愿意参加本会活动,支持本会工作,均可成为本会单位会员。',
},
{
no: '第十一条',
text: '单位会员入会程序',
items: [
'(一)填写入会申请表(线上注册),申请入会;',
'(二)缴纳会费;',
'(三)经金华市中医药学会审核通过;',
'(四)授予单位会员证书。',
],
},
{
no: '第十二条',
text: '单位会员享有的权利',
items: [
'(一)优先参加本会组织的各类学术活动;',
'(二)免费获得本会编辑出版的学术资料、科普杂志、每年度工作总结和学术活动计划;',
'(三)优先取得本会的科研成果、科技奖励等各类奖项的申报权、相关信息技术咨询服务;',
'(四)优先参加学会和专科分会组织的基层培训、帮扶等;',
'(五)对本会工作提出意见和建议;',
'(六)入会自愿,退会自由。',
],
},
{
no: '第十三条',
text: '单位会员应履行以下义务',
items: [
'(一)遵守本会章程,执行本会的决议、决定;',
'(二)维护本会合法权益;',
'(三)按规定缴纳会费;',
'(四)承担本会委托的工作,协助开展有关的学术和科普活动;',
'(五)积极支持鼓励本单位技术人员加入本会并参加本会组织的各项学术、义诊、科普活动和有关社会公益活动;',
'(六)选派 1 名联系人负责与本会联系(若单位法人代表或联系人等信息变更,应及时与本会取得联系以修改信息)。',
],
},
],
},
{
no: '第四章',
title: '会费管理',
articles: [
{
no: '第十四条',
text: '会员缴纳的会费,是确保本会正常运转的主要经费来源。按时缴纳会费,是每个会员应尽的基本义务。',
},
{
no: '第十五条',
text: '会费缴纳标准',
items: [
'(一)个人会员会费标准:每人每年 30 元,五年一交(五年到期后应主动续交会费);',
'(二)单位会员会费标准:事业单位每年 1000 元,企业单位及其他机构每年 2000 元。',
],
},
{
no: '第十六条',
text: '根据国家规定,会员可在自愿的前提下,向本会提供赞助。',
},
{
no: '第十七条',
text: '单位会员应在当年度完成会费缴纳,新入会单位会员应于正式入会后 1 个月之内缴纳会费。',
},
{ no: '第十八条', text: '会费收缴工作由学会财务具体负责。' },
{ no: '第十九条', text: '收取会费,严格使用财政部门的正规票据。' },
],
},
{
no: '第五章',
title: '会费用途',
articles: [
{
no: '第二十条',
text: '会费应本着"取之于会员,用之于会员"的原则管理使用。主要使用范围是:',
items: [
'(一)为会员提供信息和服务的支出;',
'(二)召开会员代表大会、理事会、常务理事会等会议的支出;',
'(三)举办和参与旨在推动学会事业发展的宣传推广、学术研讨等活动的支出;',
'(四)本会办公经费等支出;',
'(五)奖励有学术成就和突出贡献的会员的支出;',
'(六)符合本会宗旨的其它支出。',
],
},
],
},
{
no: '第六章',
title: '取消会员资格的规定和退会程序',
articles: [
{
no: '第二十一条',
text: '取消会员资格的规定和退会程序',
items: [
'(一)会员退会应书面通知本会,且单位会员需交回会员证书;',
'(二)会员退会和除名,已缴纳的会费不予退还;',
'(三)会员 1 年不缴纳会费或不履行会员义务的,视为自动退会;',
'(四)会员从事违反本会章程和有损本会声誉的一切活动,未经本会允许,擅自以本会名义从事商业活动,经组织工作委员会讨论决定,取消其会员资格。被剥夺政治权利者,其会员资格自动取消。',
],
},
],
},
{
no: '第七章',
title: '附则',
articles: [
{
no: '第二十二条',
text: '本办法的制订和修改,须经会员代表大会表决通过有效。',
},
{
no: '第二十三条',
text: '本办法经 2022 年 6 月 25 日第七次会员代表大会表决通过。',
},
],
},
]
const MemberRules = () => { const MemberRules = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [iframeKey, setIframeKey] = useState(0)
const [iframeError, setIframeError] = useState(false)
/** 从 URL 参数中读取自定义 PDF 地址(可选) */
const pdfUrl = useMemo(() => {
const params = new URLSearchParams(window.location.search)
return params.get('src') || DEFAULT_PDF_URL
}, [])
/** 嵌入预览的 URL: 隐藏工具栏 + 适应宽度 */
const previewUrl = useMemo(() => {
// PDF 锚点参数: toolbar=1 显示工具栏, view=FitH 横向铺满
return `${pdfUrl}#toolbar=1&navpanes=0&view=FitH`
}, [pdfUrl])
/** 检测 PDF 是否能正常加载(简单 HEAD 请求) */
useEffect(() => {
let cancelled = false
fetch(pdfUrl, { method: 'HEAD' })
.then((res) => {
if (cancelled) return
if (!res.ok) {
setIframeError(true)
}
})
.catch(() => {
if (!cancelled) setIframeError(true)
})
return () => {
cancelled = true
}
}, [pdfUrl])
/** 在浏览器新标签打开 PDF */
const handleOpenInBrowser = () => {
window.open(pdfUrl, '_blank', 'noopener,noreferrer')
}
/** 下载 PDF */
const handleDownload = () => {
try {
const a = document.createElement('a')
a.href = pdfUrl
a.download = '金华市中医药学会会员管理办法.pdf'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch (e) {
console.error('[下载失败]', e)
message.error('下载失败,请尝试在浏览器中打开')
}
}
/** 重新加载 iframe */
const handleReload = () => {
setIframeError(false)
setIframeKey((k) => k + 1)
}
return ( return (
<div className="member-rules-page"> <div className="member-rules-page">
@ -86,68 +232,61 @@ const MemberRules = () => {
className="back-btn" className="back-btn"
/> />
<h1 className="rules-title"></h1> <h1 className="rules-title"></h1>
<Button <span className="header-spacer" />
type="text"
icon={<ReloadOutlined />}
onClick={handleReload}
className="reload-btn"
aria-label="重新加载"
/>
</div> </div>
<div className="rules-toolbar"> <div className="rules-doc">
<span className="rules-toolbar-tip"> {/* 公文头 */}
, <div className="doc-banner">
<a className="toolbar-link" onClick={handleOpenInBrowser}> <div className="banner-line"></div>
<div className="banner-no">20229 </div>
</a>
<a className="toolbar-link" onClick={handleDownload}>
</a>
</span>
</div> </div>
<div className="rules-viewer"> {/* 决议 */}
{iframeError ? ( <h2 className="doc-decision-title">
<div className="rules-fallback">
<div className="fallback-icon">📄</div> <br />
<p className="fallback-title">线</p>
<p className="fallback-desc"> </h2>
PDF 线, <p className="doc-paragraph">
,,
</p> </p>
<div className="fallback-actions"> <p className="doc-paragraph">:</p>
<Button type="primary" onClick={handleOpenInBrowser}> <div className="doc-sign">
<div></div>
</Button> <div>2022 6 25 </div>
<Button icon={<DownloadOutlined />} onClick={handleDownload}>
PDF
</Button>
</div> </div>
</div>
) : ( <div className="doc-divider" />
<iframe
key={iframeKey} {/* 正文标题 */}
className="rules-iframe" <h2 className="doc-title"></h2>
src={previewUrl}
title="会员管理办法" {/* 章节正文 */}
onError={() => setIframeError(true)} {CHAPTERS.map((chapter) => (
/> <section key={chapter.no} className="doc-chapter">
<h3 className="chapter-title">
{chapter.no} {chapter.title}
</h3>
{chapter.articles.map((article) => (
<div key={article.no} className="doc-article">
<p className="article-text">
<span className="article-no">{article.no} </span>
{article.text}
</p>
{article.items && (
<ul className="article-items">
{article.items.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
)} )}
</div> </div>
))}
</section>
))}
<div className="rules-actions"> <div className="doc-footer-tip"> </div>
<Button block onClick={handleOpenInBrowser}>
</Button>
<Button
block
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
>
PDF
</Button>
</div> </div>
</div> </div>
) )

View File

@ -5,7 +5,7 @@
radial-gradient(circle at 15% 10%, rgba(139, 38, 50, 0.12) 0%, transparent 45%), radial-gradient(circle at 15% 10%, rgba(139, 38, 50, 0.12) 0%, transparent 45%),
radial-gradient(circle at 85% 20%, rgba(193, 124, 74, 0.14) 0%, transparent 50%), radial-gradient(circle at 85% 20%, rgba(193, 124, 74, 0.14) 0%, transparent 50%),
radial-gradient(circle at 50% 100%, rgba(139, 38, 50, 0.10) 0%, transparent 55%), radial-gradient(circle at 50% 100%, rgba(139, 38, 50, 0.10) 0%, transparent 55%),
linear-gradient(180deg, #fbf6ee 0%, #f5ebd8 100%); var(--page-bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
@ -39,7 +39,7 @@
.header { .header {
padding: 48px 20px 32px; padding: 48px 20px 32px;
text-align: center; text-align: center;
color: #5d1a22; color: var(--brand-color-dark);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -63,7 +63,7 @@
font-weight: 600; font-weight: 600;
margin-bottom: 12px; margin-bottom: 12px;
letter-spacing: 4px; letter-spacing: 4px;
color: #5d1a22; color: var(--brand-color-dark);
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6); text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
} }
@ -84,13 +84,13 @@
} }
.login-card { .login-card {
background: #fff; background: var(--surface-bg);
border-radius: 16px; border-radius: var(--radius-lg);
padding: 32px 24px; padding: 32px 24px;
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: var(--shadow-elevated);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
@ -107,16 +107,10 @@
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.login-card.personal::before { .login-card.personal::before,
background: #1890ff; .login-card.company::before,
}
.login-card.company::before {
background: #52c41a;
}
.login-card.rules::before { .login-card.rules::before {
background: #8b2632; background: var(--brand-color);
} }
.login-card:hover { .login-card:hover {
@ -143,16 +137,10 @@
flex-shrink: 0; flex-shrink: 0;
} }
.login-card.personal .card-icon { .login-card.personal .card-icon,
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%); .login-card.company .card-icon,
}
.login-card.company .card-icon {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
}
.login-card.rules .card-icon { .login-card.rules .card-icon {
background: linear-gradient(135deg, #fbeaec 0%, #f3c8ce 100%); background: linear-gradient(135deg, var(--brand-color-light) 0%, #f3c8ce 100%);
} }
.card-content { .card-content {
@ -166,7 +154,7 @@
.card-title { .card-title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
margin: 0; margin: 0;
line-height: 1.3; line-height: 1.3;
word-break: break-word; word-break: break-word;
@ -174,7 +162,7 @@
.card-desc { .card-desc {
font-size: 14px; font-size: 14px;
color: #666; color: var(--text-secondary);
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
word-break: break-word; word-break: break-word;
@ -212,7 +200,7 @@
.contact-title { .contact-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #5d1a22; color: var(--brand-color-dark);
letter-spacing: 1px; letter-spacing: 1px;
} }
@ -237,18 +225,18 @@
.contact-label { .contact-label {
flex-shrink: 0; flex-shrink: 0;
width: 72px; width: 72px;
color: #999; color: var(--text-tertiary);
font-size: 13px; font-size: 13px;
} }
.contact-value { .contact-value {
flex: 1; flex: 1;
color: #333; color: var(--text-primary);
word-break: break-all; word-break: break-all;
} }
.contact-phone { .contact-phone {
color: #8b2632; color: var(--brand-color);
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
letter-spacing: 0.5px; letter-spacing: 0.5px;

View File

@ -28,7 +28,7 @@ const MemberService = () => {
<div className="login-card personal" onClick={handlePersonalLogin}> <div className="login-card personal" onClick={handlePersonalLogin}>
<div className="card-icon"> <div className="card-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64"> <svg viewBox="0 0 1024 1024" width="64" height="64">
<path d="M512 512c119.3 0 216-96.7 216-216S631.3 80 512 80 296 176.7 296 296s96.7 216 216 216zm0 64c-154.6 0-280 125.4-280 280h560c0-154.6-125.4-280-280-280z" fill="#1890ff"/> <path d="M512 512c119.3 0 216-96.7 216-216S631.3 80 512 80 296 176.7 296 296s96.7 216 216 216zm0 64c-154.6 0-280 125.4-280 280h560c0-154.6-125.4-280-280-280z" fill="#8b2632"/>
</svg> </svg>
</div> </div>
<div className="card-content"> <div className="card-content">
@ -45,7 +45,7 @@ const MemberService = () => {
<div className="login-card company" onClick={handleCompanyLogin}> <div className="login-card company" onClick={handleCompanyLogin}>
<div className="card-icon"> <div className="card-icon">
<svg viewBox="0 0 1024 1024" width="64" height="64"> <svg viewBox="0 0 1024 1024" width="64" height="64">
<path d="M160 960V64h704v896H160zm64-64h576V128H224v768zm128-512h320v64H352v-64zm0 160h320v64H352v-64zm0-320h320v64H352v-64z" fill="#52c41a"/> <path d="M160 960V64h704v896H160zm64-64h576V128H224v768zm128-512h320v64H352v-64zm0 160h320v64H352v-64zm0-320h320v64H352v-64z" fill="#8b2632"/>
</svg> </svg>
</div> </div>
<div className="card-content"> <div className="card-content">

View File

@ -1,7 +1,7 @@
.member-valid-page { .member-valid-page {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #f0f9ff 0%, #f5f5f5 100%); background: var(--page-bg);
padding-bottom: 40px; padding-bottom: 40px;
} }
@ -20,7 +20,7 @@
.success-header .ant-result-icon .anticon { .success-header .ant-result-icon .anticon {
font-size: 64px; font-size: 64px;
color: #52c41a; color: var(--brand-color);
} }
.success-header .ant-result-title { .success-header .ant-result-title {
@ -44,10 +44,10 @@
.member-info-card { .member-info-card {
width: 100%; width: 100%;
border-radius: 12px; border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-elevated);
margin-bottom: 20px; margin-bottom: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--brand-gradient);
} }
.member-info-card .ant-card-body { .member-info-card .ant-card-body {
@ -118,21 +118,44 @@
.detail-value.amount { .detail-value.amount {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #ff4d4f; color: var(--danger-color);
} }
.back-home-btn { .back-home-btn {
height: 52px; height: 52px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 8px; border-radius: var(--radius-sm);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--brand-gradient) !important;
border: none; color: var(--text-on-brand) !important;
border: none !important;
margin-top: 8px; margin-top: 8px;
} }
.back-home-btn:hover { .back-home-btn:hover,
background: linear-gradient(135deg, #5568d3 0%, #6a3f91 100%); .back-home-btn:focus {
background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
}
.invoice-btn,
.renew-btn {
height: 52px;
font-size: 16px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--brand-gradient) !important;
color: var(--text-on-brand) !important;
border: none !important;
margin-bottom: 12px;
}
.invoice-btn:hover,
.invoice-btn:focus,
.renew-btn:hover,
.renew-btn:focus {
background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
} }
/* 移动端适配 */ /* 移动端适配 */

View File

@ -1,6 +1,7 @@
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Button, Card, Result } from 'antd' import { Button, Card, Result } from 'antd'
import { CheckCircleOutlined } from '@ant-design/icons' import { CheckCircleOutlined } from '@ant-design/icons'
import { getUserId, getMemberNo } from '../utils/request'
import './MemberValid.css' import './MemberValid.css'
const MemberValid = () => { const MemberValid = () => {
@ -8,9 +9,12 @@ const MemberValid = () => {
const location = useLocation() const location = useLocation()
const memberData = location.state || {} const memberData = location.state || {}
// userId/memberNo 优先取路由透传,其次取 localStorage (刷新页面后仍能展示)
const { const {
memberId = 'MEM20260510001', userId = getUserId(),
memberType = '普通会员', memberNo = getMemberNo(),
memberId = '',
memberType = '个人会员',
amount = 99.00, amount = 99.00,
validPeriod = '1年' validPeriod = '1年'
} = memberData } = memberData
@ -32,6 +36,24 @@ const MemberValid = () => {
navigate('/') navigate('/')
} }
const handleInvoice = () => {
navigate('/invoice', { state: { userId, memberNo, memberId, amount } })
}
const handleRenew = () => {
navigate('/payment', {
state: {
userId,
memberNo,
memberId,
memberType,
amount,
validPeriod,
isRenew: true,
},
})
}
return ( return (
<div className="member-valid-page"> <div className="member-valid-page">
<div className="success-header"> <div className="success-header">
@ -53,8 +75,8 @@ const MemberValid = () => {
</svg> </svg>
</div> </div>
<div className="badge-text"> <div className="badge-text">
<div className="member-id-label">ID</div> <div className="member-id-label"></div>
<div className="member-id-value">{memberId}</div> <div className="member-id-value">{memberNo || '--'}</div>
</div> </div>
</div> </div>
@ -82,6 +104,26 @@ const MemberValid = () => {
</div> </div>
</Card> </Card>
{/* 续费 */}
<Button
className="renew-btn"
onClick={handleRenew}
block
size="large"
>
</Button>
{/* 填写发票信息 */}
<Button
className="invoice-btn"
onClick={handleInvoice}
block
size="large"
>
</Button>
{/* 返回首页 */} {/* 返回首页 */}
<Button <Button
type="primary" type="primary"

View File

@ -1,7 +1,7 @@
.payment-page { .payment-page {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background: #f5f5f5; background: var(--page-bg);
padding-bottom: 40px; padding-bottom: 40px;
} }
@ -92,6 +92,9 @@
font-size: 14px; font-size: 14px;
color: #333; color: #333;
font-weight: 500; font-weight: 500;
text-align: right;
word-break: break-all;
max-width: 70%;
} }
.amount-item { .amount-item {
@ -101,21 +104,21 @@
.amount-value { .amount-value {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: #ff4d4f; color: var(--danger-color);
} }
.payment-method { .payment-method {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
background: #e6f4ff; background: var(--brand-color-light);
border-radius: 8px; border-radius: var(--radius-sm);
border: 2px solid #1677FF; border: 2px solid var(--brand-color);
} }
.alipay-method { .alipay-method {
background: #e6f4ff; background: var(--brand-color-light);
border-color: #1677FF; border-color: var(--brand-color);
} }
.method-icon { .method-icon {
@ -153,20 +156,22 @@
height: 52px; height: 52px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
border-radius: 8px; border-radius: var(--radius-sm);
margin-top: 8px; margin-top: 8px;
background: #1677FF; background: var(--brand-gradient) !important;
border-color: #1677FF; border: none !important;
color: var(--text-on-brand) !important;
} }
.pay-btn:hover { .pay-btn:hover,
background: #0958d9 !important; .pay-btn:focus {
border-color: #0958d9 !important; background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
} }
.alipay-btn { .alipay-btn {
background: #1677FF; background: var(--brand-gradient) !important;
border-color: #1677FF; border: none !important;
} }
.payment-tips { .payment-tips {
@ -230,27 +235,6 @@
text-align: center; text-align: center;
} }
.alipay-link-box {
background: #f5f5f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 4px;
}
.alipay-link-label {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.alipay-link-value {
font-size: 13px;
color: #333;
word-break: break-all;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, monospace;
}
.alipay-action-btn { .alipay-action-btn {
height: 44px; height: 44px;
border-radius: 8px; border-radius: 8px;
@ -259,41 +243,23 @@
} }
.alipay-action-btn.primary { .alipay-action-btn.primary {
background: #1677FF; background: var(--brand-gradient) !important;
border-color: #1677FF; border: none !important;
color: var(--text-on-brand) !important;
} }
.alipay-action-btn.primary:hover { .alipay-action-btn.primary:hover,
background: #0958d9 !important; .alipay-action-btn.primary:focus {
border-color: #0958d9 !important; background: var(--brand-gradient-hover) !important;
} color: var(--text-on-brand) !important;
.alipay-divider {
display: flex;
align-items: center;
margin: 8px 0 4px 0;
color: #999;
font-size: 12px;
}
.alipay-divider::before,
.alipay-divider::after {
content: '';
flex: 1;
height: 1px;
background: #f0f0f0;
}
.alipay-divider span {
padding: 0 12px;
} }
.alipay-done-btn { .alipay-done-btn {
color: #1677FF; color: var(--brand-color);
font-size: 14px; font-size: 14px;
height: 36px; height: 36px;
} }
.alipay-done-btn:hover { .alipay-done-btn:hover {
color: #0958d9 !important; color: var(--brand-color-dark) !important;
} }

View File

@ -1,48 +1,88 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Button, Card, message, Modal } from 'antd' import { Button, Card, message, Modal } from 'antd'
import { ArrowLeftOutlined, CopyOutlined, GlobalOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, GlobalOutlined } from '@ant-design/icons'
import {
createRechargeAlipayH5Order,
type RechargeAlipayH5OrderResult,
} from '../api/payment'
import './Payment.css' import './Payment.css'
/**
* ()
* TODO: 上线前改回 orderInfo.amount
*/
const PAY_AMOUNT = 0.01
/** 从后端不同形态返回中提取支付跳转 URL */
const extractPayUrl = (res: RechargeAlipayH5OrderResult): string => {
if (!res) return ''
if (typeof res === 'string') return res
return (
res.payUrl ||
res.payURL ||
res.url ||
res.body ||
res.form ||
''
)
}
const Payment = () => { const Payment = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
/** 后端返回的支付宝跳转 URL(点击支付后才拿到) */
const [alipayUrl, setAlipayUrl] = useState<string>('')
// 从路由状态获取订单信息(支持从登录页或注册页跳转) // 从路由状态获取订单信息(支持从登录页或注册页跳转)
const stateData = location.state || {} const stateData = location.state || {}
// 订单信息 // 订单信息
const orderInfo = { const orderInfo = {
orderId: 'ORD' + Date.now(),
memberId: stateData.memberId || '', memberId: stateData.memberId || '',
memberType: stateData.memberType || '普通会员', memberType: stateData.memberType || '普通会员',
amount: stateData.amount || 99.00, amount: stateData.amount || 99.0,
validPeriod: stateData.validPeriod || '1年', validPeriod: stateData.validPeriod || '1年',
isRenew: stateData.isRenew || false // 是否是续费 isRenew: stateData.isRenew || false, // 是否是续费
} }
// 构建支付宝支付链接(实际项目中应由后端返回) // 订单标题
const buildAlipayUrl = () => { const orderSubject = orderInfo.isRenew
const params = new URLSearchParams({ ? '会员续费'
orderId: orderInfo.orderId, : `${orderInfo.memberType}开通`
amount: orderInfo.amount.toFixed(2),
subject: orderInfo.isRenew ? '会员续费' : `${orderInfo.memberType}开通` // 点击支付宝支付按钮: 调后端创建订单,拿到支付链接后弹出引导弹窗
const handlePayment = async () => {
if (loading) return
setLoading(true)
try {
const res = await createRechargeAlipayH5Order({
amount: PAY_AMOUNT, // 测试阶段 0.01,上线前换回 orderInfo.amount
subject: orderSubject,
}) })
// 模拟支付宝支付链接,实际应替换为后端返回的真实地址 const url = extractPayUrl(res)
return `https://qr.alipay.com/pay?${params.toString()}` if (!url) {
message.error('获取支付链接失败,请重试')
return
} }
setAlipayUrl(url)
const alipayUrl = buildAlipayUrl()
// 点击支付宝支付按钮,弹出引导用户在浏览器中打开支付页面
const handlePayment = () => {
setModalVisible(true) setModalVisible(true)
} catch (e) {
// request 拦截器已统一提示错误
console.error('[createRechargeAlipayH5Order]', e)
} finally {
setLoading(false)
}
} }
// 在浏览器中打开支付页面 // 在浏览器中打开支付页面
const handleOpenInBrowser = () => { const handleOpenInBrowser = () => {
if (!alipayUrl) {
message.warning('支付链接为空,请重新发起支付')
return
}
setLoading(true) setLoading(true)
try { try {
const newWindow = window.open(alipayUrl, '_blank') const newWindow = window.open(alipayUrl, '_blank')
@ -58,28 +98,6 @@ const Payment = () => {
} }
} }
// 复制支付链接
const handleCopyLink = async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(alipayUrl)
} else {
// 兼容性处理
const textarea = document.createElement('textarea')
textarea.value = alipayUrl
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
message.success('支付链接已复制,请在浏览器中粘贴打开')
} catch (error) {
message.error('复制失败,请手动复制链接')
}
}
// 用户在外部完成支付后,点击"我已完成支付" // 用户在外部完成支付后,点击"我已完成支付"
const handlePaymentDone = () => { const handlePaymentDone = () => {
setModalVisible(false) setModalVisible(false)
@ -115,16 +133,6 @@ const Payment = () => {
{/* 订单信息 */} {/* 订单信息 */}
<Card title="订单信息" className="payment-card" bordered={false}> <Card title="订单信息" className="payment-card" bordered={false}>
<div className="order-info"> <div className="order-info">
<div className="order-item">
<span className="order-label"></span>
<span className="order-value">{orderInfo.orderId}</span>
</div>
{orderInfo.memberId && (
<div className="order-item">
<span className="order-label">ID</span>
<span className="order-value">{orderInfo.memberId}</span>
</div>
)}
<div className="order-item"> <div className="order-item">
<span className="order-label"></span> <span className="order-label"></span>
<span className="order-value">{orderInfo.memberType}</span> <span className="order-value">{orderInfo.memberType}</span>
@ -135,7 +143,7 @@ const Payment = () => {
</div> </div>
<div className="order-item amount-item"> <div className="order-item amount-item">
<span className="order-label">{orderInfo.isRenew ? '续费金额' : '应付金额'}</span> <span className="order-label">{orderInfo.isRenew ? '续费金额' : '应付金额'}</span>
<span className="order-value amount-value">¥{orderInfo.amount.toFixed(2)}</span> <span className="order-value amount-value">¥{PAY_AMOUNT.toFixed(2)}</span>
</div> </div>
</div> </div>
</Card> </Card>
@ -165,10 +173,11 @@ const Payment = () => {
type="primary" type="primary"
className="pay-btn alipay-btn" className="pay-btn alipay-btn"
onClick={handlePayment} onClick={handlePayment}
loading={loading}
block block
size="large" size="large"
> >
¥{orderInfo.amount.toFixed(2)} ¥{PAY_AMOUNT.toFixed(2)}
</Button> </Button>
<p className="payment-tips"> <p className="payment-tips">
@ -191,11 +200,6 @@ const Payment = () => {
,, ,,
</p> </p>
<div className="alipay-link-box">
<div className="alipay-link-label"></div>
<div className="alipay-link-value">{alipayUrl}</div>
</div>
<Button <Button
type="primary" type="primary"
icon={<GlobalOutlined />} icon={<GlobalOutlined />}
@ -208,20 +212,6 @@ const Payment = () => {
</Button> </Button>
<Button
icon={<CopyOutlined />}
className="alipay-action-btn"
onClick={handleCopyLink}
block
size="large"
>
</Button>
<div className="alipay-divider">
<span></span>
</div>
<Button <Button
type="link" type="link"
className="alipay-done-btn" className="alipay-done-btn"

View File

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, message, Card } from 'antd' import { Form, Input, Button, message, Card } from 'antd'
import { ArrowLeftOutlined, UserOutlined, LockOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, UserOutlined, LockOutlined } from '@ant-design/icons'
import { loginPersonal } from '../api/auth' import { loginPersonal } from '../api/auth'
import { setToken } from '../utils/request' import { setToken, setUserId, setMemberNo } from '../utils/request'
import './Login.css' import './Login.css'
const PersonalLogin = () => { const PersonalLogin = () => {
@ -26,41 +26,45 @@ const PersonalLogin = () => {
setToken(accessToken) setToken(accessToken)
} }
// TODO: 待 yapi 返回字段明确后,调用 /member/status 查询会员状态决定跳转 // 保存后端返回的 userId (会员权益页面需要展示)
// 当前调试阶段,使用 mock 状态检查逻辑 const userId = (result?.userId as string) || ''
const memberStatus = { if (userId) {
isPaid: result?.isPaid ?? false, setUserId(userId)
isExpired: result?.isExpired ?? false, }
memberId: (result?.memberId as string) || 'MEM20260510001',
memberType: '普通会员', // 保存后端返回的 memberNo (会员编号,发票接口路径参数也使用)
const memberNo = (result?.memberNo as string) || ''
if (memberNo) {
setMemberNo(memberNo)
}
// 根据后端返回的 isVip 判断会员状态
// - isVip = true: 已充值会员 -> 会员权益页(/member-valid)
// - isVip = false/未返回: 未充值 -> 支付页(/payment)
const isVip = result?.isVip === true
const memberInfo = {
userId,
memberNo,
memberId: (result?.memberId as string) || '',
memberType: '个人会员',
amount: 99.0, amount: 99.0,
validPeriod: '1年', validPeriod: '1年',
expireDate: '2027-05-10',
} }
message.success('登录成功!') message.success('登录成功!')
// 根据会员状态跳转
setTimeout(() => { setTimeout(() => {
if (!memberStatus.isPaid || memberStatus.isExpired) { if (isVip) {
// 未支付或已过期,跳转至待支付页面 // 已充值 -> 会员权益页
navigate('/payment', { navigate('/member-valid', {
state: { state: memberInfo,
memberId: memberStatus.memberId,
memberType: memberStatus.memberType,
amount: memberStatus.amount,
validPeriod: memberStatus.validPeriod,
isRenew: memberStatus.isExpired,
},
}) })
} else { } else {
// 会员有效,跳转至会员有效页面 // 未充值 -> 支付页
navigate('/member-valid', { navigate('/payment', {
state: { state: {
memberId: memberStatus.memberId, ...memberInfo,
memberType: memberStatus.memberType, isRenew: false,
amount: memberStatus.amount,
validPeriod: memberStatus.validPeriod,
}, },
}) })
} }
@ -96,7 +100,8 @@ const PersonalLogin = () => {
onFinish={onFinish} onFinish={onFinish}
size="large" size="large"
initialValues={{ initialValues={{
phone: '13800138000', // 与个人会员注册默认字段保持一致,方便联调调试
phone: '13800000001',
password: '123456' password: '123456'
}} }}
> >

View File

@ -10,6 +10,8 @@ import {
PoliticalStatusLabel, PoliticalStatusLabel,
EducationLevel, EducationLevel,
EducationLevelLabel, EducationLevelLabel,
ProfessionalTitle,
ProfessionalTitleLabel,
RegionOrUnit, RegionOrUnit,
RegionOrUnitLabel, RegionOrUnitLabel,
PersonalMemberType, PersonalMemberType,
@ -31,19 +33,19 @@ const enumToOptions = <T extends string>(labelMap: Record<T, string>) =>
const genderOptions = enumToOptions(GenderLabel) const genderOptions = enumToOptions(GenderLabel)
const politicalStatusOptions = enumToOptions(PoliticalStatusLabel) const politicalStatusOptions = enumToOptions(PoliticalStatusLabel)
const educationOptions = enumToOptions(EducationLevelLabel) const educationOptions = enumToOptions(EducationLevelLabel)
const titleOptions = enumToOptions(ProfessionalTitleLabel)
const regionOptions = enumToOptions(RegionOrUnitLabel) const regionOptions = enumToOptions(RegionOrUnitLabel)
const memberTypeOptions = enumToOptions(PersonalMemberTypeLabel) const memberTypeOptions = enumToOptions(PersonalMemberTypeLabel)
// 地区(协会会长、秘书长场景使用,沿用原有省内地区) // 地区(地区中医药学/协会会长、秘书长场景使用,金华市内区县)
const cityOptions = [ const cityOptions = [
'杭州市', '宁波市', '湖州市', '嘉兴市', '绍兴市', '舟山市', '婺城区', '金东区', '开发区', '义乌市', '兰溪市', '东阳市',
'温州市', '台州市', '丽水市', '金华市', '衢州市', '永康市', '浦江县', '武义县', '磐安县',
] ]
/** 表单字段类型: 包含 PersonalRegisterParams + UI 辅助字段 */ /** 表单字段类型: 包含 PersonalRegisterParams + UI 辅助字段 */
type FormValues = PersonalRegisterParams & { type FormValues = PersonalRegisterParams & {
confirmPassword?: string confirmPassword?: string
branchCommittee?: string
cityAssociation?: string cityAssociation?: string
} }
@ -63,12 +65,17 @@ const PersonalRegister = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
confirmPassword, confirmPassword,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
branchCommittee,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
cityAssociation, cityAssociation,
...payload ...payload
} = values } = values
// 非专科分会委员不传 specialCommitteeMemberType
if (
payload.memberType !== PersonalMemberType.SPECIAL_COMMITTEE_MEMBER
) {
delete payload.specialCommitteeMemberType
}
// 调用真实注册接口 // 调用真实注册接口
// 后端约定: success === true 表示成功, 返回值是 data 字段内容 // 后端约定: success === true 表示成功, 返回值是 data 字段内容
// 业务失败会被拦截器统一 reject + message.error,走 catch // 业务失败会被拦截器统一 reject + message.error,走 catch
@ -81,23 +88,11 @@ const PersonalRegister = () => {
setToken(accessToken) setToken(accessToken)
} }
message.success('注册成功!') message.success('注册成功,请登录!')
// 跳转到支付页面 // 注册成功后跳转到个人会员登录页,由用户主动登录
setTimeout(() => { setTimeout(() => {
navigate('/payment', { navigate('/personal-login')
state: {
memberId: result?.memberId,
phone: payload.phone,
memberType: PersonalMemberTypeLabel[
(payload.memberType as PersonalMemberType) ||
PersonalMemberType.ORDINARY_MEMBER
],
amount: 99.0,
validPeriod: '1年',
isRenew: false,
},
})
}, 500) }, 500)
} catch (error) { } catch (error) {
// 请求拦截器已统一 message.error // 请求拦截器已统一 message.error
@ -110,7 +105,7 @@ const PersonalRegister = () => {
const handleMemberTypeChange = (value: PersonalMemberType) => { const handleMemberTypeChange = (value: PersonalMemberType) => {
setMemberType(value) setMemberType(value)
if (value !== PersonalMemberType.SPECIAL_COMMITTEE_MEMBER) { if (value !== PersonalMemberType.SPECIAL_COMMITTEE_MEMBER) {
form.setFieldValue('branchCommittee', undefined) form.setFieldValue('specialCommitteeMemberType', undefined)
} }
if (value !== PersonalMemberType.REGIONAL_ASSOCIATION_LEADER) { if (value !== PersonalMemberType.REGIONAL_ASSOCIATION_LEADER) {
form.setFieldValue('cityAssociation', undefined) form.setFieldValue('cityAssociation', undefined)
@ -141,18 +136,19 @@ const PersonalRegister = () => {
name: '张三', name: '张三',
phone: '13800000001', phone: '13800000001',
email: 'demo@example.com', email: 'demo@example.com',
zipCode: '310000', zipCode: '321000',
gender: Gender.MALE, gender: Gender.MALE,
identityCard: '330101199001011234', identityCard: '330701199001011234',
politicalStatus: PoliticalStatus.CPC_MEMBER, politicalStatus: PoliticalStatus.CPC_MEMBER,
educationLevel: EducationLevel.DOCTORAL, educationLevel: EducationLevel.DOCTORAL,
title: '主任医师', title: ProfessionalTitle.CHIEF_PHYSICIAN,
position: '科室负责人', position: '科室负责人',
regionOrUnit: RegionOrUnit.HANGZHOU, regionOrUnit: RegionOrUnit.JINHUA_TCM_HOSPITAL,
unitName: '浙江省中医院', unitName: '金华市中医医院',
unitAddress: '杭州市西湖区邮电路54号', unitAddress: '金华市婺城区陕西路439号',
// 会员类型 // 会员类型
memberType: PersonalMemberType.SPECIAL_COMMITTEE_MEMBER, memberType: PersonalMemberType.SPECIAL_COMMITTEE_MEMBER,
specialCommitteeMemberType: '内科分会',
// 密码 // 密码
password: '123456', password: '123456',
confirmPassword: '123456', confirmPassword: '123456',
@ -246,9 +242,19 @@ const PersonalRegister = () => {
<Form.Item <Form.Item
label="职称" label="职称"
name="title" name="title"
rules={[{ max: 50, message: '职称最长 50 个字符' }]}
> >
<Input placeholder="请输入职称" maxLength={50} /> <Select
placeholder="请选择职称"
showSearch
optionFilterProp="children"
allowClear
>
{titleOptions.map((opt) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@ -335,11 +341,11 @@ const PersonalRegister = () => {
</Select> </Select>
</Form.Item> </Form.Item>
{/* 专科分会委员 - 条件显示(UI辅助字段,接口暂不传) */} {/* 专科分会委员 - 条件显示(对接 specialCommitteeMemberType) */}
{memberType === PersonalMemberType.SPECIAL_COMMITTEE_MEMBER && ( {memberType === PersonalMemberType.SPECIAL_COMMITTEE_MEMBER && (
<Form.Item <Form.Item
label="专科分会" label="专科分会"
name="branchCommittee" name="specialCommitteeMemberType"
rules={[{ required: true, message: '请选择或输入专科分会' }]} rules={[{ required: true, message: '请选择或输入专科分会' }]}
> >
<Select <Select

View File

@ -1,7 +1,7 @@
.register-page { .register-page {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
background: #f5f5f5; background: var(--page-bg);
padding-bottom: 40px; padding-bottom: 40px;
} }
@ -47,7 +47,7 @@
} }
.form-card .ant-card-head { .form-card .ant-card-head {
border-bottom: 2px solid #667eea; border-bottom: 2px solid var(--brand-color);
padding: 0 24px; padding: 0 24px;
min-height: 48px; min-height: 48px;
} }
@ -107,20 +107,29 @@
} }
.login-link { .login-link {
color: #1890ff; color: var(--brand-color);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
margin-left: 4px; margin-left: 4px;
} }
.login-link:hover { .login-link:hover {
color: #40a9ff; color: var(--brand-color-dark);
} }
.register-submit-btn { .register-submit-btn {
height: 48px; height: 48px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 8px; border-radius: var(--radius-sm);
margin-top: 8px; margin-top: 8px;
background: var(--brand-gradient) !important;
border: none !important;
color: var(--text-on-brand) !important;
}
.register-submit-btn:hover,
.register-submit-btn:focus {
background: var(--brand-gradient-hover) !important;
color: var(--text-on-brand) !important;
} }

View File

@ -9,6 +9,10 @@ import type { ApiResponse } from '../api/types'
/** 本地存储 token 的 key (存放后端返回的 accessToken) */ /** 本地存储 token 的 key (存放后端返回的 accessToken) */
export const TOKEN_KEY = 'member_login_token' export const TOKEN_KEY = 'member_login_token'
/** 本地存储 userId 的 key (存放后端登录返回的 userId) */
export const USER_ID_KEY = 'member_login_user_id'
/** 本地存储 memberNo 的 key (后端登录返回的会员编号) */
export const MEMBER_NO_KEY = 'member_login_member_no'
/** 获取本地 token (accessToken) */ /** 获取本地 token (accessToken) */
export const getToken = (): string => localStorage.getItem(TOKEN_KEY) || '' export const getToken = (): string => localStorage.getItem(TOKEN_KEY) || ''
@ -19,6 +23,31 @@ export const setToken = (token: string): void => {
/** 清除本地 token */ /** 清除本地 token */
export const clearToken = (): void => { export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(MEMBER_NO_KEY)
}
/** 获取本地 userId */
export const getUserId = (): string => localStorage.getItem(USER_ID_KEY) || ''
/** 保存后端返回的 userId 到本地 */
export const setUserId = (userId: string): void => {
localStorage.setItem(USER_ID_KEY, userId)
}
/** 清除本地 userId */
export const clearUserId = (): void => {
localStorage.removeItem(USER_ID_KEY)
}
/** 获取本地 memberNo (会员编号) */
export const getMemberNo = (): string =>
localStorage.getItem(MEMBER_NO_KEY) || ''
/** 保存后端返回的 memberNo 到本地 */
export const setMemberNo = (memberNo: string): void => {
localStorage.setItem(MEMBER_NO_KEY, memberNo)
}
/** 清除本地 memberNo */
export const clearMemberNo = (): void => {
localStorage.removeItem(MEMBER_NO_KEY)
} }
/** 创建 axios 实例 */ /** 创建 axios 实例 */

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/index.ts","./src/api/member.ts","./src/api/payment.ts","./src/api/types.ts","./src/pages/companylogin.tsx","./src/pages/companyregister.tsx","./src/pages/memberrules.tsx","./src/pages/memberservice.tsx","./src/pages/membervalid.tsx","./src/pages/payment.tsx","./src/pages/personallogin.tsx","./src/pages/personalregister.tsx","./src/utils/request.ts"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/index.ts","./src/api/member.ts","./src/api/payment.ts","./src/api/types.ts","./src/pages/companylogin.tsx","./src/pages/companyregister.tsx","./src/pages/invoice.tsx","./src/pages/memberrules.tsx","./src/pages/memberservice.tsx","./src/pages/membervalid.tsx","./src/pages/payment.tsx","./src/pages/personallogin.tsx","./src/pages/personalregister.tsx","./src/utils/request.ts"],"version":"5.9.3"}