update qr-code style

This commit is contained in:
LittleBoy 2024-05-26 11:41:45 +08:00
parent f9761015c3
commit d98bddc54f
21 changed files with 233 additions and 76 deletions

View File

@ -4,6 +4,12 @@ FROM node:18.19.1-alpine AS builder
MAINTAINER yaclty2@gmail.com
WORKDIR /app
# envs 配置
# 应用部署后的URL
ENV APP_SITE_URL ""
# 应用接口前缀
ENV APP_API_URL ""
# Copy source code to the builder
COPY package.json yarn.lock* ./
COPY public ./public
@ -24,8 +30,7 @@ FROM nginx:1.26-alpine3.19 AS runner
WORKDIR /app
# envs 配置
ENV APP_API_URL https://baidu.com
ENV APP_SITE_URL https://pay.wm-app.xyz
ENV APP_API_URL http://43.136.175.109:50000
# nginx配置文件
COPY nginx.conf /etc/nginx/conf.d/default.conf.template

View File

@ -0,0 +1,25 @@
import React from "react";
import {useTranslation} from "react-i18next";
import {IconBillType, IconMoney, IconStudentId} from "@/components/icons";
import MoneyFormat from "@/components/money-format.tsx";
import './bill.less'
const BillDetailItem = (item: { title: React.ReactNode; value: React.ReactNode, icon?: React.ReactNode }) => {
return <div className={'bill-detail-item'}>
<div className={'detail-item-title'}>{item.icon} <span className={'item-title'}>{item.title}</span> :</div>
<div className={'detail-item-value'}>{item.value}</div>
</div>
}
const BillDetailItems = ({bill}: { bill: BillModel }) => {
const {t} = useTranslation();
return (<>
<BillDetailItem icon={<IconBillType/>} title={t('manual.bill_type')} value={'TUITION FEE'}/>
<BillDetailItem icon={<IconStudentId/>} title={t('manual.student_number')} value={bill.student_number}/>
<BillDetailItem icon={<IconStudentId/>} title={t('bill.title_student_name')}
value={`${bill.student_english_name}/${bill.student_chinese_name}`}/>
<BillDetailItem icon={<IconMoney/>} title={t('manual.amount')} value={<MoneyFormat money={bill.amount}/>}/>
</>)
}
export default BillDetailItems;

View File

@ -1,4 +1,4 @@
.bill-search-form{
.bill-search-form {
}
@ -15,6 +15,7 @@
.bill-info-title {
display: flex;
align-items: center;
margin-right: 5px;
&:before {
content: ' ';
@ -27,4 +28,49 @@
}
}
}
}
.modal-bill-detail{
.modal-bill-info{
display: flex;
justify-content: center;
}
.bill-info-detail{
margin-left: 30px;
width: 340px;
}
.bill-exp-time{
font-size: 18px;
margin-bottom: 20px;
border-bottom: dashed 2px #eeeeee;
padding-bottom: 20px;
}
}
.bill-detail-item {
display: flex;
margin-bottom: 10px;
align-items: center;
.detail-item-title {
font-weight: bold;
width: 120px;
white-space: nowrap;
display: flex;
align-items: center;
}
.item-title{
margin-left: 5px;
}
.detail-item-value {
color: #999999;
}
}
body[data-lang=en-US]{
.bill-detail-item {
.detail-item-title{
width: 170px;
}
}
}

View File

