update api fields and add sso

This commit is contained in:
LittleBoy 2024-06-02 01:18:01 +08:00
parent b288cad771
commit 4d917a7e41
19 changed files with 205 additions and 65 deletions

View File

@ -50,7 +50,10 @@
.money-format{
white-space: nowrap;
}
.btn-auth-login{
box-sizing: border-box;
text-decoration: none;
}
.page-content-container {
max-width: 90%;
width: 1400px;

View File

@ -28,7 +28,7 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
params.end_date = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
}
if (value.student_number) {
params.student_name = value.student_number;
params.student_number = value.student_number;
}
// 对账状态
if (value.apply_status) {
@ -48,16 +48,16 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
// 根据语言变化更新订单状态options
const billStatusOptions = useMemo(() => {
return [
{value: `${i18n.language}pending`, label: t('bill.pay_status_pending')},
{value: 'paid', label: t('bill.pay_status_paid')},
{value: 'canceled', label: t('bill.pay_status_canceled')}
{value: `PENDING`, label: t('bill.pay_status_pending')},
{value: 'PAID', label: t('bill.pay_status_paid')},
{value: 'CANCELLED', label: t('bill.pay_status_canceled')}
]
}, [i18n.language])
// 根据语言变化更新对账状态options
const applyStatusOptions = useMemo(() => {
return [
{value: 'pending', label: t('bill.reconciliation_status_pending')},
{value: 'submitted', label: t('bill.reconciliation_status_submitted')}
{value: 'UNCHECKED', label: t('bill.reconciliation_status_pending')},
{value: 'CHECKED', label: t('bill.reconciliation_status_submitted')}
]
}, [i18n.language])
return (<Card style={{marginBottom: 20}}>
@ -81,9 +81,9 @@ const SearchForm: React.FC<SearchFormProps> = (props) => {
<Col span={4}>
<Form.Select showClear field="pay_method" label={t('bill.title_pay_method')}
placeholder={t('base.please_select')} style={{width: '100%'}}>
<Form.Select.Option value="operate">AsiaPay</Form.Select.Option>
<Form.Select.Option value="rd">FlyWire</Form.Select.Option>
<Form.Select.Option value="pm">PPS</Form.Select.Option>
<Form.Select.Option value="ASIAPAY">AsiaPay</Form.Select.Option>
<Form.Select.Option value="FLYWIRE">FlyWire</Form.Select.Option>
<Form.Select.Option value="PPS">PPS</Form.Select.Option>
</Form.Select>
</Col>
<Col span={4}>

View File

@ -1,5 +1,7 @@
import Loader from "@/components/loader";
import React, {createContext, useEffect, useReducer} from "react";
import {auth, getUserInfo} from "@/service/api/user.ts";
import {setAuthToken} from "@/hooks/useAuth.ts";
const AuthContext = createContext<AuthContextType | null>(null)
@ -43,45 +45,69 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
// MOCK INIT DATA
const init = async () => {
setTimeout(() => {
getUserInfo().then(user => {
dispatch({
action: 'init',
payload: {
isInitialized: true,
isLoggedIn: !!user,
user
}
})
}).finally(() => {
dispatch({
payload: {
isInitialized: true,
isLoggedIn: true,
user: {
id: 1,
nickname: 'TU',
department: 'root',
}
}
})
}, 300)
})
return 'initialized'
}
// 登录
const login = async () => {
const login = async (code: string, state: string) => {
const user = await auth(code, state)
// 保存token
setAuthToken(user.token, user.exp)
//
dispatch({
action: 'login',
payload: {
isLoggedIn: true,
user
}
})
}
// 登出
const logout = async () => {
setAuthToken(null)
dispatch({
action: 'logout',
payload: {
isLoggedIn: false,
user: null
}
})
}
const mockLogin = async () => {
setAuthToken('test-123123', Date.now() + 36000 * 1000)
dispatch({
action: 'login',
payload: {
isLoggedIn: true,
user: {
id: 1,
nickname: 'TU',
department: 'root',
token: 'test-123123',
email: 'test@qq.com',
exp: 1,
iat: 1,
iss: "Hong Kong Chu Hai College",
nbf: 1,
type: "id_token",
username: 'test-123123',
}
}
})
}
// 登出
const logout = async () => {
dispatch({
action: 'logout',
payload: {
isLoggedIn: false,
user: null
}
})
}
useEffect(() => {
init().then(console.log)
}, [])
@ -92,7 +118,8 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
}
return (<AuthContext.Provider value={{
...state,
login, logout
login, logout,
mockLogin
}}>{children}</AuthContext.Provider>)
}
export default AuthContext

View File

@ -1,4 +1,4 @@
import { useContext } from 'react';
import {useContext} from 'react';
// auth provider
import AuthContext from '@/contexts/auth';
@ -6,11 +6,37 @@ import AuthContext from '@/contexts/auth';
// ==============================|| AUTH HOOKS ||============================== //
const useAuth = () => {
const context = useContext(AuthContext);
const context = useContext(AuthContext);
if (!context) throw new Error('context must be use inside provider');
if (!context) throw new Error('context must be use inside provider');
return context;
return context;
};
export const setAuthToken = (token: string | null, expiry_time = -1) => {
if (!token) {
localStorage.removeItem(AppConfig.AUTH_TOKEN_KEY);
return;
}
localStorage.setItem(AppConfig.AUTH_TOKEN_KEY, JSON.stringify({
token, expiry_time
}));
}
export const getAuthToken = () => {
const data = localStorage.getItem(AppConfig.AUTH_TOKEN_KEY);
if (!data) return;
try {
const {token, expiry_time} = JSON.parse(data) as { token: string, expiry_time: number };
if (expiry_time != -1 && expiry_time < Date.now()) {
localStorage.removeItem(AppConfig.AUTH_TOKEN_KEY);
return;
}
return token;
} catch (_e) {
return;
}
}
export default useAuth;

View File

@ -9,7 +9,7 @@
"student_number": "学号"
},
"bill": {
"bill_date": "日期",
"bill_date": "支付日期",
"bill_number": "账单编号",
"cancel": "作废",
"cancel_confirm": "确定作废此账单",

View File

@ -9,7 +9,7 @@
"student_number": "學號"
},
"bill": {
"bill_date": "日期",
"bill_date": "支付日期",
"bill_number": "帳單編號",
"cancel": "作廢",
"cancel_confirm": "確定作廢此帳單",

View File

@ -1,10 +1,14 @@
import {Button, Space, Typography} from "@douyinfe/semi-ui";
import {Button, Space, Spin, Typography} from "@douyinfe/semi-ui";
import {useTranslation} from 'react-i18next';
import useConfig from "@/hooks/useConfig.ts";
import {useEffect} from "react";
import {useSetState} from "ahooks";
import styled from "@emotion/styled";
import {useNavigate, useSearchParams} from "react-router-dom";
import useConfig from "@/hooks/useConfig.ts";
import AppLogo from "@/assets/AppLogo";
import {useNavigate} from "react-router-dom";
import useAuth from "@/hooks/useAuth.ts";
import {getAppUrl} from "@/hooks/useAppUrl";
const LoginContainer = styled.div({
@ -22,14 +26,49 @@ const SubmitContainer = styled.div({
width: 300,
margin: '70px auto 20px'
})
const LoginURL = getAppUrl() + '/login?action=auth'
const AuthLogin = () => {
const { login } = useAuth();
const {login, mockLogin} = useAuth();
const navigate = useNavigate();
const [state, setState] = useSetState({
showLogin: false,
loading: false
})
const {t} = useTranslation();
const {appName} = useConfig()
const [query] = useSearchParams()
const showDashboard = ()=> {
login().then(()=>{
useEffect(() => {
const code = query.get('code'),
state = query.get('state');
if (query.get('action') == 'auth' && state && code) {
setState({loading: true})
// 授权回调
login(code, state).then(() => {
navigate('/dashboard')
}).catch(() => {
// 获取登录凭证失败 重新显示登录按钮
setTimeout(() => {
setState({showLogin: true})
}, 2000)
}).finally(() => {
setTimeout(() => {
setState({loading: false})
}, 2000)
})
} else {
// 正常登录 - 显示登录按钮
setState({
showLogin: true
})
}
}, [])
const handleMockLogin = () => {
mockLogin().then(() => {
navigate('/dashboard')
})
}
@ -42,10 +81,19 @@ const AuthLogin = () => {
</Space>
</LogoContainer>
<SubmitContainer>
<Button theme='solid' type='primary' block onClick={showDashboard}>{t('login.submit')}</Button>
<Button style={{marginTop:10}} theme='solid' type='primary' block onClick={()=>{
navigate('/pay')
}}></Button>
{state.loading && <Space>
<Spin/><span>Authing ...</span>
</Space>}
{state.showLogin && <div style={{marginTop: 5}}>
<a
className="btn-auth-login semi-button semi-button-primary semi-button-block"
href={`${AppConfig.SSO_AUTH_URL}/authorize?response_type=code&scope=openid%20profile%20email&client_id=${AppConfig.SSO_AUTH_CLIENT_KEY}&state=${Date.now()}&redirect_uri=${encodeURIComponent(LoginURL)}`}
>
<span className="semi-button-content" x-semi-prop="children">{t('login.submit')}</span>
</a>
<Button theme='solid' type='warning' block onClick={handleMockLogin} style={{marginTop: 5}}>MOCK
LOGIN</Button>
</div>}
</SubmitContainer>
</LoginContainer>)
}

View File

@ -36,6 +36,7 @@ const BillQuery = () => {
<Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button>
</Popconfirm>
<Button onClick={() => setShowBill(bill)} size={'small'} theme={'solid'} type={'primary'}>{t('base.qr-code')}</Button>
{AppMode == 'development' && <a href={`/pay?bill=${bill.id}`} target={'_blank'}>Payment</a>}
</>}
{
bill.status == BillStatus.PAID

View File

@ -40,8 +40,8 @@ const BillReconciliation = () => {
<SearchForm searchHeader={(
<Tabs className={'no-border'} onChange={(apply_status) => setBillQueryParams({apply_status})}>
<TabPane tab={<span>{t('bill.reconciliation_status_pending')}</span>} itemKey="pending"/>
<TabPane tab={<span>{t('bill.reconciliation_status_submitted')}</span>} itemKey="confirmed"/>
<TabPane tab={<span>{t('bill.reconciliation_status_pending')}</span>} itemKey="UNCHECKED"/>
<TabPane tab={<span>{t('bill.reconciliation_status_submitted')}</span>} itemKey="CHECKED"/>
</Tabs>
)} loading={loading} onSearch={setBillQueryParams}/>
<BillList

View File

@ -28,6 +28,7 @@ const HeaderUserProfile = styled.div({
export const HeaderUserAvatar = () => {
const {t} = useTranslation()
const {user, logout} = useAuth()
// const UserAvatar = () => (<Avatar color="orange" size="small">{user?.username.substring(0, 3)}</Avatar>)
return (<Dropdown
position={'bottomRight'}
trigger={'click'}
@ -35,11 +36,12 @@ export const HeaderUserAvatar = () => {
<>
<HeaderUserProfile>
<Space>
<Avatar color="orange" size="small">{user?.nickname}</Avatar>
<Avatar color="orange" size="small">{user?.username.substring(0, 3)}</Avatar>
<div>
<Typography.Title heading={6}>{user?.nickname}</Typography.Title>
<Typography.Text type="quaternary"
size={'small'}>DEPT:{user?.department?.toUpperCase()}</Typography.Text>
<Typography.Title heading={6}>{user?.username}</Typography.Title>
<Typography.Text
type="quaternary"
size={'small'}>{user?.iss?.toUpperCase()}</Typography.Text>
</div>
</Space>
</HeaderUserProfile>
@ -51,7 +53,7 @@ export const HeaderUserAvatar = () => {
}
>
<Avatar color="orange" size="small">{user?.nickname}</Avatar>
<Avatar color="orange" size="small">{user?.username.substring(0, 3)}</Avatar>
</Dropdown>)
}
export const CommonHeader: React.FC<CommonHeaderProps> = ({children, title, rightExtra}) => {
@ -97,7 +99,7 @@ type LayoutProps = {
const LayoutContentContainer = styled.div({
borderRadius: 10,
marginTop:20
marginTop: 20
})
export const BaseLayout: React.FC<LayoutProps> = ({children}) => {
const {t} = useTranslation()

View File

@ -47,9 +47,9 @@ export function cancelBill(id: number) {
}
export function confirmBills(bill_ids: number[]) {
return post(`/api/bills/apply`, {bill_ids})
return post(`/bills/apply`, {bill_ids})
}
export function updateBillPaymentSuccess(billId: number, merchant_ref: string,peyment_channel:string) {
return post(`api/bills/${billId}/finish`, {merchant_ref,peyment_channel})
export function updateBillPaymentSuccess(billId: number, merchant_ref: string,payment_channel:string) {
return post(`/bills/${billId}/finish`, {merchant_ref,payment_channel})
}

14
src/service/api/user.ts Normal file
View File

@ -0,0 +1,14 @@
import {get} from "@/service/request.ts";
export function getUserInfo() {
return get<UserProfile>('/userinfo')
}
/**
* 使 sso codestate换取登录凭证或用户信息
* @param code
* @param state
*/
export function auth(code:string,state:string){
return get<UserProfile>('/auth', {code, state})
}

View File

@ -1,6 +1,7 @@
import axios from 'axios';
import {stringify} from 'qs'
import {BizError} from './types';
import {getAuthToken} from "@/hooks/useAuth.ts";
const JSON_FORMAT: string = 'application/json';
const REQUEST_TIMEOUT = 300000; // 超时时长5min
@ -14,7 +15,7 @@ const Axios = axios.create({
// 请求前拦截
Axios.interceptors.request.use(config => {
const token = localStorage.getItem('payment_front_token');
const token = getAuthToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}

View File

@ -16,4 +16,4 @@ export enum BillStatus {
PENDING= 'PENDING',
PAID = 'PAID',
CANCELED = 'CANCELED',
}
}

13
src/types/auth.d.ts vendored
View File

@ -1,7 +1,13 @@
declare type UserProfile = {
id: string | number;
nickname: string;
department: string;
token: string;
email: string;
exp: number;
iat: number;
iss: string;
nbf: number;
type: string;
username: string;
}
declare interface AuthProps {
@ -16,5 +22,6 @@ declare type AuthContextType = {
isInitialized?: boolean;
user?: UserProfile | null | undefined;
logout: () => Promise<void>;
login: () => Promise<void>;
mockLogin: () => Promise<void>;
login: (code:string,state:string) => Promise<void>;
};

2
src/types/bill.d.ts vendored
View File

@ -21,7 +21,7 @@ declare type BillQueryParam = {
page_number:int;
status:string;
apply_status:string;
student_name:string;
student_number:string;
payment_method:string;
start_date:string;
end_date:string;

0
src/types/user.d.ts vendored Normal file
View File

7
src/vite-env.d.ts vendored
View File

@ -12,7 +12,14 @@ declare const AppConfig: {
API_PREFIX: string;
// flywire 支付网关
FIY_WIRE_GATEWAY: string;
// sso 认证地址
SSO_AUTH_URL: string;
// sso 认证客户端 key
SSO_AUTH_CLIENT_KEY: string;
// 登录凭证 token key
AUTH_TOKEN_KEY: string;
};
declare const AppMode: 'test' | 'production' | 'development';
declare type BasicComponentProps = {
children?: React.ReactNode;

View File

@ -13,7 +13,11 @@ export default defineConfig(({mode}) => {
SITE_URL: process.env.APP_SITE_URL || null,
API_PREFIX: process.env.APP_API_PREFIX || '/api',
FIY_WIRE_GATEWAY: process.env.FIY_WIRE_GATEWAY || 'https://gateway.flywire.com/v1/transfers',
SSO_AUTH_URL: process.env.SSO_AUTH_URL || 'https://portal.chuhai.edu.hk',
SSO_AUTH_CLIENT_KEY: process.env.AUTH_CLIENT_KEY || '',
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || 'payment-auth-token',
}),
AppMode: JSON.stringify(mode)
},
resolve: {
alias: {