add:支持批量添加基金
This commit is contained in:
187
app/globals.css
187
app/globals.css
@@ -287,6 +287,14 @@ body {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modal .item .name {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
max-width: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
.item .weight {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
@@ -615,3 +623,182 @@ body {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* 搜索相关样式 */
|
||||
.search-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1000; /* 提升到更高层级,避免被其他元素遮挡 */
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-spinner {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 10000; /* 确保全局最高层级 */
|
||||
padding: 8px;
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-item:hover:not(.added) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-item.selected {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.search-item.added {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fund-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fund-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fund-code {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-item.selected .checkbox {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.checked-mark {
|
||||
width: 10px;
|
||||
height: 6px;
|
||||
border-left: 2px solid #05263b;
|
||||
border-bottom: 2px solid #05263b;
|
||||
transform: rotate(-45deg) translateY(-1px);
|
||||
}
|
||||
|
||||
.added-label {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.selected-funds-bar {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selected-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selected-inline-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fund-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
border: 1px solid rgba(34, 211, 238, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.remove-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.remove-chip:hover {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.batch-add-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 提升添加基金区域的层级,避免父级 stacking context 影响 */
|
||||
.add-fund-section {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
317
app/page.jsx
317
app/page.jsx
@@ -197,9 +197,57 @@ function FeedbackModal({ onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddResultModal({ failures, onClose }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="添加结果"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>部分基金添加失败</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted" style={{ marginBottom: 12, fontSize: '14px' }}>
|
||||
未获取到估值数据的基金如下:
|
||||
</div>
|
||||
<div className="list">
|
||||
{failures.map((it, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
<span className="name">{it.name || '未知名称'}</span>
|
||||
<div className="values">
|
||||
<span className="badge">#{it.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button className="button" onClick={onClose}>知道了</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [funds, setFunds] = useState([]);
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const timerRef = useRef(null);
|
||||
@@ -230,6 +278,27 @@ export default function HomePage() {
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const [feedbackNonce, setFeedbackNonce] = useState(0);
|
||||
|
||||
// 搜索相关状态
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [selectedFunds, setSelectedFunds] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchTimeoutRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [addResultOpen, setAddResultOpen] = useState(false);
|
||||
const [addFailures, setAddFailures] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = (code) => {
|
||||
setFavorites(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -442,6 +511,102 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const performSearch = async (val) => {
|
||||
if (!val.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
// 使用 JSONP 方式获取数据,添加 callback 参数
|
||||
const callbackName = `SuggestData_${Date.now()}`;
|
||||
const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
window[callbackName] = (data) => {
|
||||
if (data && data.Datas) {
|
||||
// 过滤出基金类型的数据 (CATEGORY 为 700 是公募基金)
|
||||
const fundsOnly = data.Datas.filter(d =>
|
||||
d.CATEGORY === 700 ||
|
||||
d.CATEGORY === "700" ||
|
||||
d.CATEGORYDESC === "基金"
|
||||
);
|
||||
setSearchResults(fundsOnly);
|
||||
}
|
||||
delete window[callbackName];
|
||||
resolve();
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
if (document.body.contains(script)) document.body.removeChild(script);
|
||||
};
|
||||
script.onerror = () => {
|
||||
if (document.body.contains(script)) document.body.removeChild(script);
|
||||
delete window[callbackName];
|
||||
reject(new Error('搜索请求失败'));
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('搜索失败', e);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchInput = (e) => {
|
||||
const val = e.target.value;
|
||||
setSearchTerm(val);
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
searchTimeoutRef.current = setTimeout(() => performSearch(val), 300);
|
||||
};
|
||||
|
||||
const toggleSelectFund = (fund) => {
|
||||
setSelectedFunds(prev => {
|
||||
const exists = prev.find(f => f.CODE === fund.CODE);
|
||||
if (exists) {
|
||||
return prev.filter(f => f.CODE !== fund.CODE);
|
||||
}
|
||||
return [...prev, fund];
|
||||
});
|
||||
};
|
||||
|
||||
const batchAddFunds = async () => {
|
||||
if (selectedFunds.length === 0) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const newFunds = [];
|
||||
for (const f of selectedFunds) {
|
||||
if (funds.some(existing => existing.code === f.CODE)) continue;
|
||||
try {
|
||||
const data = await fetchFundData(f.CODE);
|
||||
newFunds.push(data);
|
||||
} catch (e) {
|
||||
console.error(`添加基金 ${f.CODE} 失败`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (newFunds.length > 0) {
|
||||
const updated = dedupeByCode([...newFunds, ...funds]);
|
||||
setFunds(updated);
|
||||
localStorage.setItem('funds', JSON.stringify(updated));
|
||||
}
|
||||
|
||||
setSelectedFunds([]);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
} catch (e) {
|
||||
setError('批量添加失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = async (codes) => {
|
||||
if (refreshingRef.current) return;
|
||||
refreshingRef.current = true;
|
||||
@@ -479,24 +644,49 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const addFund = async (e) => {
|
||||
e.preventDefault();
|
||||
e?.preventDefault?.();
|
||||
setError('');
|
||||
const clean = code.trim();
|
||||
if (!clean) {
|
||||
setError('请输入基金编号');
|
||||
return;
|
||||
}
|
||||
if (funds.some((f) => f.code === clean)) {
|
||||
setError('该基金已添加');
|
||||
const manualTokens = String(searchTerm || '')
|
||||
.split(/[^0-9A-Za-z]+/)
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
const selectedCodes = Array.from(new Set([
|
||||
...selectedFunds.map(f => f.CODE),
|
||||
...manualTokens.filter(t => /^\d{6}$/.test(t))
|
||||
]));
|
||||
if (selectedCodes.length === 0) {
|
||||
setError('请输入或选择基金代码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchFundData(clean);
|
||||
const next = [data, ...funds];
|
||||
setFunds(next);
|
||||
localStorage.setItem('funds', JSON.stringify(next));
|
||||
setCode('');
|
||||
const newFunds = [];
|
||||
const failures = [];
|
||||
const nameMap = {};
|
||||
selectedFunds.forEach(f => { nameMap[f.CODE] = f.NAME; });
|
||||
for (const c of selectedCodes) {
|
||||
if (funds.some((f) => f.code === c)) continue;
|
||||
try {
|
||||
const data = await fetchFundData(c);
|
||||
newFunds.push(data);
|
||||
} catch (err) {
|
||||
failures.push({ code: c, name: nameMap[c] });
|
||||
}
|
||||
}
|
||||
if (newFunds.length === 0) {
|
||||
setError('未添加任何新基金');
|
||||
} else {
|
||||
const next = dedupeByCode([...newFunds, ...funds]);
|
||||
setFunds(next);
|
||||
localStorage.setItem('funds', JSON.stringify(next));
|
||||
}
|
||||
setSearchTerm('');
|
||||
setSelectedFunds([]);
|
||||
setShowDropdown(false);
|
||||
if (failures.length > 0) {
|
||||
setAddFailures(failures);
|
||||
setAddResultOpen(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || '添加失败');
|
||||
} finally {
|
||||
@@ -595,20 +785,85 @@ export default function HomePage() {
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<PlusIcon width="20" height="20" />
|
||||
<span>添加基金</span>
|
||||
<span className="muted">输入基金编号(例如:110022)</span>
|
||||
<span className="muted">搜索并选择基金(支持名称或代码)</span>
|
||||
</div>
|
||||
<form className="form" onSubmit={addFund}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="基金编号"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? '添加中…' : '添加'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="search-container" ref={dropdownRef}>
|
||||
<form className="form" onSubmit={addFund}>
|
||||
<div className="search-input-wrapper" style={{ flex: 1, gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{selectedFunds.length > 0 && (
|
||||
<div className="selected-inline-chips">
|
||||
{selectedFunds.map(fund => (
|
||||
<div key={fund.CODE} className="fund-chip">
|
||||
<span>{fund.NAME}</span>
|
||||
<button onClick={() => toggleSelectFund(fund)} className="remove-chip">
|
||||
<CloseIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
placeholder="搜索基金名称或代码..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchInput}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
/>
|
||||
{isSearching && <div className="search-spinner" />}
|
||||
</div>
|
||||
<button className="button" type="submit" disabled={loading}>
|
||||
{loading ? '添加中…' : '添加'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<AnimatePresence>
|
||||
{showDropdown && (searchTerm.trim() || searchResults.length > 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="search-dropdown glass"
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="search-results">
|
||||
{searchResults.map((fund) => {
|
||||
const isSelected = selectedFunds.some(f => f.CODE === fund.CODE);
|
||||
const isAlreadyAdded = funds.some(f => f.code === fund.CODE);
|
||||
return (
|
||||
<div
|
||||
key={fund.CODE}
|
||||
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
|
||||
onClick={() => {
|
||||
if (isAlreadyAdded) return;
|
||||
toggleSelectFund(fund);
|
||||
}}
|
||||
>
|
||||
<div className="fund-info">
|
||||
<span className="fund-name">{fund.NAME}</span>
|
||||
<span className="fund-code muted">#{fund.CODE} | {fund.TYPE}</span>
|
||||
</div>
|
||||
{isAlreadyAdded ? (
|
||||
<span className="added-label">已添加</span>
|
||||
) : (
|
||||
<div className="checkbox">
|
||||
{isSelected && <div className="checked-mark" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : searchTerm.trim() && !isSearching ? (
|
||||
<div className="no-results muted">未找到相关基金</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{error && <div className="muted" style={{ marginTop: 8, color: 'var(--danger)' }}>{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -901,6 +1156,14 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{addResultOpen && (
|
||||
<AddResultModal
|
||||
failures={addFailures}
|
||||
onClose={() => setAddResultOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
|
||||
|
||||
Reference in New Issue
Block a user