feat: 加仓自动获取费率数据

This commit is contained in:
hzm
2026-03-17 15:41:19 +08:00
parent 104a847d2a
commit b489677d3e
4 changed files with 182 additions and 67 deletions

View File

@@ -720,7 +720,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
}; };
}; };
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => { const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (typeof document === 'undefined' || !document.body) { if (typeof document === 'undefined' || !document.body) {
reject(new Error('无浏览器环境')); reject(new Error('无浏览器环境'));

View File

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { fetchSmartFundNetValue } from '../api/fund'; import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
import { DatePicker, NumericInput } from './Common'; import { DatePicker, NumericInput } from './Common';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import { CloseIcon } from './Icons'; import { CloseIcon } from './Icons';
@@ -16,6 +16,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import PendingTradesModal from './PendingTradesModal'; import PendingTradesModal from './PendingTradesModal';
import { Spinner } from '@/components/ui/spinner';
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -39,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
const [share, setShare] = useState(''); const [share, setShare] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [feeRate, setFeeRate] = useState('0'); const [feeRate, setFeeRate] = useState('0');
const [minBuyAmount, setMinBuyAmount] = useState(0);
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
const [buyMetaError, setBuyMetaError] = useState(null);
const [date, setDate] = useState(() => { const [date, setDate] = useState(() => {
return formatDate(); return formatDate();
}); });
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15); const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
const [calcShare, setCalcShare] = useState(null); const [calcShare, setCalcShare] = useState(null);
const parseNumberish = (input) => {
if (input === null || typeof input === 'undefined') return null;
if (typeof input === 'number') return Number.isFinite(input) ? input : null;
const cleaned = String(input).replace(/[^\d.]/g, '');
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : null;
};
useEffect(() => {
if (!isBuy || !fund?.code) return;
let cancelled = false;
setLoadingBuyMeta(true);
setBuyMetaError(null);
fetchFundPingzhongdata(fund.code)
.then((pz) => {
if (cancelled) return;
const rate = parseNumberish(pz?.fund_Rate);
const minsg = parseNumberish(pz?.fund_minsg);
if (Number.isFinite(minsg)) {
setMinBuyAmount(minsg);
} else {
setMinBuyAmount(0);
}
if (Number.isFinite(rate)) {
setFeeRate((prev) => {
const prevNum = parseNumberish(prev);
const shouldOverride = prev === '' || prev === '0' || prevNum === 0 || prevNum === null;
return shouldOverride ? rate.toFixed(2) : prev;
});
}
})
.catch((e) => {
if (cancelled) return;
setBuyMetaError(e?.message || '买入信息加载失败');
setMinBuyAmount(0);
})
.finally(() => {
if (cancelled) return;
setLoadingBuyMeta(false);
});
return () => {
cancelled = true;
};
}, [isBuy, fund?.code]);
const currentPendingTrades = useMemo(() => { const currentPendingTrades = useMemo(() => {
return pendingTrades.filter(t => t.fundCode === fund?.code); return pendingTrades.filter(t => t.fundCode === fund?.code);
}, [pendingTrades, fund]); }, [pendingTrades, fund]);
@@ -148,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
}; };
const isValid = isBuy const isValid = isBuy
? (!!amount && !!feeRate && !!date && calcShare !== null) ? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
: (!!share && !!date); : (!!share && !!date);
const handleSetShareFraction = (fraction) => { const handleSetShareFraction = (fraction) => {
@@ -372,72 +426,112 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{isBuy ? ( {isBuy ? (
<> <>
<div className="form-group" style={{ marginBottom: 16 }}> <div style={{ position: 'relative' }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span> <div className="form-group" style={{ marginBottom: 16 }}>
</label> <label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}> 加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
<NumericInput </label>
value={amount} <div
onChange={setAmount} style={{
step={100} border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
min={0} ? '1px solid var(--danger)'
placeholder="请输入加仓金额" : '1px solid var(--border)',
/> borderRadius: 12
</div> }}
</div> >
<NumericInput
value={amount}
onChange={setAmount}
step={100}
min={Number(minBuyAmount) || 0}
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
/>
</div>
{(Number(minBuyAmount) || 0) > 0 && (
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
最小加仓金额¥{Number(minBuyAmount)}
</div>
)}
</div>
<div className="row" style={{ gap: 12, marginBottom: 16 }}> <div className="row" style={{ gap: 12, marginBottom: 16 }}>
<div className="form-group" style={{ flex: 1 }}> <div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span> 买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
</label> </label>
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}> <div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput <NumericInput
value={feeRate} value={feeRate}
onChange={setFeeRate} onChange={setFeeRate}
step={0.01} step={0.01}
min={0} min={0}
placeholder="0.12" placeholder="0.12"
/> />
</div>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段
</label>
<div className="trade-time-slot row" style={{ gap: 8 }}>
<button
type="button"
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(false)}
>
15:00
</button>
<button
type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{buyMetaError ? (
<span className="muted" style={{ color: 'var(--danger)' }}>{buyMetaError}</span>
) : null}
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
</div>
)}
</div> </div>
</div> </div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}> {loadingBuyMeta && (
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <div
交易时段 style={{
</label> position: 'absolute',
<div className="trade-time-slot row" style={{ gap: 8 }}> inset: 0,
<button display: 'flex',
type="button" alignItems: 'center',
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'} justifyContent: 'center',
onClick={() => setIsAfter3pm(false)} gap: 10,
padding: 12,
borderRadius: 12,
background: 'rgba(0,0,0,0.25)',
backdropFilter: 'blur(2px)',
WebkitBackdropFilter: 'blur(2px)',
}}
> >
15:00 <Spinner className="size-5" />
</button> <span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
<button
type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
</div> </div>
)} )}
</div> </div>
@@ -564,8 +658,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<button <button
type="submit" type="submit"
className="button" className="button"
disabled={!isValid || loadingPrice} disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }} style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
> >
确定 确定
</button> </button>

21
components/ui/spinner.jsx Normal file
View File

@@ -0,0 +1,21 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({
className,
...props
}) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn(
"size-4 animate-spin text-muted-foreground motion-reduce:animate-none",
className
)}
{...props} />
);
}
export { Spinner }

2
package-lock.json generated
View File

@@ -5923,7 +5923,7 @@
}, },
"node_modules/class-variance-authority": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {