/gi) || [];
for (const r of dataRows) {
const tds = (r.match(/([\s\S]*?)<\/td>/gi) || []).map(td => td.replace(/<[^>]*>/g, '').trim());
if (!tds.length) continue;
let code = '';
let name = '';
let weight = '';
if (idxCode >= 0 && tds[idxCode]) {
const m = tds[idxCode].match(/(\d{6})/);
code = m ? m[1] : tds[idxCode];
} else {
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
if (codeIdx >= 0) code = tds[codeIdx];
}
if (idxName >= 0 && tds[idxName]) {
name = tds[idxName];
} else if (code) {
const i = tds.findIndex(txt => txt && txt !== code && !/%$/.test(txt));
name = i >= 0 ? tds[i] : '';
}
if (idxWeight >= 0 && tds[idxWeight]) {
const wm = tds[idxWeight].match(/([\d.]+)\s*%/);
weight = wm ? `${wm[1]}%` : tds[idxWeight];
} else {
const wIdx = tds.findIndex(txt => /\d+(?:\.\d+)?\s*%/.test(txt));
weight = wIdx >= 0 ? tds[wIdx].match(/([\d.]+)\s*%/)?.[1] + '%' : '';
}
if (code || name || weight) {
holdings.push({ code, name, weight, change: null });
}
}
holdings = holdings.slice(0, 10);
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
if (needQuotes.length) {
try {
const tencentCodes = needQuotes.map(h => {
const cd = String(h.code || '');
if (/^\d{6}$/.test(cd)) {
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
return `s_${pfx}${cd}`;
}
if (/^\d{5}$/.test(cd)) {
return `s_hk${cd}`;
}
return null;
}).filter(Boolean).join(',');
if (!tencentCodes) {
resolveH(holdings);
return;
}
const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
await new Promise((resQuote) => {
const scriptQuote = document.createElement('script');
scriptQuote.src = quoteUrl;
scriptQuote.onload = () => {
needQuotes.forEach(h => {
const cd = String(h.code || '');
let varName = '';
if (/^\d{6}$/.test(cd)) {
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
varName = `v_s_${pfx}${cd}`;
} else if (/^\d{5}$/.test(cd)) {
varName = `v_s_hk${cd}`;
} else {
return;
}
const dataStr = window[varName];
if (dataStr) {
const parts = dataStr.split('~');
if (parts.length > 5) {
h.change = parseFloat(parts[5]);
}
}
});
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
resQuote();
};
scriptQuote.onerror = () => {
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
resQuote();
};
document.body.appendChild(scriptQuote);
});
} catch (e) { }
}
resolveH(holdings);
}).catch(() => resolveH([]));
});
Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
if (tData) {
// 如果腾讯数据的日期更新(或相同),优先使用腾讯的净值数据(通常更准且包含涨跌幅)
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
gzData.dwjz = tData.dwjz;
gzData.jzrq = tData.jzrq;
gzData.zzl = tData.zzl; // 真实涨跌幅
}
}
resolve({ ...gzData, holdings });
});
};
scriptGz.onerror = () => {
window.jsonpgz = originalJsonpgz;
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
reject(new Error('基金数据加载失败'));
};
document.body.appendChild(scriptGz);
// 加载完立即移除脚本
setTimeout(() => {
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
}, 5000);
});
};
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;
setRefreshing(true);
const uniqueCodes = Array.from(new Set(codes));
try {
const updated = [];
for (const c of uniqueCodes) {
try {
const data = await fetchFundData(c);
updated.push(data);
} catch (e) {
console.error(`刷新基金 ${c} 失败`, e);
// 失败时从当前 state 中寻找旧数据
setFunds(prev => {
const old = prev.find((f) => f.code === c);
if (old) updated.push(old);
return prev;
});
}
}
if (updated.length > 0) {
setFunds(prev => {
// 将更新后的数据合并回当前最新的 state 中,防止覆盖掉刚刚导入的数据
const merged = [...prev];
updated.forEach(u => {
const idx = merged.findIndex(f => f.code === u.code);
if (idx > -1) {
merged[idx] = u;
} else {
merged.push(u);
}
});
const deduped = dedupeByCode(merged);
localStorage.setItem('funds', JSON.stringify(deduped));
return deduped;
});
}
} catch (e) {
console.error(e);
} finally {
refreshingRef.current = false;
setRefreshing(false);
}
};
const toggleViewMode = () => {
const nextMode = viewMode === 'card' ? 'list' : 'card';
setViewMode(nextMode);
};
const requestRemoveFund = (fund) => {
const h = holdings[fund.code];
const hasHolding = h && typeof h.share === 'number' && h.share > 0;
if (hasHolding) {
setFundDeleteConfirm({ code: fund.code, name: fund.name });
} else {
removeFund(fund.code);
}
};
const addFund = async (e) => {
e?.preventDefault?.();
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 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 {
setLoading(false);
}
};
const removeFund = (removeCode) => {
const next = funds.filter((f) => f.code !== removeCode);
setFunds(next);
localStorage.setItem('funds', JSON.stringify(next));
// 同步删除分组中的失效代码
const nextGroups = groups.map(g => ({
...g,
codes: g.codes.filter(c => c !== removeCode)
}));
setGroups(nextGroups);
localStorage.setItem('groups', JSON.stringify(nextGroups));
// 同步删除展开收起状态
setCollapsedCodes(prev => {
if (!prev.has(removeCode)) return prev;
const nextSet = new Set(prev);
nextSet.delete(removeCode);
localStorage.setItem('collapsedCodes', JSON.stringify(Array.from(nextSet)));
return nextSet;
});
// 同步删除自选状态
setFavorites(prev => {
if (!prev.has(removeCode)) return prev;
const nextSet = new Set(prev);
nextSet.delete(removeCode);
localStorage.setItem('favorites', JSON.stringify(Array.from(nextSet)));
if (nextSet.size === 0) setCurrentTab('all');
return nextSet;
});
// 同步删除持仓数据
setHoldings(prev => {
if (!prev[removeCode]) return prev;
const next = { ...prev };
delete next[removeCode];
localStorage.setItem('holdings', JSON.stringify(next));
return next;
});
};
const manualRefresh = async () => {
if (refreshingRef.current) return;
const codes = Array.from(new Set(funds.map((f) => f.code)));
if (!codes.length) return;
await refreshAll(codes);
};
const saveSettings = (e) => {
e?.preventDefault?.();
const ms = Math.max(10, Number(tempSeconds)) * 1000;
setRefreshMs(ms);
localStorage.setItem('refreshMs', String(ms));
setSettingsOpen(false);
};
const importFileRef = useRef(null);
const [importMsg, setImportMsg] = useState('');
function getComparablePayload(payload) {
if (!payload || typeof payload !== 'object') return '';
return JSON.stringify({
funds: Array.isArray(payload.funds) ? payload.funds : [],
favorites: Array.isArray(payload.favorites) ? payload.favorites : [],
groups: Array.isArray(payload.groups) ? payload.groups : [],
collapsedCodes: Array.isArray(payload.collapsedCodes) ? payload.collapsedCodes : [],
refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000,
holdings: payload.holdings && typeof payload.holdings === 'object' ? payload.holdings : {}
});
}
const collectLocalPayload = () => {
try {
const funds = JSON.parse(localStorage.getItem('funds') || '[]');
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
const groups = JSON.parse(localStorage.getItem('groups') || '[]');
const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
const fundCodes = new Set(
Array.isArray(funds)
? funds.map((f) => f?.code).filter(Boolean)
: []
);
const holdings = JSON.parse(localStorage.getItem('holdings') || '{}');
const cleanedHoldings = holdings && typeof holdings === 'object' && !Array.isArray(holdings)
? Object.entries(holdings).reduce((acc, [code, value]) => {
if (!fundCodes.has(code) || !value || typeof value !== 'object') return acc;
const parsedShare = typeof value.share === 'number'
? value.share
: typeof value.share === 'string'
? Number(value.share)
: NaN;
const parsedCost = typeof value.cost === 'number'
? value.cost
: typeof value.cost === 'string'
? Number(value.cost)
: NaN;
const nextShare = Number.isFinite(parsedShare) ? parsedShare : null;
const nextCost = Number.isFinite(parsedCost) ? parsedCost : null;
if (nextShare === null && nextCost === null) return acc;
acc[code] = {
...value,
share: nextShare,
cost: nextCost
};
return acc;
}, {})
: {};
const cleanedFavorites = Array.isArray(favorites)
? favorites.filter((code) => fundCodes.has(code))
: [];
const cleanedCollapsed = Array.isArray(collapsedCodes)
? collapsedCodes.filter((code) => fundCodes.has(code))
: [];
const cleanedGroups = Array.isArray(groups)
? groups.map((group) => ({
...group,
codes: Array.isArray(group?.codes)
? group.codes.filter((code) => fundCodes.has(code))
: []
}))
: [];
return {
funds,
favorites: cleanedFavorites,
groups: cleanedGroups,
collapsedCodes: cleanedCollapsed,
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
holdings: cleanedHoldings,
exportedAt: new Date().toISOString()
};
} catch {
return {
funds: [],
favorites: [],
groups: [],
collapsedCodes: [],
refreshMs: 30000,
holdings: {},
exportedAt: new Date().toISOString()
};
}
};
const applyCloudConfig = async (cloudData, cloudUpdatedAt) => {
if (!cloudData || typeof cloudData !== 'object') return;
skipSyncRef.current = true;
try {
if (cloudUpdatedAt) {
localStorage.setItem('localUpdatedAt', new Date(cloudUpdatedAt).toISOString());
}
const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : [];
setFunds(nextFunds);
localStorage.setItem('funds', JSON.stringify(nextFunds));
const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : [];
setFavorites(new Set(nextFavorites));
localStorage.setItem('favorites', JSON.stringify(nextFavorites));
const nextGroups = Array.isArray(cloudData.groups) ? cloudData.groups : [];
setGroups(nextGroups);
localStorage.setItem('groups', JSON.stringify(nextGroups));
const nextCollapsed = Array.isArray(cloudData.collapsedCodes) ? cloudData.collapsedCodes : [];
setCollapsedCodes(new Set(nextCollapsed));
localStorage.setItem('collapsedCodes', JSON.stringify(nextCollapsed));
const nextRefreshMs = Number.isFinite(cloudData.refreshMs) && cloudData.refreshMs >= 5000 ? cloudData.refreshMs : 30000;
setRefreshMs(nextRefreshMs);
setTempSeconds(Math.round(nextRefreshMs / 1000));
localStorage.setItem('refreshMs', String(nextRefreshMs));
if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') {
setViewMode(cloudData.viewMode);
}
const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {};
setHoldings(nextHoldings);
localStorage.setItem('holdings', JSON.stringify(nextHoldings));
if (nextFunds.length) {
const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
if (codes.length) await refreshAll(codes);
}
const payload = collectLocalPayload();
lastSyncedRef.current = getComparablePayload(payload);
} finally {
skipSyncRef.current = false;
}
};
const fetchCloudConfig = async (userId) => {
if (!userId) return;
try {
const { data, error } = await supabase
.from('user_configs')
.select('id, data, updated_at')
.eq('user_id', userId)
.maybeSingle();
if (error) throw error;
if (!data?.id) {
const { error: insertError } = await supabase
.from('user_configs')
.insert({ user_id: userId });
if (insertError) throw insertError;
setCloudConfigModal({ open: true, userId, type: 'empty' });
return;
}
if (data?.data && typeof data.data === 'object' && Object.keys(data.data).length > 0) {
const localPayload = collectLocalPayload();
const localComparable = getComparablePayload(localPayload);
const cloudComparable = getComparablePayload(data.data);
if (localComparable !== cloudComparable) {
const cloudTime = new Date(data.updated_at || 0).getTime();
const localTime = new Date(localStorage.getItem('localUpdatedAt') || 0).getTime();
if (localTime > cloudTime + 2000) {
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
return;
}
}
await applyCloudConfig(data.data, data.updated_at);
return;
}
setCloudConfigModal({ open: true, userId, type: 'empty' });
} catch (e) {
console.error('获取云端配置失败', e);
}
};
const syncUserConfig = async (userId, showTip = true) => {
if (!userId) return;
try {
const payload = collectLocalPayload();
const now = new Date().toISOString();
const { error: updateError } = await supabase
.from('user_configs')
.upsert(
{
user_id: userId,
data: payload,
updated_at: now
},
{ onConflict: 'user_id' }
);
if (updateError) throw updateError;
localStorage.setItem('localUpdatedAt', now);
if (showTip) {
setSuccessModal({ open: true, message: '已同步云端配置' });
}
} catch (e) {
console.error('同步云端配置异常', e);
showToast(`同步云端配置异常:${e}`, 'error');
}
};
const handleSyncLocalConfig = async () => {
const userId = cloudConfigModal.userId;
setCloudConfigModal({ open: false, userId: null });
await syncUserConfig(userId);
};
const exportLocalData = async () => {
try {
const payload = {
funds: JSON.parse(localStorage.getItem('funds') || '[]'),
favorites: JSON.parse(localStorage.getItem('favorites') || '[]'),
groups: JSON.parse(localStorage.getItem('groups') || '[]'),
collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'),
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
holdings: JSON.parse(localStorage.getItem('holdings') || '{}'),
exportedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
suggestedName: `realtime-fund-config-${Date.now()}.json`,
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }]
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
setSuccessModal({ open: true, message: '导出成功' });
setSettingsOpen(false);
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `realtime-fund-config-${Date.now()}.json`;
let done = false;
const finish = () => {
if (done) return;
done = true;
URL.revokeObjectURL(url);
setSuccessModal({ open: true, message: '导出成功' });
setSettingsOpen(false);
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') return;
finish();
document.removeEventListener('visibilitychange', onVisibility);
};
document.addEventListener('visibilitychange', onVisibility, { once: true });
a.click();
setTimeout(finish, 3000);
} catch (err) {
console.error('Export error:', err);
}
};
const handleImportFileChange = async (e) => {
try {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
const data = JSON.parse(text);
if (data && typeof data === 'object') {
// 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失
const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]');
const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
const currentGroups = JSON.parse(localStorage.getItem('groups') || '[]');
const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
let mergedFunds = currentFunds;
let appendedCodes = [];
if (Array.isArray(data.funds)) {
const incomingFunds = dedupeByCode(data.funds);
const existingCodes = new Set(currentFunds.map(f => f.code));
const newItems = incomingFunds.filter(f => f && f.code && !existingCodes.has(f.code));
appendedCodes = newItems.map(f => f.code);
mergedFunds = [...currentFunds, ...newItems];
setFunds(mergedFunds);
localStorage.setItem('funds', JSON.stringify(mergedFunds));
}
if (Array.isArray(data.favorites)) {
const mergedFav = Array.from(new Set([...currentFavorites, ...data.favorites]));
setFavorites(new Set(mergedFav));
localStorage.setItem('favorites', JSON.stringify(mergedFav));
}
if (Array.isArray(data.groups)) {
// 合并分组:如果 ID 相同则合并 codes,否则添加新分组
const mergedGroups = [...currentGroups];
data.groups.forEach(incomingGroup => {
const existingIdx = mergedGroups.findIndex(g => g.id === incomingGroup.id);
if (existingIdx > -1) {
mergedGroups[existingIdx] = {
...mergedGroups[existingIdx],
codes: Array.from(new Set([...mergedGroups[existingIdx].codes, ...(incomingGroup.codes || [])]))
};
} else {
mergedGroups.push(incomingGroup);
}
});
setGroups(mergedGroups);
localStorage.setItem('groups', JSON.stringify(mergedGroups));
}
if (Array.isArray(data.collapsedCodes)) {
const mergedCollapsed = Array.from(new Set([...currentCollapsed, ...data.collapsedCodes]));
setCollapsedCodes(new Set(mergedCollapsed));
localStorage.setItem('collapsedCodes', JSON.stringify(mergedCollapsed));
}
if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) {
setRefreshMs(data.refreshMs);
setTempSeconds(Math.round(data.refreshMs / 1000));
localStorage.setItem('refreshMs', String(data.refreshMs));
}
if (data.viewMode === 'card' || data.viewMode === 'list') {
setViewMode(data.viewMode);
}
if (data.holdings && typeof data.holdings === 'object') {
const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings };
setHoldings(mergedHoldings);
localStorage.setItem('holdings', JSON.stringify(mergedHoldings));
}
// 导入成功后,仅刷新新追加的基金
if (appendedCodes.length) {
// 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds
// 我们直接传入所有代码执行一次全量刷新是最稳妥的,或者修改 refreshAll 支持增量更新
const allCodes = mergedFunds.map(f => f.code);
await refreshAll(allCodes);
}
setSuccessModal({ open: true, message: '导入成功' });
setSettingsOpen(false); // 导入成功自动关闭设置弹框
if (importFileRef.current) importFileRef.current.value = '';
}
} catch (err) {
console.error('Import error:', err);
setImportMsg('导入失败,请检查文件格式');
setTimeout(() => setImportMsg(''), 4000);
if (importFileRef.current) importFileRef.current.value = '';
}
};
useEffect(() => {
const isAnyModalOpen =
settingsOpen ||
feedbackOpen ||
addResultOpen ||
addFundToGroupOpen ||
groupManageOpen ||
groupModalOpen ||
successModal.open ||
cloudConfigModal.open ||
logoutConfirmOpen ||
holdingModal.open ||
actionModal.open ||
tradeModal.open ||
!!clearConfirm ||
donateOpen ||
!!fundDeleteConfirm ||
updateModalOpen;
if (isAnyModalOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [
settingsOpen,
feedbackOpen,
addResultOpen,
addFundToGroupOpen,
groupManageOpen,
groupModalOpen,
successModal.open,
cloudConfigModal.open,
logoutConfirmOpen,
holdingModal.open,
actionModal.open,
tradeModal.open,
clearConfirm,
donateOpen,
updateModalOpen
]);
useEffect(() => {
const onKey = (ev) => {
if (ev.key === 'Escape' && settingsOpen) setSettingsOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [settingsOpen]);
const getGroupName = () => {
if (currentTab === 'all') return '全部资产';
if (currentTab === 'fav') return '自选资产';
const group = groups.find(g => g.id === currentTab);
return group ? `${group.name}资产` : '分组资产';
};
return (
{refreshing && }
{hasUpdate && (
setUpdateModalOpen(true)}
>
)}
 window.open("https://github.com/hzm0321/real-time-fund")} />
刷新
{Math.round(refreshMs / 1000)}秒
{/* */}
{/* 用户菜单 */}
{userMenuOpen && (
{user ? (
<>
{userAvatar ? (

) : (
(user.email?.charAt(0).toUpperCase() || 'U')
)}
{user.email}
已登录
>
) : (
<>
>
)}
)}
{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} }
setCurrentTab('all')}
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
>
全部 ({funds.length})
setCurrentTab('fav')}
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
>
自选 ({favorites.size})
{groups.map(g => (
setCurrentTab(g.id)}
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
>
{g.name} ({g.codes.length})
))}
{groups.length > 0 && (
)}
排序
{[
{ id: 'default', label: '默认' },
{ id: 'yield', label: '涨跌幅' },
{ id: 'holding', label: '持有收益' },
{ id: 'name', label: '名称' },
].map((s) => (
))}
{displayFunds.length === 0 ? (
📂
{funds.length === 0 ? '尚未添加基金' : '该分组下暂无数据'}
{currentTab !== 'all' && currentTab !== 'fav' && funds.length > 0 && (
)}
) : (
<>
{currentTab !== 'all' && currentTab !== 'fav' && (
setAddFundToGroupOpen(true)}
style={{
width: '100%',
height: '48px',
border: '2px dashed rgba(255,255,255,0.1)',
background: 'transparent',
borderRadius: '12px',
color: 'var(--muted)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginBottom: '16px',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '14px',
fontWeight: 500
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)';
e.currentTarget.style.color = 'var(--primary)';
e.currentTarget.style.background = 'rgba(34, 211, 238, 0.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)';
e.currentTarget.style.color = 'var(--muted)';
e.currentTarget.style.background = 'transparent';
}}
>
添加基金到此分组
)}
{viewMode === 'list' && (
基金名称
净值/估值
涨跌幅
估值时间
持仓金额
当日盈亏
持有收益
操作
)}
{displayFunds.map((f) => (
{viewMode === 'list' && isMobile && (
{
e.stopPropagation(); // 阻止冒泡,防止触发全局收起导致状态混乱
if (refreshing) return;
requestRemoveFund(f);
}}
style={{ pointerEvents: refreshing ? 'none' : 'auto', opacity: refreshing ? 0.6 : 1 }}
>
删除
)}
{
// 如果水平移动距离小于垂直移动距离,或者水平速度很小,视为垂直滚动意图,不进行拖拽处理
// framer-motion 的 dragDirectionLock 已经处理了大部分情况,但可以进一步微调体验
}}
// 如果当前行不是被选中的行,强制回到原点 (x: 0)
animate={viewMode === 'list' && isMobile ? { x: swipedFundCode === f.code ? -80 : 0 } : undefined}
onDragEnd={(e, { offset, velocity }) => {
if (viewMode === 'list' && isMobile) {
if (offset.x < -40) {
setSwipedFundCode(f.code);
} else {
setSwipedFundCode(null);
}
}
}}
onClick={(e) => {
// 阻止事件冒泡,避免触发全局的 click listener 导致立刻被收起
// 只有在已经展开的情况下点击自身才需要阻止冒泡(或者根据需求调整)
// 这里我们希望:点击任何地方都收起。
// 如果点击的是当前行,且不是拖拽操作,上面的全局 listener 会处理收起。
// 但为了防止点击行内容触发收起后又立即触发行的其他点击逻辑(如果有的话),
// 可以在这里处理。不过当前需求是“点击其他区域收起”,
// 实际上全局 listener 已经覆盖了“点击任何区域(包括其他行)收起”。
// 唯一的问题是:点击当前行的“删除按钮”时,会先触发全局 click 导致收起,然后触发删除吗?
// 删除按钮在底层,通常不会受影响,因为 React 事件和原生事件的顺序。
// 但为了保险,删除按钮的 onClick 应该阻止冒泡。
// 如果当前行已展开,点击行内容(非删除按钮)应该收起
if (viewMode === 'list' && isMobile && swipedFundCode === f.code) {
e.stopPropagation(); // 阻止冒泡,自己处理收起,避免触发全局再次处理
setSwipedFundCode(null);
}
}}
style={{
background: viewMode === 'list' ? 'var(--bg)' : undefined,
position: 'relative',
zIndex: 1
}}
>
{viewMode === 'list' ? (
<>
{currentTab !== 'all' && currentTab !== 'fav' ? (
) : (
)}
{f.name}
#{f.code}
{(() => {
const now = new Date();
const isAfter9 = now.getHours() >= 9;
const hasTodayData = f.jzrq === todayStr;
const shouldHideChange = isTradingDay && isAfter9 && !hasTodayData;
if (!shouldHideChange) {
// 如果涨跌幅列显示(即非交易时段或今日净值已更新),则显示单位净值和真实涨跌幅
return (
<>
{f.dwjz ?? '—'}
0 ? 'up' : f.zzl < 0 ? 'down' : ''} style={{ fontWeight: 700 }}>
{f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
>
);
} else {
// 否则显示估值净值和估值涨跌幅
// 如果是无估值数据的基金,直接显示净值数据
if (f.noValuation) {
return (
<>
{f.dwjz ?? '—'}
0 ? 'up' : f.zzl < 0 ? 'down' : ''} style={{ fontWeight: 700 }}>
{f.zzl !== undefined && f.zzl !== null ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'}
>
);
}
return (
<>
{f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')}
0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : '')} style={{ fontWeight: 700 }}>
{f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
>
);
}
})()}
{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}
{!isMobile && (() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const amount = profit ? profit.amount : null;
if (amount === null) {
return (
{ e.stopPropagation(); setHoldingModal({ open: true, fund: f }); }}
>
未设置
);
}
return (
{ e.stopPropagation(); setActionModal({ open: true, fund: f }); }}
>
¥{amount.toFixed(2)}
);
})()}
{(() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const profitValue = profit ? profit.profitToday : null;
const hasProfit = profitValue !== null;
return (
0 ? 'up' : profitValue < 0 ? 'down' : '') : 'muted'}
style={{ fontWeight: 700 }}
>
{hasProfit
? `${profitValue > 0 ? '+' : profitValue < 0 ? '-' : ''}¥${Math.abs(profitValue).toFixed(2)}`
: ''}
);
})()}
{!isMobile && (() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
const total = profit ? profit.profitTotal : null;
const principal = holding && holding.cost && holding.share ? holding.cost * holding.share : 0;
const asPercent = percentModes[f.code];
const hasTotal = total !== null;
const formatted = hasTotal
? (asPercent && principal > 0
? `${total > 0 ? '+' : total < 0 ? '-' : ''}${Math.abs((total / principal) * 100).toFixed(2)}%`
: `${total > 0 ? '+' : total < 0 ? '-' : ''}¥${Math.abs(total).toFixed(2)}`)
: '';
const cls = hasTotal ? (total > 0 ? 'up' : total < 0 ? 'down' : '') : 'muted';
return (
{
e.stopPropagation();
if (hasTotal) {
setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] }));
}
}}
style={{ cursor: hasTotal ? 'pointer' : 'default' }}
>
{formatted}
);
})()}
>
) : (
<>
{currentTab !== 'all' && currentTab !== 'fav' ? (
) : (
)}
{f.name}
#{f.code}
{f.noValuation ? '净值日期' : '估值时间'}
{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}
{f.noValuation ? (
// 无估值数据的基金,直接显示净值涨跌幅,不显示估值相关字段
0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'}
delta={f.zzl}
/>
) : (
<>
{(() => {
const now = new Date();
const isAfter9 = now.getHours() >= 9;
const hasTodayData = f.jzrq === todayStr;
const shouldHideChange = isTradingDay && isAfter9 && !hasTodayData;
if (shouldHideChange) return null;
return (
0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
delta={f.zzl}
/>
);
})()}
0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} />
0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)}
/>
>
)}
{(() => {
const holding = holdings[f.code];
const profit = getHoldingProfit(f, holding);
if (!profit) {
return (
持仓金额
setHoldingModal({ open: true, fund: f })}
>
未设置
);
}
return (
<>
setActionModal({ open: true, fund: f })}
>
持仓金额
¥{profit.amount.toFixed(2)}
当日盈亏
0 ? 'up' : profit.profitToday < 0 ? 'down' : ''}`}>
{profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥{Math.abs(profit.profitToday).toFixed(2)}
{profit.profitTotal !== null && (
{
e.stopPropagation();
setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] }));
}}
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
title="点击切换金额/百分比"
>
持有收益{percentModes[f.code] ? '(%)' : ''}
0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''}`}>
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
{percentModes[f.code]
? `${Math.abs((holding.cost * holding.share) ? (profit.profitTotal / (holding.cost * holding.share)) * 100 : 0).toFixed(2)}%`
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`
}
)}
>
);
})()}
{f.estPricedCoverage > 0.05 && (
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
)}
toggleCollapse(f.code)}
>
{!collapsedCodes.has(f.code) && (
{Array.isArray(f.holdings) && f.holdings.length ? (
{f.holdings.map((h, idx) => (
{h.name}
{typeof h.change === 'number' && (
0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
)}
{h.weight}
))}
) : (
暂无重仓数据
)}
)}
>
)}
))}
>
)}
{fundDeleteConfirm && (
{
removeFund(fundDeleteConfirm.code);
setFundDeleteConfirm(null);
}}
onCancel={() => setFundDeleteConfirm(null)}
/>
)}
{logoutConfirmOpen && (
{
setLogoutConfirmOpen(false);
handleLogout();
}}
onCancel={() => setLogoutConfirmOpen(false)}
/>
)}
数据源:实时估值与重仓直连东方财富,仅供个人学习及参考使用。数据可能存在延迟,不作为任何投资建议
注:估算数据与真实结算数据会有1%左右误差,非股票型基金误差较大
遇到任何问题或需求建议可
{feedbackOpen && (
setFeedbackOpen(false)}
user={user}
/>
)}
{addResultOpen && (
setAddResultOpen(false)}
/>
)}
{addFundToGroupOpen && (
g.id === currentTab)?.codes || []}
onClose={() => setAddFundToGroupOpen(false)}
onAdd={handleAddFundsToGroup}
/>
)}
{actionModal.open && (
setActionModal({ open: false, fund: null })}
onAction={(type) => handleAction(type, actionModal.fund)}
/>
)}
{tradeModal.open && (
setTradeModal({ open: false, fund: null, type: 'buy' })}
onConfirm={(data) => handleTrade(tradeModal.fund, data)}
/>
)}
{clearConfirm && (
setClearConfirm(null)}
confirmText="确认清空"
/>
)}
{holdingModal.open && (
setHoldingModal({ open: false, fund: null })}
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
/>
)}
{donateOpen && (
setDonateOpen(false)}>
e.stopPropagation()}
>
☕ 请作者喝杯咖啡
感谢您的支持!您的鼓励是我持续维护和更新的动力。
)}
{groupManageOpen && (
setGroupManageOpen(false)}
onSave={handleUpdateGroups}
/>
)}
{groupModalOpen && (
setGroupModalOpen(false)}
onConfirm={handleAddGroup}
/>
)}
{successModal.open && (
setSuccessModal({ open: false, message: '' })}
/>
)}
{cloudConfigModal.open && (
{
if (cloudConfigModal.type === 'conflict' && cloudConfigModal.cloudData) {
applyCloudConfig(cloudConfigModal.cloudData);
}
setCloudConfigModal({ open: false, userId: null });
}}
/>
)}
{settingsOpen && (
setSettingsOpen(false)}>
e.stopPropagation()}>
设置
配置刷新频率
刷新频率
{[10, 30, 60, 120, 300].map((s) => (
))}
setTempSeconds(Number(e.target.value))}
placeholder="自定义秒数"
/>
{tempSeconds < 10 && (
最小 10 秒
)}
数据导出
数据导入
{importMsg && (
{importMsg}
)}
)}
{/* 更新提示弹窗 */}
{updateModalOpen && (
setUpdateModalOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 10002 }}
>
e.stopPropagation()}
>
更新提示
检测到新版本,是否刷新浏览器以更新?
更新内容如下:
{updateContent && (
{updateContent}
)}
)}
{/* 登录模态框 */}
{loginModalOpen && (
{
setLoginModalOpen(false);
setLoginError('');
setLoginSuccess('');
setLoginEmail('');
}}
>
e.stopPropagation()}>
邮箱登录
使用邮箱验证登录
)}
{/* 全局轻提示 Toast */}
{toast.show && (
{toast.type === 'error' && (
)}
{toast.type === 'success' && (
)}
{toast.message}
)}
);
}
|