diff --git a/app/globals.css b/app/globals.css
index 747467e..da3699d 100644
--- a/app/globals.css
+++ b/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;
+}
diff --git a/app/page.jsx b/app/page.jsx
index 9ee5d16..aa9f155 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -197,9 +197,57 @@ function FeedbackModal({ onClose }) {
);
}
+function AddResultModal({ failures, onClose }) {
+ return (
+
+ e.stopPropagation()}
+ >
+
+
+ 未获取到估值数据的基金如下:
+
+
+ {failures.map((it, idx) => (
+
+
{it.name || '未知名称'}
+
+ #{it.code}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
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() {
添加基金
-
输入基金编号(例如:110022)
+
搜索并选择基金(支持名称或代码)
-
+
+
+
+
+
+ {showDropdown && (searchTerm.trim() || searchResults.length > 0) && (
+
+ {searchResults.length > 0 ? (
+
+ {searchResults.map((fund) => {
+ const isSelected = selectedFunds.some(f => f.CODE === fund.CODE);
+ const isAlreadyAdded = funds.some(f => f.code === fund.CODE);
+ return (
+
{
+ if (isAlreadyAdded) return;
+ toggleSelectFund(fund);
+ }}
+ >
+
+ {fund.NAME}
+ #{fund.CODE} | {fund.TYPE}
+
+ {isAlreadyAdded ? (
+
已添加
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ ) : searchTerm.trim() && !isSearching ? (
+ 未找到相关基金
+ ) : null}
+
+ )}
+
+
+
+
+
{error && {error}
}
@@ -901,6 +1156,14 @@ export default function HomePage() {
/>
)}
+
+ {addResultOpen && (
+ setAddResultOpen(false)}
+ />
+ )}
+
{settingsOpen && (
setSettingsOpen(false)}>