diff --git a/.gitignore b/.gitignore index d4be12c..9cac844 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ fabric.properties /node_modules/ /.next/ +.vscode/ diff --git a/app/page.jsx b/app/page.jsx index acc3c47..f4bc7f0 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -228,7 +228,7 @@ function DatePicker({ value, onChange }) { onClick={handleNextMonth} className="icon-button" style={{ width: 24, height: 24 }} - // 如果下个月已经是未来,可以禁用(可选,这里简单起见不禁用翻页,只禁用日期点击) + // 如果下个月已经是未来,可以禁用(可选,这里简单起见不禁用翻页,只禁用日期点击) > > @@ -1808,60 +1808,60 @@ export default function HomePage() { return () => clearInterval(timer); }, []); - // 计算持仓收益 - const getHoldingProfit = (fund, holding) => { - if (!holding || typeof holding.share !== 'number') return null; + // 计算持仓收益 + const getHoldingProfit = (fund, holding) => { + if (!holding || typeof holding.share !== 'number') return null; - const now = new Date(); - const isAfter9 = now.getHours() >= 9; - const hasTodayData = fund.jzrq === todayStr; + const now = new Date(); + const isAfter9 = now.getHours() >= 9; + const hasTodayData = fund.jzrq === todayStr; - // 如果是交易日且9点以后,且今日净值未出,则强制使用估值(隐藏涨跌幅列模式) - const useValuation = isTradingDay && isAfter9 && !hasTodayData; + // 如果是交易日且9点以后,且今日净值未出,则强制使用估值(隐藏涨跌幅列模式) + const useValuation = isTradingDay && isAfter9 && !hasTodayData; - let currentNav; - let profitToday; + let currentNav; + let profitToday; - if (!useValuation) { - // 使用确权净值 (dwjz) - currentNav = Number(fund.dwjz); - if (!currentNav) return null; + if (!useValuation) { + // 使用确权净值 (dwjz) + currentNav = Number(fund.dwjz); + if (!currentNav) return null; - const amount = holding.share * currentNav; - // 优先用 zzl (真实涨跌幅), 降级用 gszzl - const rate = fund.zzl !== undefined ? Number(fund.zzl) : (Number(fund.gszzl) || 0); - profitToday = amount - (amount / (1 + rate / 100)); - } else { - // 否则使用估值 - currentNav = fund.estPricedCoverage > 0.05 - ? fund.estGsz - : (typeof fund.gsz === 'number' ? fund.gsz : Number(fund.dwjz)); + const amount = holding.share * currentNav; + // 优先用 zzl (真实涨跌幅), 降级用 gszzl + const rate = fund.zzl !== undefined ? Number(fund.zzl) : (Number(fund.gszzl) || 0); + profitToday = amount - (amount / (1 + rate / 100)); + } else { + // 否则使用估值 + currentNav = fund.estPricedCoverage > 0.05 + ? fund.estGsz + : (typeof fund.gsz === 'number' ? fund.gsz : Number(fund.dwjz)); - if (!currentNav) return null; + if (!currentNav) return null; - const amount = holding.share * currentNav; - // 估值涨跌幅 - const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0); - profitToday = amount - (amount / (1 + gzChange / 100)); - } + const amount = holding.share * currentNav; + // 估值涨跌幅 + const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0); + profitToday = amount - (amount / (1 + gzChange / 100)); + } - // 持仓金额 - const amount = holding.share * currentNav; + // 持仓金额 + const amount = holding.share * currentNav; - // 总收益 = (当前净值 - 成本价) * 份额 - const profitTotal = typeof holding.cost === 'number' - ? (currentNav - holding.cost) * holding.share - : null; + // 总收益 = (当前净值 - 成本价) * 份额 + const profitTotal = typeof holding.cost === 'number' + ? (currentNav - holding.cost) * holding.share + : null; - return { - amount, - profitToday, - profitTotal - }; + return { + amount, + profitToday, + profitTotal }; + }; - // 过滤和排序后的基金列表 + // 过滤和排序后的基金列表 const displayFunds = funds .filter(f => { if (currentTab === 'all') return true; @@ -1869,7 +1869,7 @@ export default function HomePage() { const group = groups.find(g => g.id === currentTab); return group ? group.codes.includes(f.code) : true; }) - .sort((a, b) => { + .sort((a, b) => { if (sortBy === 'yield') { const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (Number(a.gszzl) || 0); const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (Number(b.gszzl) || 0); @@ -1882,7 +1882,7 @@ export default function HomePage() { const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY; return sortOrder === 'asc' ? valA - valB : valB - valA; } - if(sortBy === 'name'){ + if (sortBy === 'name') { return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN'); } return 0; @@ -2170,7 +2170,7 @@ export default function HomePage() { if (savedHoldings && typeof savedHoldings === 'object') { setHoldings(savedHoldings); } - } catch {} + } catch { } }, []); useEffect(() => { @@ -2202,6 +2202,97 @@ export default function HomePage() { }); }; + // 当估值接口无法获取数据时,使用腾讯接口获取基金基本信息和净值(回退方案) + const fetchFundDataFallback = async (c) => { + return new Promise(async (resolve, reject) => { + // 先通过东方财富搜索接口获取基金名称 + const searchCallbackName = `SuggestData_fallback_${Date.now()}`; + const searchUrl = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(c)}&callback=${searchCallbackName}&_=${Date.now()}`; + + let fundName = ''; + + try { + await new Promise((resSearch, rejSearch) => { + window[searchCallbackName] = (data) => { + if (data && data.Datas && data.Datas.length > 0) { + const found = data.Datas.find(d => d.CODE === c); + if (found) { + fundName = found.NAME || found.SHORTNAME || ''; + } + } + delete window[searchCallbackName]; + resSearch(); + }; + + const script = document.createElement('script'); + script.src = searchUrl; + 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[searchCallbackName]; + rejSearch(new Error('搜索接口失败')); + }; + document.body.appendChild(script); + + // 超时处理 + setTimeout(() => { + if (window[searchCallbackName]) { + delete window[searchCallbackName]; + resSearch(); + } + }, 3000); + }); + } catch (e) { + // 搜索失败,继续尝试腾讯接口 + } + + // 使用腾讯接口获取净值数据 + const tUrl = `https://qt.gtimg.cn/q=jj${c}`; + const tScript = document.createElement('script'); + tScript.src = tUrl; + tScript.onload = () => { + const v = window[`v_jj${c}`]; + if (v && v.length > 5) { + const p = v.split('~'); + // p[1]: 基金名称, p[5]: 单位净值, p[7]: 涨跌幅, p[8]: 净值日期 + const name = fundName || p[1] || `未知基金(${c})`; + const dwjz = p[5]; + const zzl = parseFloat(p[7]); + const jzrq = p[8] ? p[8].slice(0, 10) : ''; + + if (dwjz) { + // 成功获取净值数据 + resolve({ + code: c, + name: name, + dwjz: dwjz, + gsz: null, // 无估值数据 + gztime: null, + jzrq: jzrq, + gszzl: null, // 无估值涨跌幅 + zzl: !isNaN(zzl) ? zzl : null, + noValuation: true, // 标记为无估值数据 + holdings: [] + }); + } else { + reject(new Error('未能获取到基金数据')); + } + } else { + reject(new Error('未能获取到基金数据')); + } + if (document.body.contains(tScript)) document.body.removeChild(tScript); + }; + tScript.onerror = () => { + if (document.body.contains(tScript)) document.body.removeChild(tScript); + reject(new Error('基金数据加载失败')); + }; + document.body.appendChild(tScript); + }); + }; + const fetchFundData = async (c) => { return new Promise(async (resolve, reject) => { // 腾讯接口识别逻辑优化 @@ -2226,7 +2317,8 @@ export default function HomePage() { window.jsonpgz = (json) => { window.jsonpgz = originalJsonpgz; // 立即恢复 if (!json || typeof json !== 'object') { - reject(new Error('未获取到基金估值数据')); + // 估值数据无法获取时,尝试使用腾讯接口获取基金基本信息和净值 + fetchFundDataFallback(c).then(resolve).catch(reject); return; } const gszzlNum = Number(json.gszzl); @@ -2365,7 +2457,7 @@ export default function HomePage() { }; document.body.appendChild(scriptQuote); }); - } catch (e) {} + } catch (e) { } } resolveH(holdings); }).catch(() => resolveH([])); @@ -2879,7 +2971,7 @@ export default function HomePage() { 基估宝
- 项目Github地址 window.open("https://github.com/hzm0321/real-time-fund")}/> + 项目Github地址 window.open("https://github.com/hzm0321/real-time-fund")} />
刷新 {Math.round(refreshMs / 1000)}秒 @@ -3001,56 +3093,56 @@ export default function HomePage() { data-mask-right={canRight} >
- + className="tabs" + ref={tabsRef} + onMouseDown={handleMouseDown} + onMouseLeave={handleMouseLeaveOrUp} + onMouseUp={handleMouseLeaveOrUp} + onMouseMove={handleMouseMove} + onWheel={handleWheel} + onScroll={updateTabOverflow} + > + + 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('all')} + key={g.id} + className={`tab ${currentTab === g.id ? 'active' : ''}`} + onClick={() => setCurrentTab(g.id)} transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }} > - 全部 ({funds.length}) + {g.name} ({g.codes.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) => ( + -
- -
- -
- - - 排序 - -
- {[ - { id: 'default', label: '默认' }, - { id: 'yield', label: '涨跌幅' }, - { id: 'holding', label: '持有收益' }, - { id: 'name', label: '名称' }, - ].map((s) => ( - - ))} -
+ ))}
+
{displayFunds.length === 0 ? (
@@ -3199,483 +3291,509 @@ export default function HomePage() { )} - -
- {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 }} + +
+ {viewMode === 'list' && ( +
+
基金名称
+
净值/估值
+
涨跌幅
+
估值时间
+
持仓金额
+
当日盈亏
+
持有收益
+
操作
+
+ )} + + {displayFunds.map((f) => ( + - - 删除 -
- )} - { - // 如果水平移动距离小于垂直移动距离,或者水平速度很小,视为垂直滚动意图,不进行拖拽处理 - // 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' ? ( - - ) : ( - - )} -
- { + e.stopPropagation(); // 阻止冒泡,防止触发全局收起导致状态混乱 + if (refreshing) return; + requestRemoveFund(f); + }} + style={{ pointerEvents: refreshing ? 'none' : 'auto', opacity: refreshing ? 0.6 : 1 }} > - {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 { - // 否则显示估值净值和估值涨跌幅 - 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.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.gztime || f.time || '-'} -
-
- -
-
-
- -
- - {(() => { - 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)} + { + // 如果水平移动距离小于垂直移动距离,或者水平速度很小,视为垂直滚动意图,不进行拖拽处理 + // 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 + }} > -
-
- 前10重仓股票 - -
- 涨跌幅 / 占比 -
-
- - {!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} -
-
- ))} + {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)} + > +
+
+ 前10重仓股票 + +
+ 涨跌幅 / 占比 +
+
+ + {!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} +
+
+ ))} +
+ ) : ( +
暂无重仓数据
+ )} +
+ )} +
+ )} - - - )} - - - ))} - -
-
-
- +
+
+ ))} + +
+
+
+ )}