@ -1,43 +1,37 @@
import styles from "@/pages/manual/manual.module.less";
import {Button, Space} from "@douyinfe/semi-ui";
import QRCode from "qrcode.react";
import {useTranslation} from "react-i18next";
import {useRef} from "react";
import {saveAs} from "file-saver";
import {BillDetailItems, useBillQRCode} from "@/components/bill/index.ts";
import './bill.less'
const BillDetailItem = (item:{title:string;value:string})=>{
return <div className={styles.billDetailItem}>
<div className={styles.billDetailItemTitle}>{item.title} :</div>
<div className={styles.billDetailItemValue}>{item.value}</div>
</div>
type BillDetailProps = {
onCancel: ()=>void;
bill:BillModel;
}
const BillDetail:BasicComponent<{bill:BillModel}> = ()=>{
const BillDetail:BasicComponent<BillDetailProps> = ({bill,onCancel})=>{
const {t} = useTranslation();
const qrCodeRef = useRef<HTMLDivElement>(null)
const downloadQRCode = ()=>{
const canvas = qrCodeRef.current?.querySelector('canvas');
if(!canvas) return
saveAs(canvas.toDataURL(), 'qrcode.png')
}
return <div>
<Space className={styles.billDetail} align={'start'}>
<div className={styles.billQrCode}>
const { exportQRCode,QRCode } = useBillQRCode()
return <div className={'modal-bill-detail'}>
<div className={'modal-bill-info'}>
<div className={'bill-qr-code'}>
<div className={styles.QRCodeContainer}>
<div className={styles.qrCode} ref={qrCodeRef}>
<QRCode size={250} value={'http://localhost:5173/pay?bill=123123123&from=qrcode'} />
</div>
<QRCode size={160} className={styles.qrCode} bill={bill} />
</div>
<div className={styles.billExpTime}> {t('manual.exp_time')} {'12:00'} </div>
</div>
<div >
<BillDetailItem title={t('manual.bill_type')} value={'TUITION FEE'} />
<BillDetailItem title={t('manual.student_number')} value={'12345612'} />
<BillDetailItem title={t('manual.amount')} value={'HK$ 13600.00'} />
<Button onClick={downloadQRCode} style={{marginTop:20}} theme={'solid'} type={'primary'}>Download QR code</Button>
<div className={'bill-info-detail'}>
<div className={'bill-exp-time text-center'}> {t('manual.exp_time')} {'12:00'} </div>
<BillDetailItems bill={bill} />
</div>
</Space>
</div>
<div className="text-center semi-modal-footer">
<Space spacing={1}>
<Button type="primary" onClick={onCancel}>{t('base.close')}</Button>
<Button theme={'solid'} type="primary" onClick={exportQRCode}>{t('bill.download-qr-code')}</Button>
</Space>
</div>
</div>
}
export default BillDetail

View File

@ -3,5 +3,6 @@ import {
BillList
} from './list.tsx'
import useBillQRCode from './qr-code.tsx'
import BillDetailItems from './bill-detail-items.tsx'
export {BillDetail, BillList, useBillQRCode}
export {BillDetail, BillList, useBillQRCode,BillDetailItems}

View File

@ -15,7 +15,7 @@ export type BillQrcodeProps = {
function getPayUrl(billId?: string | number) {
const {host, protocol} = location //AppConfig.SITE_URL
const rootUrl = (typeof (APP_SITE_URL) == "string" ? APP_SITE_URL : undefined) || `${protocol}//${host}`
const rootUrl = (typeof (APP_SITE_URL) == "string" ? APP_SITE_URL : undefined) || AppConfig.SITE_URL || `${protocol}//${host}`
return `${rootUrl}/pay?bill=${billId || 0}&from=qrcode`
}

View File

@ -23,7 +23,7 @@ type SearchFormFields = {
const SearchForm: React.FC<SearchFormProps> = (props) => {
const formSubmit = (value: SearchFormFields) => {
const params: BillQueryParams = {}
if (value.dateRange) {
if (value.dateRange && value.dateRange.length == 2) {
params.start_date = dayjs(value.dateRange[0]).format('YYYY-MM-DD');
params.end_date = dayjs(value.dateRange[1]).format('YYYY-MM-DD');
}

View File

@ -32,4 +32,93 @@ export const IconChecked = ({style}: IconProps) => {
fill="currentColor"></path>
</svg>
)
}
export const IconMoney = ({style}: IconProps) => {
return (
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}>
<defs>
<clipPath id="master_svg0_1_579925">
<rect x="0" y="0" width="20" height="20" rx="0"/>
</clipPath>
</defs>
<g clip-path="url(#master_svg0_1_579925)">
<g>
<path
d="M8.247756875,7.59451C8.575876875,7.38402,9.096836875000001,7.2191399999999994,9.767556875,7.1875L10.239506875,7.1875C10.910256875,7.2191399999999994,11.431196875000001,7.38402,11.759336874999999,7.59451C12.088336875,7.8055699999999995,12.191036875,8.02607,12.191036875,8.27881C12.191036875,8.31139,12.189336875,8.343440000000001,12.185666874999999,8.375L13.438906875,8.375C13.440336875,8.34324,13.441036875,8.31119,13.441036875,8.27881C13.441036875,6.946149999999999,12.228616875,6.13037,10.628536875,5.9637899999999995L10.628536875,4.375L9.378536875,4.375L9.378536875,5.9637899999999995C7.778456875,6.13037,6.566035275,6.946149999999999,6.566035275,8.27881C6.566035275,9.78029,8.105056875,10.625630000000001,10.003536875,10.625630000000001C10.785876875,10.62645,11.389026874999999,10.80287,11.753826875,11.03688C12.082856875000001,11.24793,12.185546875,11.468440000000001,12.185546875,11.72119C12.185546875,11.97393,12.082856875000001,12.19445,11.753826875,12.40549C11.425706875,12.61598,10.904746875,12.78086,10.234026875,12.8125L9.762066875,12.8125C9.091346875,12.78086,8.570386875,12.61598,8.242266875,12.40549C7.9132468750000005,12.19445,7.810546875,11.97393,7.810546875,11.72119C7.810546875,11.68861,7.812246875,11.656559999999999,7.815936875,11.625L6.562675955,11.625C6.561269283,11.65676,6.560546875,11.68883,6.560546875,11.72119C6.560546875,13.05385,7.772966875,13.86963,9.373046875,14.03621L9.373046875,15.625L10.623046875,15.625L10.623046875,14.03621C12.223126875,13.86963,13.435546875,13.05385,13.435546875,11.72119C13.435546875,10.21971,11.896526875,9.374369999999999,9.998046875,9.374369999999999C9.215706875,9.37355,8.612556875,9.19713,8.247756875,8.96313C7.918726875,8.75207,7.816036875,8.531559999999999,7.816036875,8.27881C7.816036875,8.02607,7.918726875,7.8055699999999995,8.247756875,7.59451Z"
fill="#FF8432" fill-opacity="1"/>
</g>
<g>
<path
d="M18.75,10C18.75,14.8325,14.8325,18.75,10,18.75C5.1675,18.75,1.25,14.8325,1.25,10C1.25,5.1675,5.1675,1.25,10,1.25C14.8325,1.25,18.75,5.1675,18.75,10ZM17.5,10C17.5,5.85787,14.1421,2.5,10,2.5C5.85787,2.5,2.5,5.85787,2.5,10C2.5,14.1421,5.85787,17.5,10,17.5C14.1421,17.5,17.5,14.1421,17.5,10Z"
fill="#FF8432" fill-opacity="1"/>
</g>
</g>
</svg>
)
}
export const IconStudentId = ({style}: IconProps) => {
return (
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}>
<defs>
<clipPath id="master_svg0_1_310440">
<rect x="0" y="0" width="20" height="20" rx="0"/>
</clipPath>
</defs>
<g clip-path="url(#master_svg0_1_310440)">
<g>
<path
d="M14.6875,5.9375C14.6875,3.34867,12.5888,1.25,10,1.25C7.41117,1.25,5.3125,3.34867,5.3125,5.9375C5.3125,7.47584,6.05354,8.84111,7.19812,9.6958C4.1175999999999995,10.62863,1.875,13.4899,1.875,16.875L1.875,18.125C1.875,18.4702,2.154824,18.75,2.5,18.75L11.25,18.75L11.25,17.5L3.125,17.5L3.125,16.875C3.125,13.4232,5.92322,10.625,9.375,10.625L10.625,10.625C11.60687,10.625,12.5359,10.85141,13.3626,11.2549L15.5914,11.2549C14.7944,10.55002,13.8463,10.01207,12.8019,9.6958C13.9465,8.84111,14.6875,7.47584,14.6875,5.9375ZM10,9.375C8.10152,9.375,6.5625,7.83598,6.5625,5.9375C6.5625,4.03902,8.10152,2.5,10,2.5C11.8985,2.5,13.4375,4.03902,13.4375,5.9375C13.4375,7.83598,11.8985,9.375,10,9.375Z"
fill="#00C479" fill-opacity="1"/>
</g>
<g>
<path d="M12.5,13.75L18.125,13.75L18.125,12.5L12.5,12.5L12.5,13.75Z" fill="#00C479"
fill-opacity="1"/>
</g>
<g>
<path d="M18.125,16.25L12.5,16.25L12.5,15L18.125,15L18.125,16.25Z" fill="#00C479" fill-opacity="1"/>
</g>
<g>
<path d="M12.5,18.75L18.125,18.75L18.125,17.5L12.5,17.5L12.5,18.75Z" fill="#00C479"
fill-opacity="1"/>
</g>
</g>
</svg>)
}
export const IconBillType = ({style}: IconProps) => {
return (
<svg className="icon" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
width="1em" height="1em" style={style}>
<defs>
<clipPath id="master_svg0_1_310428">
<rect x="0" y="0" width="20" height="20" rx="0"/>
</clipPath>
</defs>
<g clip-path="url(#master_svg0_1_310428)">
<g>
<path
d="M11.875,7.499664306640625L5,7.499664306640625L5,6.249664306640625L11.875,6.249664306640625L11.875,7.499664306640625Z"
fill="#FFC65F" fill-opacity="1"/>
</g>
<g>
<path
d="M5,13.749969482421875L10,13.749969482421875L10,12.499969482421875L5,12.499969482421875L5,13.749969482421875Z"
fill="#FFC65F" fill-opacity="1"/>
</g>
<g>
<path d="M11.875,10.625L5,10.625L5,9.375L11.875,9.375L11.875,10.625Z" fill="#FFC65F"
fill-opacity="1"/>
</g>
<g>
<path
d="M15,16.875C15,17.5654,14.4404,18.125,13.75,18.125L3.125,18.125C2.434649,18.125,1.875,17.5654,1.875,16.875L1.875,3.125C1.875,2.434649,2.434649,1.875,3.125,1.875L13.75,1.875C14.4404,1.875,15,2.434649,15,3.125L16.875,3.125C17.5654,3.125,18.125,3.68465,18.125,4.375L18.125,15.625C18.125,16.3154,17.5654,16.875,16.875,16.875L15,16.875ZM13.75,3.125L3.125,3.125L3.125,16.875L13.75,16.875L13.75,3.125ZM15,4.375L15,15.625L16.875,15.625L16.875,4.375L15,4.375Z"
fill="#FFC65F" fill-opacity="1"/>
</g>
</g>
</svg>
)
}

View File

@ -6,6 +6,9 @@ type MoneyFormatProps = {
}
function formatCurrency(amount: string | number) {
if(amount === '0' || amount === '0.00') {
return '0.00';
}
// 将金额转换为字符串,并限制小数点后两位
let amountStr = Number(amount).toFixed(2);
@ -24,6 +27,6 @@ function formatCurrency(amount: string | number) {
const MoneyFormat: React.FC<MoneyFormatProps> = ({money, currency = 'HK$'}) => {
// 将货币数字转换为千分位格式且带2位小数
return money?<span className={'money-format'}>{currency} {formatCurrency(money)}</span>:null
return (money||money==0||money=='0') ?<span className={'money-format'}>{currency} {formatCurrency(money)}</span>:null;
}
export default MoneyFormat;

View File

@ -2,6 +2,7 @@
"base": {
"bill_number": "Bill Number",
"btn_search_submit": "Search",
"close": "Close",
"please_enter": "Please Enter",
"please_select": "Please Select",
"student_number": "Student Number"
@ -13,6 +14,7 @@
"confirm_batch": "Batch Confirm",
"confirm_select_empty": "Require confirm bill data",
"confirmed": "Confirmed",
"download-qr-code": "Download QR Code",
"download_receipt": "Download receipt",
"pay_status": "Bill Status",
"pay_status_canceled": "Canceled",

View File

@ -2,6 +2,7 @@
"base": {
"bill_number": "账单编号",
"btn_search_submit": "搜索",
"close": "关闭",
"please_enter": "请输入",
"please_select": "请选择",
"student_number": "学号"
@ -13,6 +14,7 @@
"confirm_batch": "批量对账",
"confirm_select_empty": "对账账单为空",
"confirmed": "已对账",
"download-qr-code": "下载二维码",
"download_receipt": "下载收据",
"pay_status": "账单状态",
"pay_status_canceled": "已作废",

View File

@ -2,6 +2,7 @@
"base": {
"bill_number": "账单编号",
"btn_search_submit": "搜索",
"close": "关闭",
"please_enter": "请输入",
"please_select": "请选择",
"student_number": "学号"
@ -13,6 +14,7 @@
"confirm_batch": "批量对账",
"confirm_select_empty": "对账账单为空",
"confirmed": "已对账",
"download-qr-code": "下载二维码",
"download_receipt": "下载收据",
"pay_status": "账单状态",
"pay_status_canceled": "已作废",

View File

@ -32,15 +32,15 @@ const BillQuery = () => {
}}
/>
<Modal
title="View QR code"
title="Bill Detail"
visible={!!showBill}
onOk={() => {
}}
width={620}
onCancel={() => setShowBill(undefined)} //>=1.16.0
closeOnEsc={true}
footerFill={true}
footer={null}
closeIcon={<span></span>}
>
{showBill && <BillDetail bill={showBill}/>}
{showBill && <BillDetail bill={showBill} onCancel={() => setShowBill(undefined)}/>}
</Modal>
</div>)
}

View File

@ -1,11 +1,10 @@
import {Button, Form, Space, Toast} from "@douyinfe/semi-ui";
import {useTranslation} from "react-i18next";
import {ReactNode, useRef, useState} from "react";
import { useRef, useState} from "react";
import {Card} from "@/components/card";
import MoneyFormat from "@/components/money-format.tsx";
import {BillTypes} from "@/service/bill-types.ts";
import {useBillQRCode} from "@/components/bill";
import {BillDetailItems, useBillQRCode} from "@/components/bill";
import styles from './manual.module.less'
import {createManualBill, getBillDetail} from "@/service/api/bill.ts";
@ -14,12 +13,6 @@ import {BizError} from "@/service/types.ts";
import {IconAlertTriangle} from "@douyinfe/semi-icons";
import dayjs from "dayjs";
const BillDetailItem = (item: { title: string; value: ReactNode }) => {
return <div className={styles.billDetailItem}>
<div className={styles.billDetailItemTitle}>{item.title} :</div>
<div className={styles.billDetailItemValue}>{item.value}</div>
</div>
}
export default function Index() {
@ -50,9 +43,7 @@ export default function Index() {
const BillInfo = ({bill}: { bill?: BillModel }) => {
if (!bill) return null;
return (<>
<BillDetailItem title={t('manual.bill_type')} value={'TUITION FEE'}/>
<BillDetailItem title={t('manual.student_number')} value={bill.student_number}/>
<BillDetailItem title={t('manual.amount')} value={<MoneyFormat money={bill.amount}/>}/>
<BillDetailItems bill={bill} />
<Button onClick={exportQRCode} style={{marginTop: 20}} theme={'solid'} type={'primary'}>Download QR
code</Button>
</>)
@ -100,7 +91,7 @@ export default function Index() {
</div>
<div className={styles.billQrCode}>
<div className={styles.QRCodeContainer}>
<QRCode className={styles.qrCode} bill={billInfo}/>
<QRCode size={250} className={styles.qrCode} bill={billInfo}/>
</div>
{billInfo && <div
className={styles.billExpTime}> {t('manual.exp_time')} {dayjs(billInfo.expiration_time).format('HH:mm')} </div>}

View File

@ -11,23 +11,9 @@
}
.billDetail {
padding: 30px 0 50px;
}
.billDetailItem {
display: flex;
margin-bottom: 10px;
align-items: center;
}
.billDetailItemTitle {
font-weight: bold;
width: 150px;
}
.billDetailItemValue {
color: #999999;
}
.billQrCode{
text-align: center;
margin-left: 150px;

View File

@ -80,6 +80,7 @@ const AppRouter = () => {
// change ui locale
const {i18n} = useTranslation()
const locale = useMemo(()=>{
document.body.setAttribute('data-lang', i18n.language)
if(i18n.language === 'zh-CN') return zh_CN;
else if(i18n.language === 'zh-TW') return zh_TW;
return en_US;

View File

@ -1,4 +1,4 @@
import {get, post} from "@/service/request.ts";
import {get, post, put} from "@/service/request.ts";
export type BillQueryResult = {
result: BillModel[];
@ -23,7 +23,7 @@ function formatBillQueryResult(params: BillQueryParams, result: BillQueryResult)
}
export async function billList(params: BillQueryParams) {
const result = await get<BillQueryResult>('/bills', {params})
const result = await get<BillQueryResult>('/bills', params)
return formatBillQueryResult(params, result);
}
@ -32,6 +32,12 @@ export function createManualBill(params: ManualCreateBillParam) {
return post<{ id: number }>('/manual_payment', params)
}
// 获取账单详情
export function getBillDetail(id: number) {
return get<BillModel>('/bills/' + id)
}
// 作废订单
export function cancelBill(id: number){
return put('/bills/' + id)
}

View File

@ -1,15 +1,13 @@
import axios from 'axios';
import {stringify} from 'qs'
import { BizError } from './types';
import {BizError} from './types';
const JSON_FORMAT: string = 'application/json';
export type RequestMethod = 'get' | 'post' | 'put' | 'delete'
const REQUEST_TIMEOUT = 300000; // 超时时长5min
const Axios = axios.create({
baseURL: "/api",
timeout:REQUEST_TIMEOUT,
baseURL: AppConfig.API_PREFIX || '/api',
timeout: REQUEST_TIMEOUT,
headers: {'Content-Type': JSON_FORMAT}
})

4
src/types/api.d.ts vendored
View File

@ -1,4 +1,7 @@
// 请求方式
declare type RequestMethod = 'get' | 'post' | 'put' | 'delete'
// 接口返回数据类型
declare interface APIResponse<T> {
/**
* 0:成功
@ -9,4 +12,5 @@ declare interface APIResponse<T> {
* 0
*/
message: string;
request_id: string;
}

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

@ -7,7 +7,8 @@ declare type AllType = string | number | object | undefined | null;
declare const APP_SITE_URL:string;
declare const AppConfig:{
// 系统部署运行地址(主要用于支付二维码生成)
SITE_URL:string
SITE_URL:string;
API_PREFIX: string;
};
declare type BasicComponentProps = {

View File

@ -11,6 +11,7 @@ export default defineConfig(({mode}) => {
define: {
AppConfig: JSON.stringify({
SITE_URL: process.env.APP_SITE_URL || null,
API_PREFIX: process.env.APP_API_PREFIX || '/api',
}),
},
resolve: {