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

View File

@ -1,5 +1,7 @@
import Loader from "@/components/loader"; import Loader from "@/components/loader";
import React, {createContext, useEffect, useReducer} from "react"; 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) const AuthContext = createContext<AuthContextType | null>(null)
@ -43,45 +45,69 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
// MOCK INIT DATA // MOCK INIT DATA
const init = async () => { const init = async () => {
setTimeout(() => { getUserInfo().then(user => {
dispatch({
action: 'init',
payload: {
isInitialized: true,
isLoggedIn: !!user,
user
}
})
}).finally(() => {
dispatch({ dispatch({
payload: { payload: {
isInitialized: true, isInitialized: true,
isLoggedIn: true,
user: {
id: 1,
nickname: 'TU',
department: 'root',
}
} }
}) })
}, 300) })
return 'initialized' 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({ dispatch({
action: 'login', action: 'login',
payload: { payload: {
isLoggedIn: true, isLoggedIn: true,
user: { user: {
id: 1, id: 1,
nickname: 'TU', token: 'test-123123',
department: 'root', 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(() => { useEffect(() => {
init().then(console.log) init().then(console.log)
}, []) }, [])
@ -92,7 +118,8 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
} }
return (<AuthContext.Provider value={{ return (<AuthContext.Provider value={{
...state, ...state,
login, logout login, logout,
mockLogin
}}>{children}</AuthContext.Provider>) }}>{children}</AuthContext.Provider>)
} }
export default AuthContext export default AuthContext

View File

@ -13,4 +13,30 @@ const useAuth = () => {
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; export default useAuth;

View File

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

View File

@ -9,7 +9,7 @@
"student_number": "學號" "student_number": "學號"
}, },
"bill": { "bill": {
"bill_date": "日期", "bill_date": "支付日期",
"bill_number": "帳單編號", "bill_number": "帳單編號",
"cancel": "作廢", "cancel": "作廢",
"cancel_confirm": "確定作廢此帳單", "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 {useTranslation} from 'react-i18next';
import useConfig from "@/hooks/useConfig.ts"; import {useEffect} from "react";
import {useSetState} from "ahooks";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import {useNavigate, useSearchParams} from "react-router-dom";
import useConfig from "@/hooks/useConfig.ts";
import AppLogo from "@/assets/AppLogo"; import AppLogo from "@/assets/AppLogo";
import {useNavigate} from "react-router-dom";
import useAuth from "@/hooks/useAuth.ts"; import useAuth from "@/hooks/useAuth.ts";
import {getAppUrl} from "@/hooks/useAppUrl";
const LoginContainer = styled.div({ const LoginContainer = styled.div({
@ -22,14 +26,49 @@ const SubmitContainer = styled.div({
width: 300, width: 300,
margin: '70px auto 20px' margin: '70px auto 20px'
}) })
const LoginURL = getAppUrl() + '/login?action=auth'
const AuthLogin = () => { const AuthLogin = () => {
const { login } = useAuth(); const {login, mockLogin} = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [state, setState] = useSetState({
showLogin: false,
loading: false
})
const {t} = useTranslation(); const {t} = useTranslation();
const {appName} = useConfig() const {appName} = useConfig()
const [query] = useSearchParams()
const showDashboard = ()=> { useEffect(() => {
login().then(()=>{ 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') navigate('/dashboard')
}) })
} }
@ -42,10 +81,19 @@ const AuthLogin = () => {
</Space> </Space>
</LogoContainer> </LogoContainer>
<SubmitContainer> <SubmitContainer>
<Button theme='solid' type='primary' block onClick={showDashboard}>{t('login.submit')}</Button> {state.loading && <Space>
<Button style={{marginTop:10}} theme='solid' type='primary' block onClick={()=>{ <Spin/><span>Authing ...</span>
navigate('/pay') </Space>}
}}></Button> {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> </SubmitContainer>
</LoginContainer>) </LoginContainer>)
} }

View File

@ -36,6 +36,7 @@ const BillQuery = () => {
<Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button> <Button size={'small'} theme={'solid'} type={'primary'}>{t('bill.cancel')}</Button>
</Popconfirm> </Popconfirm>
<Button onClick={() => setShowBill(bill)} size={'small'} theme={'solid'} type={'primary'}>{t('base.qr-code')}</Button> <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 bill.status == BillStatus.PAID

View File

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

View File

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

View File

@ -47,9 +47,9 @@ export function cancelBill(id: number) {
} }
export function confirmBills(bill_ids: 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) { export function updateBillPaymentSuccess(billId: number, merchant_ref: string,payment_channel:string) {
return post(`api/bills/${billId}/finish`, {merchant_ref,peyment_channel}) 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 axios from 'axios';
import {stringify} from 'qs' import {stringify} from 'qs'
import {BizError} from './types'; import {BizError} from './types';
import {getAuthToken} from "@/hooks/useAuth.ts";
const JSON_FORMAT: string = 'application/json'; const JSON_FORMAT: string = 'application/json';
const REQUEST_TIMEOUT = 300000; // 超时时长5min const REQUEST_TIMEOUT = 300000; // 超时时长5min
@ -14,7 +15,7 @@ const Axios = axios.create({
// 请求前拦截 // 请求前拦截
Axios.interceptors.request.use(config => { Axios.interceptors.request.use(config => {
const token = localStorage.getItem('payment_front_token'); const token = getAuthToken();
if (token) { if (token) {
config.headers['Authorization'] = `Bearer ${token}`; config.headers['Authorization'] = `Bearer ${token}`;
} }

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

@ -1,7 +1,13 @@
declare type UserProfile = { declare type UserProfile = {
id: string | number; id: string | number;
nickname: string; token: string;
department: string; email: string;
exp: number;
iat: number;
iss: string;
nbf: number;
type: string;
username: string;
} }
declare interface AuthProps { declare interface AuthProps {
@ -16,5 +22,6 @@ declare type AuthContextType = {
isInitialized?: boolean; isInitialized?: boolean;
user?: UserProfile | null | undefined; user?: UserProfile | null | undefined;
logout: () => Promise<void>; 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; page_number:int;
status:string; status:string;
apply_status:string; apply_status:string;
student_name:string; student_number:string;
payment_method:string; payment_method:string;
start_date:string; start_date:string;
end_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; API_PREFIX: string;
// flywire 支付网关 // flywire 支付网关
FIY_WIRE_GATEWAY: string; 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 = { declare type BasicComponentProps = {
children?: React.ReactNode; children?: React.ReactNode;

View File

@ -13,7 +13,11 @@ export default defineConfig(({mode}) => {
SITE_URL: process.env.APP_SITE_URL || null, SITE_URL: process.env.APP_SITE_URL || null,
API_PREFIX: process.env.APP_API_PREFIX || '/api', API_PREFIX: process.env.APP_API_PREFIX || '/api',
FIY_WIRE_GATEWAY: process.env.FIY_WIRE_GATEWAY || 'https://gateway.flywire.com/v1/transfers', 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: { resolve: {
alias: { alias: {