feat: 加仓自动获取费率数据
This commit is contained in:
@@ -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('无浏览器环境'));
|
||||||
|
|||||||
@@ -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
21
components/ui/spinner.jsx
Normal 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
2
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user