Merge remote-tracking branch 'origin/main'
This commit is contained in:
38
app/api/AGENTS.md
Normal file
38
app/api/AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# app/api/ — Data Fetching Layer
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Single file (`fund.js`, ~954 lines) containing ALL external data fetching for the entire application. Pure client-side: JSONP + script tag injection to bypass CORS.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `fetchFundData(code)` | Main fund data (valuation + NAV + holdings). Uses 天天基金 JSONP |
|
||||
| `fetchFundDataFallback(code)` | Backup data source when primary fails |
|
||||
| `fetchSmartFundNetValue(code, date)` | Smart NAV lookup with date fallback |
|
||||
| `searchFunds(val)` | Fund search by name/code (东方财富) |
|
||||
| `fetchFundHistory(code, range)` | Historical NAV data via pingzhongdata |
|
||||
| `fetchFundPingzhongdata(code)` | Raw eastmoney pingzhongdata (trend, grand total) |
|
||||
| `fetchMarketIndices()` | 24 A-share/HK/US indices via 腾讯财经 |
|
||||
| `fetchShanghaiIndexDate()` | Shanghai index date for trading day check |
|
||||
| `parseFundTextWithLLM(text)` | OCR text → fund codes via LLM (apis.iflow.cn) |
|
||||
| `loadScript(url)` | JSONP helper — creates script tag, waits for global var |
|
||||
| `fetchRelatedSectors(code)` | Fund sector/track info (unused in main UI) |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **JSONP pattern**: `loadScript(url)` → sets global callback → script.onload → reads `window.XXX` → cleanup
|
||||
- **All functions return Promises** — async/await throughout
|
||||
- **Cached via `cachedRequest()`** from `app/lib/cacheRequest.js`
|
||||
- **Error handling**: try/catch returning null/empty — never throws to UI
|
||||
- **Market indices**: `MARKET_INDEX_KEYS` array defines 24 indices with `code`, `varKey`, `name`
|
||||
- **Stock code normalization**: `normalizeTencentCode()` handles A-share (6-digit), HK (5-digit), US (letter codes)
|
||||
|
||||
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||
|
||||
- **Hardcoded API keys** (lines 911-914) — plaintext LLM service keys in source
|
||||
- **Empty catch blocks** — several `catch (e) {}` silently swallowing errors
|
||||
- **Global window pollution** — JSONP callbacks assigned to `window.jsonpgz`, `window.SuggestData_*`, etc.
|
||||
- **No retry logic** — failed requests return null, no exponential backoff
|
||||
- **Script cleanup race conditions** — scripts removed from DOM after onload/onerror, but timeout may trigger after removal
|
||||
@@ -155,6 +155,38 @@ const parseLatestNetValueFromLsjzContent = (content) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析历史净值数据(支持多条记录)
|
||||
* 返回按日期升序排列的净值数组
|
||||
*/
|
||||
const parseNetValuesFromLsjzContent = (content) => {
|
||||
if (!content || content.includes('暂无数据')) return [];
|
||||
const rowMatches = content.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||
const results = [];
|
||||
for (const row of rowMatches) {
|
||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/gi) || [];
|
||||
if (!cells.length) continue;
|
||||
const getText = (td) => td.replace(/<[^>]+>/g, '').trim();
|
||||
const dateStr = getText(cells[0] || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
|
||||
const navStr = getText(cells[1] || '');
|
||||
const nav = parseFloat(navStr);
|
||||
if (!Number.isFinite(nav)) continue;
|
||||
let growth = null;
|
||||
for (const c of cells) {
|
||||
const txt = getText(c);
|
||||
const m = txt.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||
if (m) {
|
||||
growth = parseFloat(m[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
results.push({ date: dateStr, nav, growth });
|
||||
}
|
||||
// 返回按日期升序排列的结果(API返回的是倒序,需要反转)
|
||||
return results.reverse();
|
||||
};
|
||||
|
||||
const extractHoldingsReportDate = (html) => {
|
||||
if (!html) return null;
|
||||
|
||||
@@ -316,16 +348,19 @@ export const fetchFundData = async (c) => {
|
||||
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
||||
};
|
||||
const lsjzPromise = new Promise((resolveT) => {
|
||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=2&sdate=&edate=`;
|
||||
loadScript(url)
|
||||
.then((apidata) => {
|
||||
const content = apidata?.content || '';
|
||||
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||
if (latest && latest.nav) {
|
||||
const navList = parseNetValuesFromLsjzContent(content);
|
||||
if (navList.length > 0) {
|
||||
const latest = navList[navList.length - 1];
|
||||
const previousNav = navList.length > 1 ? navList[navList.length - 2] : null;
|
||||
resolveT({
|
||||
dwjz: String(latest.nav),
|
||||
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||
jzrq: latest.date
|
||||
jzrq: latest.date,
|
||||
lastNav: previousNav ? String(previousNav.nav) : null
|
||||
});
|
||||
} else {
|
||||
resolveT(null);
|
||||
@@ -506,6 +541,7 @@ export const fetchFundData = async (c) => {
|
||||
gzData.dwjz = tData.dwjz;
|
||||
gzData.jzrq = tData.jzrq;
|
||||
gzData.zzl = tData.zzl;
|
||||
gzData.lastNav = tData.lastNav;
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
@@ -909,8 +945,8 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
};
|
||||
|
||||
const API_KEYS = [
|
||||
'sk-5b03d4e02ec22dd2ba233fb6d2dd549b',
|
||||
'sk-5f14ce9c6e94af922bf592942426285c'
|
||||
'sk-25b8a4a3d88a49e82e87c981d9d8f6b4',
|
||||
'sk-1565f822d5bd745b6529cfdf28b55574'
|
||||
// 添加更多 API Key 到这里
|
||||
];
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 182 KiB |
56
app/components/AGENTS.md
Normal file
56
app/components/AGENTS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# app/components/ — UI Components
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
47 app-specific React components (all client-side). Modals dominate (~26). Core display: FundCard, PcFundTable, MobileFundTable.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
app/components/
|
||||
├── Core Display (6)
|
||||
│ ├── FundCard.jsx # Individual fund card (valuation + holdings)
|
||||
│ ├── PcFundTable.jsx # Desktop table layout
|
||||
│ ├── MobileFundTable.jsx # Mobile list with swipe actions
|
||||
│ ├── MobileFundCardDrawer.jsx# Mobile fund detail drawer
|
||||
│ ├── GroupSummary.jsx # Group portfolio summary
|
||||
│ └── MarketIndexAccordion.jsx# Market indices (24 A/HK/US)
|
||||
├── Modals (26)
|
||||
│ ├── Fund ops: AddFundToGroupModal, GroupManageModal, GroupModal, AddResultModal
|
||||
│ ├── Trading: TradeModal, HoldingEditModal, HoldingActionModal, TransactionHistoryModal, PendingTradesModal, DcaModal, AddHistoryModal
|
||||
│ ├── Settings: SettingsModal, MarketSettingModal, MobileSettingModal, PcTableSettingModal, SortSettingModal
|
||||
│ ├── Auth: LoginModal, CloudConfigModal
|
||||
│ ├── Scan: ScanPickModal, ScanProgressModal, ScanImportConfirmModal, ScanImportProgressModal
|
||||
│ └── Misc: ConfirmModal, SuccessModal, DonateModal, FeedbackModal, WeChatModal, UpdatePromptModal, FundHistoryNetValueModal
|
||||
├── Charts (3)
|
||||
│ ├── FundIntradayChart.jsx # Intraday valuation chart (localStorage data)
|
||||
│ ├── FundTrendChart.jsx # Fund trend chart (pingzhongdata)
|
||||
│ └── FundHistoryNetValue.jsx # Historical NAV display
|
||||
└── Utilities (7)
|
||||
├── Icons.jsx # Custom SVG icons (Close, Eye, Moon, Sun, etc.)
|
||||
├── Common.jsx # Shared UI helpers
|
||||
├── FitText.jsx # Auto-fit text sizing
|
||||
├── RefreshButton.jsx # Manual refresh control
|
||||
├── EmptyStateCard.jsx # Empty state placeholder
|
||||
├── Announcement.jsx # Banner announcement
|
||||
├── ThemeColorSync.jsx # Theme meta tag sync
|
||||
├── PwaRegister.jsx # Service worker registration
|
||||
└── AnalyticsGate.jsx # Conditional GA loader
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **All client components** — `'use client'` at top, no server components
|
||||
- **State from parent** — page.jsx manages ALL state; components receive props only
|
||||
- **shadcn/ui primitives** — imported from `@/components/ui/*`
|
||||
- **Mobile/Desktop switching** — parent passes `isMobile` prop; 640px breakpoint
|
||||
- **Modals**: use `useBodyScrollLock(open)` hook for scroll prevention
|
||||
- **Icons**: mix of custom SVG (Icons.jsx) + lucide-react
|
||||
- **Styling**: glassmorphism via CSS variables (globals.css), no component-level CSS
|
||||
|
||||
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||
|
||||
- **No prop drilling avoidance** — all state flows from page.jsx via props (30+ prop holes in FundCard)
|
||||
- **Modal sprawl** — 26 modals could benefit from a modal manager/context
|
||||
- **Swipe gesture duplication** — MobileFundTable and MobileFundCardDrawer both implement swipe logic
|
||||
- **No loading skeletons** — components show spinners, not skeleton placeholders
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { CloseIcon, PlusIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,8 +11,17 @@ import {
|
||||
|
||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdings = {}, onClose, onAdd }) {
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
||||
const availableFunds = useMemo(() => {
|
||||
const base = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
||||
if (!searchQuery.trim()) return base;
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return base.filter(f =>
|
||||
(f.name && f.name.toLowerCase().includes(query)) ||
|
||||
(f.code && f.code.includes(query))
|
||||
);
|
||||
}, [allFunds, currentGroupCodes, searchQuery]);
|
||||
|
||||
const getHoldingAmount = (fund) => {
|
||||
const holding = holdings[fund?.code];
|
||||
@@ -44,6 +54,22 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdi
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||
>
|
||||
<style>{`
|
||||
.group-manage-list-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.group-manage-list-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.group-manage-list-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.group-manage-list-container::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
`}</style>
|
||||
<DialogTitle className="sr-only">添加基金到分组</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -55,10 +81,45 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group-manage-list-container" style={{ maxHeight: '50vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||||
<div style={{ marginBottom: 16, position: 'relative' }}>
|
||||
<Search
|
||||
width="16"
|
||||
height="16"
|
||||
className="muted"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input no-zoom"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索基金名称或编号"
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingLeft: 36,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group-manage-list-container"
|
||||
style={{
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--border) transparent',
|
||||
}}
|
||||
>
|
||||
{availableFunds.length === 0 ? (
|
||||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<p>所有基金已在该分组中</p>
|
||||
<p>{searchQuery.trim() ? '未找到匹配的基金' : '所有基金已在该分组中'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group-manage-list">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v19';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v20';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -75,15 +75,14 @@ export default function Announcement() {
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.8 更新内容:</p>
|
||||
<p>1. 增加关联板块列。</p>
|
||||
<p>2. 设置持仓支持今日首次买入。</p>
|
||||
<p>3. 加仓自动获取费率。</p>
|
||||
<p>v0.2.9 更新内容:</p>
|
||||
<p>1. 排序新增按昨日涨幅排序。</p>
|
||||
<p>2. 排序个性化设置支持切换排序形式。</p>
|
||||
<p>3. 全局设置新增显示/隐藏大盘指数。</p>
|
||||
<p>4. 新增持有天数。</p>
|
||||
<p>5. 登录方式支持 Github。</p>
|
||||
<br/>
|
||||
<p>下一版本更新内容:</p>
|
||||
<p>1. 关联板块实时估值。</p>
|
||||
<p>2. 收益曲线。</p>
|
||||
<p>3. 估值差异列。</p>
|
||||
关联板块实时估值还在测试,会在近期上线。
|
||||
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
|
||||
|
||||
export function DatePicker({ value, onChange }) {
|
||||
export function DatePicker({ value, onChange, position = 'bottom' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
|
||||
|
||||
@@ -83,16 +83,15 @@ export function DatePicker({ value, onChange }) {
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: position === 'top' ? -10 : 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
exit={{ opacity: 0, y: position === 'top' ? -10 : 10, scale: 0.95 }}
|
||||
className="date-picker-dropdown glass card"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
...(position === 'top' ? { bottom: '100%', marginBottom: 8 } : { top: '100%', marginTop: 8 }),
|
||||
left: 0,
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
zIndex: 10
|
||||
}}
|
||||
|
||||
@@ -267,6 +267,20 @@ export default function FundCard({
|
||||
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
{holding?.firstPurchaseDate && !masked && (() => {
|
||||
const today = dayjs.tz(todayStr, TZ);
|
||||
const purchaseDate = dayjs.tz(holding.firstPurchaseDate, TZ);
|
||||
if (!purchaseDate.isValid()) return null;
|
||||
const days = today.diff(purchaseDate, 'day');
|
||||
return (
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">持有天数</span>
|
||||
<span className="value">
|
||||
{days}天
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">当日收益</span>
|
||||
<span
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
tension: 0.2,
|
||||
order: 2
|
||||
},
|
||||
...grandDatasets,
|
||||
...(['1y', '3y', 'all'].includes(range) ? [] : grandDatasets),
|
||||
{
|
||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||
label: '买入',
|
||||
@@ -261,7 +261,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData]);
|
||||
}, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData, range]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
@@ -615,6 +615,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
)}
|
||||
</div>
|
||||
{Array.isArray(data.grandTotalSeries) &&
|
||||
!['1y', '3y', 'all'].includes(range) &&
|
||||
data.grandTotalSeries
|
||||
.filter((_, idx) => idx > 0)
|
||||
.map((series, displayIdx) => {
|
||||
@@ -646,7 +647,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
}
|
||||
|
||||
const rawPoint = pointsByDate.get(targetDate);
|
||||
// 注意:Data_grandTotal 某些对比线可能不包含区间最后一天的点。
|
||||
// 旧逻辑是对 `targetDate` 做严格匹配,缺点就会得到 `--`。
|
||||
// 新逻辑:找不到精确日期时,回退到该对比线在区间内最近的可用日期。
|
||||
let rawPoint = pointsByDate.get(targetDate);
|
||||
if ((rawPoint === undefined || rawPoint === null) && baseValue != null) {
|
||||
for (let i = currentIndex; i >= 0; i--) {
|
||||
const d = data[i];
|
||||
if (!d) continue;
|
||||
const v = pointsByDate.get(d.date);
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
rawPoint = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (baseValue != null && typeof rawPoint === 'number' && Number.isFinite(rawPoint)) {
|
||||
const normalized = rawPoint - baseValue;
|
||||
valueText = `${normalized.toFixed(2)}%`;
|
||||
|
||||
@@ -68,12 +68,15 @@ export default function GroupSummary({
|
||||
groupName,
|
||||
getProfit,
|
||||
stickyTop,
|
||||
isSticky = false,
|
||||
onToggleSticky,
|
||||
masked,
|
||||
onToggleMasked,
|
||||
marketIndexAccordionHeight,
|
||||
navbarHeight
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
const [metricSize, setMetricSize] = useState(18);
|
||||
@@ -127,7 +130,7 @@ export default function GroupSummary({
|
||||
|
||||
if (profit) {
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
totalAsset += Math.round(profit.amount * 100) / 100;
|
||||
if (profit.profitToday != null) {
|
||||
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
|
||||
totalProfitToday += profit.profitToday;
|
||||
@@ -177,12 +180,22 @@ export default function GroupSummary({
|
||||
metricSize,
|
||||
]);
|
||||
|
||||
const style = useMemo(()=>{
|
||||
const style = {};
|
||||
if (isSticky) {
|
||||
style.top = stickyTop + 14;
|
||||
}else if(!marketIndexAccordionHeight) {
|
||||
style.marginTop = navbarHeight;
|
||||
}
|
||||
return style;
|
||||
},[isSticky, stickyTop, marketIndexAccordionHeight, navbarHeight])
|
||||
|
||||
if (!summary.hasHolding) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isSticky ? 'group-summary-sticky' : ''}
|
||||
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className="glass card group-summary-card"
|
||||
@@ -195,7 +208,9 @@ export default function GroupSummary({
|
||||
>
|
||||
<span
|
||||
className="sticky-toggle-btn"
|
||||
onClick={() => setIsSticky(!isSticky)}
|
||||
onClick={() => {
|
||||
onToggleSticky?.(!isSticky);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CloseIcon, SettingsIcon } from './Icons';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { CloseIcon, SettingsIcon, SwitchIcon } from './Icons';
|
||||
import { DatePicker } from './Common';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const TZ = typeof Intl !== 'undefined' && Intl.DateTimeFormat
|
||||
? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai')
|
||||
: 'Asia/Shanghai';
|
||||
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) {
|
||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||
const [dateMode, setDateMode] = useState('date'); // 'date' | 'days'
|
||||
|
||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||
const dwjzRef = useRef(dwjz);
|
||||
@@ -21,10 +33,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
const [cost, setCost] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [profit, setProfit] = useState('');
|
||||
const [firstPurchaseDate, setFirstPurchaseDate] = useState('');
|
||||
const [holdingDaysInput, setHoldingDaysInput] = useState('');
|
||||
|
||||
const holdingSig = useMemo(() => {
|
||||
if (!holding) return '';
|
||||
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
|
||||
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}|${holding.firstPurchaseDate ?? ''}`;
|
||||
}, [holding]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +47,14 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
const c = holding.cost || 0;
|
||||
setShare(String(s));
|
||||
setCost(String(c));
|
||||
setFirstPurchaseDate(holding.firstPurchaseDate || '');
|
||||
|
||||
if (holding.firstPurchaseDate) {
|
||||
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(holding.firstPurchaseDate, TZ), 'day');
|
||||
setHoldingDaysInput(days > 0 ? String(days) : '');
|
||||
} else {
|
||||
setHoldingDaysInput('');
|
||||
}
|
||||
|
||||
const price = dwjzRef.current;
|
||||
if (price > 0) {
|
||||
@@ -42,7 +64,6 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
setProfit(p.toFixed(2));
|
||||
}
|
||||
}
|
||||
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [holdingSig]);
|
||||
|
||||
@@ -74,6 +95,41 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateModeToggle = () => {
|
||||
const newMode = dateMode === 'date' ? 'days' : 'date';
|
||||
setDateMode(newMode);
|
||||
|
||||
if (newMode === 'days' && firstPurchaseDate) {
|
||||
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(firstPurchaseDate, TZ), 'day');
|
||||
setHoldingDaysInput(days > 0 ? String(days) : '');
|
||||
} else if (newMode === 'date' && holdingDaysInput) {
|
||||
const days = parseInt(holdingDaysInput, 10);
|
||||
if (Number.isFinite(days) && days >= 0) {
|
||||
const date = dayjs.tz(undefined, TZ).subtract(days, 'day').format('YYYY-MM-DD');
|
||||
setFirstPurchaseDate(date);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHoldingDaysChange = (value) => {
|
||||
setHoldingDaysInput(value);
|
||||
const days = parseInt(value, 10);
|
||||
if (Number.isFinite(days) && days >= 0) {
|
||||
const date = dayjs.tz(undefined, TZ).subtract(days, 'day').format('YYYY-MM-DD');
|
||||
setFirstPurchaseDate(date);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstPurchaseDateChange = (value) => {
|
||||
setFirstPurchaseDate(value);
|
||||
if (value) {
|
||||
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(value, TZ), 'day');
|
||||
setHoldingDaysInput(days > 0 ? String(days) : '');
|
||||
} else {
|
||||
setHoldingDaysInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -94,9 +150,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
finalCost = finalShare > 0 ? principal / finalShare : 0;
|
||||
}
|
||||
|
||||
const trimmedDate = firstPurchaseDate ? firstPurchaseDate.trim() : '';
|
||||
|
||||
onSave({
|
||||
share: finalShare,
|
||||
cost: finalCost
|
||||
cost: finalCost,
|
||||
...(trimmedDate && { firstPurchaseDate: trimmedDate })
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
@@ -255,6 +314,49 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span className="muted" style={{ fontSize: '14px' }}>
|
||||
{dateMode === 'date' ? '首次买入日期' : '持有天数'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDateModeToggle}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={dateMode === 'date' ? '切换到持有天数' : '切换到日期'}
|
||||
>
|
||||
<SwitchIcon />
|
||||
{dateMode === 'date' ? '按天数' : '按日期'}
|
||||
</button>
|
||||
</div>
|
||||
{dateMode === 'date' ? (
|
||||
<DatePicker value={firstPurchaseDate} onChange={handleFirstPurchaseDateChange} position="top" />
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
className="input"
|
||||
value={holdingDaysInput}
|
||||
onChange={(e) => handleHoldingDaysChange(e.target.value)}
|
||||
placeholder="请输入持有天数"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { MailIcon } from './Icons';
|
||||
import githubImg from "../assets/github.svg";
|
||||
|
||||
export default function LoginModal({
|
||||
onClose,
|
||||
@@ -13,7 +15,8 @@ export default function LoginModal({
|
||||
loginError,
|
||||
loginSuccess,
|
||||
handleSendOtp,
|
||||
handleVerifyEmailOtp
|
||||
handleVerifyEmailOtp,
|
||||
handleGithubLogin
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -84,7 +87,6 @@ export default function LoginModal({
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={onClose}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -98,6 +100,53 @@ export default function LoginModal({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{handleGithubLogin && !loginSuccess && (
|
||||
<>
|
||||
<div
|
||||
className="login-divider"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
margin: '20px 0',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
<span className="muted" style={{ fontSize: '12px', whiteSpace: 'nowrap' }}>或使用</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="github-login-btn"
|
||||
onClick={handleGithubLogin}
|
||||
disabled={loginLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
padding: '12px 16px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
cursor: loginLoading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
opacity: loginLoading ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span className="github-icon-wrap">
|
||||
<Image unoptimized alt="项目Github地址" src={githubImg} style={{ width: '24px', height: '24px', cursor: 'pointer' }} onClick={() => window.open("https://github.com/hzm0321/real-time-fund")} />
|
||||
</span>
|
||||
<span>使用 GitHub 登录</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
'holdingDays',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
'latestNav',
|
||||
@@ -47,6 +48,7 @@ const MOBILE_COLUMN_HEADERS = {
|
||||
yesterdayChangePercent: '昨日涨幅',
|
||||
estimateChangePercent: '估值涨幅',
|
||||
totalChangePercent: '估算收益',
|
||||
holdingDays: '持有天数',
|
||||
todayProfit: '当日收益',
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
@@ -238,6 +240,7 @@ export default function MobileFundTable({
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
o.relatedSector = false;
|
||||
o.holdingDays = false;
|
||||
return o;
|
||||
})();
|
||||
|
||||
@@ -253,6 +256,7 @@ export default function MobileFundTable({
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
if (next.holdingDays === undefined) next.holdingDays = false;
|
||||
return next;
|
||||
}
|
||||
return defaultVisibility;
|
||||
@@ -349,9 +353,14 @@ export default function MobileFundTable({
|
||||
if (!stickySummaryWrapper) return stickyTop;
|
||||
|
||||
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
|
||||
const isSummaryStuck = wrapperRect.top <= stickyTop + 1;
|
||||
// 用“实际 DOM 的 top”判断 sticky 是否已生效,避免 mobile 下 stickyTop 入参与 GroupSummary 不一致导致的偏移。
|
||||
const computedTopStr = window.getComputedStyle(stickySummaryWrapper).top;
|
||||
const computedTop = Number.parseFloat(computedTopStr);
|
||||
const baseTop = Number.isFinite(computedTop) ? computedTop : stickyTop;
|
||||
const isSummaryStuck = wrapperRect.top <= baseTop + 1;
|
||||
|
||||
return isSummaryStuck ? stickyTop + stickySummaryWrapper.offsetHeight : stickyTop;
|
||||
// header 使用固定定位(top),所以也用视口坐标系下的 wrapperRect.top + 高度,确保不重叠
|
||||
return isSummaryStuck ? wrapperRect.top + stickySummaryWrapper.offsetHeight : stickyTop;
|
||||
};
|
||||
|
||||
const updateVerticalState = () => {
|
||||
@@ -437,6 +446,7 @@ export default function MobileFundTable({
|
||||
yesterdayChangePercent: 72,
|
||||
estimateChangePercent: 80,
|
||||
totalChangePercent: 80,
|
||||
holdingDays: 64,
|
||||
todayProfit: 80,
|
||||
holdingProfit: 80,
|
||||
};
|
||||
@@ -453,7 +463,7 @@ export default function MobileFundTable({
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item == null) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
@@ -510,6 +520,7 @@ export default function MobileFundTable({
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
allVisible.holdingDays = false;
|
||||
setMobileColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||
@@ -844,6 +855,23 @@ export default function MobileFundTable({
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'total-change-cell', width: columnWidthMap.totalChangePercent },
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingDays',
|
||||
header: '持有天数',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.holdingDaysValue;
|
||||
if (value == null) {
|
||||
return <div className="muted" style={{ textAlign: 'right', fontSize: '12px' }}>—</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ fontWeight: 700, textAlign: 'right' }}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'holding-days-cell', width: columnWidthMap.holdingDays ?? 64 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'todayProfit',
|
||||
header: '当日收益',
|
||||
@@ -854,7 +882,6 @@ export default function MobileFundTable({
|
||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||
const percentStr = original.todayProfitPercent ?? '';
|
||||
const isUpdated = original.isUpdated;
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
@@ -862,7 +889,7 @@ export default function MobileFundTable({
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -1015,7 +1042,7 @@ export default function MobileFundTable({
|
||||
|
||||
const getAlignClass = (columnId) => {
|
||||
if (columnId === 'fundName') return '';
|
||||
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'holdingDays', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||
return 'text-right';
|
||||
};
|
||||
|
||||
|
||||
@@ -43,11 +43,13 @@ const NON_FROZEN_COLUMN_IDS = [
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
'holdingAmount',
|
||||
'holdingDays',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
'latestNav',
|
||||
'estimateNav',
|
||||
];
|
||||
|
||||
const COLUMN_HEADERS = {
|
||||
relatedSector: '关联板块',
|
||||
latestNav: '最新净值',
|
||||
@@ -56,6 +58,7 @@ const COLUMN_HEADERS = {
|
||||
estimateChangePercent: '估值涨幅',
|
||||
totalChangePercent: '估算收益',
|
||||
holdingAmount: '持仓金额',
|
||||
holdingDays: '持有天数',
|
||||
todayProfit: '当日收益',
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
@@ -288,13 +291,15 @@ export default function PcFundTable({
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
if (next.holdingDays === undefined) next.holdingDays = false;
|
||||
return next;
|
||||
}
|
||||
const allVisible = {};
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
allVisible.relatedSector = false;
|
||||
return allVisible;
|
||||
allVisible.relatedSector = false;
|
||||
allVisible.holdingDays = false;
|
||||
return allVisible;
|
||||
})();
|
||||
const columnSizing = (() => {
|
||||
const s = currentGroupPc?.pcTableColumns;
|
||||
@@ -366,6 +371,7 @@ export default function PcFundTable({
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
allVisible.holdingDays = false;
|
||||
setColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||
@@ -466,7 +472,7 @@ export default function PcFundTable({
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item == null) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
results.push(await worker(item));
|
||||
}
|
||||
});
|
||||
@@ -848,6 +854,28 @@ export default function PcFundTable({
|
||||
cellClassName: 'holding-amount-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingDays',
|
||||
header: '持有天数',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.holdingDaysValue;
|
||||
if (value == null) {
|
||||
return <div className="muted" style={{ textAlign: 'right', fontSize: '12px' }}>—</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ fontWeight: 700, textAlign: 'right' }}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'holding-days-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'todayProfit',
|
||||
header: '当日收益',
|
||||
@@ -866,7 +894,7 @@ export default function PcFundTable({
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -1046,19 +1074,22 @@ export default function PcFundTable({
|
||||
const isNameColumn =
|
||||
header.column.id === 'fundName' ||
|
||||
header.column.columnDef?.accessorKey === 'fundName';
|
||||
const align = isNameColumn ? '' : 'text-center';
|
||||
const isRightAligned = NON_FROZEN_COLUMN_IDS.includes(header.column.id);
|
||||
const align = isNameColumn ? '' : isRightAligned ? 'text-right' : 'text-center';
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`table-header-cell ${align}`}
|
||||
style={style}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<div style={{ paddingRight: isRightAligned ? '20px' : '0' }}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</div>
|
||||
{!forPortal && (
|
||||
<div
|
||||
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||
@@ -1201,19 +1232,9 @@ export default function PcFundTable({
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||
const isNameColumn = columnId === 'fundName';
|
||||
const rightAlignedColumns = new Set([
|
||||
'latestNav',
|
||||
'estimateNav',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
'holdingAmount',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
]);
|
||||
const align = isNameColumn
|
||||
? ''
|
||||
: rightAlignedColumns.has(columnId)
|
||||
: NON_FROZEN_COLUMN_IDS.includes(columnId)
|
||||
? 'text-right'
|
||||
: 'text-center';
|
||||
const cellClassName =
|
||||
@@ -1281,19 +1302,22 @@ export default function PcFundTable({
|
||||
const isNameColumn =
|
||||
header.column.id === 'fundName' ||
|
||||
header.column.columnDef?.accessorKey === 'fundName';
|
||||
const align = isNameColumn ? '' : 'text-center';
|
||||
const isRightAligned = NON_FROZEN_COLUMN_IDS.includes(header.column.id);
|
||||
const align = isNameColumn ? '' : isRightAligned ? 'text-right' : 'text-center';
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`table-header-cell ${align}`}
|
||||
style={style}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<div style={{ paddingRight: isRightAligned ? '20px' : '0' }}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
@@ -19,10 +20,14 @@ export default function SettingsModal({
|
||||
containerWidth = 1200,
|
||||
setContainerWidth,
|
||||
onResetContainerWidth,
|
||||
showMarketIndexPc = true,
|
||||
showMarketIndexMobile = true,
|
||||
}) {
|
||||
const [sliderDragging, setSliderDragging] = useState(false);
|
||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
||||
const [localShowMarketIndexPc, setLocalShowMarketIndexPc] = useState(showMarketIndexPc);
|
||||
const [localShowMarketIndexMobile, setLocalShowMarketIndexMobile] = useState(showMarketIndexMobile);
|
||||
const pageWidthTrackRef = useRef(null);
|
||||
|
||||
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
@@ -55,6 +60,14 @@ export default function SettingsModal({
|
||||
setLocalSeconds(tempSeconds);
|
||||
}, [tempSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalShowMarketIndexPc(showMarketIndexPc);
|
||||
}, [showMarketIndexPc]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalShowMarketIndexMobile(showMarketIndexMobile);
|
||||
}, [showMarketIndexMobile]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -162,6 +175,22 @@ export default function SettingsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>显示大盘指数</div>
|
||||
<div className="row" style={{ justifyContent: 'flex-start', alignItems: 'center' }}>
|
||||
<Switch
|
||||
checked={isMobile ? localShowMarketIndexMobile : localShowMarketIndexPc}
|
||||
className="ml-2 scale-125"
|
||||
onCheckedChange={(checked) => {
|
||||
const nextValue = Boolean(checked);
|
||||
if (isMobile) setLocalShowMarketIndexMobile(nextValue);
|
||||
else setLocalShowMarketIndexPc(nextValue);
|
||||
}}
|
||||
aria-label="显示大盘指数"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
@@ -188,7 +217,12 @@ export default function SettingsModal({
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button
|
||||
className="button"
|
||||
onClick={(e) => saveSettings(e, localSeconds)}
|
||||
onClick={(e) => saveSettings(
|
||||
e,
|
||||
localSeconds,
|
||||
isMobile ? localShowMarketIndexMobile : localShowMarketIndexPc,
|
||||
isMobile
|
||||
)}
|
||||
disabled={localSeconds < 30}
|
||||
>
|
||||
保存并关闭
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerClose,
|
||||
} from "@/components/ui/drawer";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
|
||||
@@ -33,6 +34,8 @@ export default function SortSettingModal({
|
||||
rules = [],
|
||||
onChangeRules,
|
||||
onResetRules,
|
||||
sortDisplayMode = "buttons",
|
||||
onChangeSortDisplayMode,
|
||||
}) {
|
||||
const [localRules, setLocalRules] = useState(rules);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
@@ -120,6 +123,59 @@ export default function SortSettingModal({
|
||||
: "pc-table-setting-body"
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="pc-table-setting-subtitle"
|
||||
style={{ margin: 0, fontSize: 14 }}
|
||||
>
|
||||
排序形式
|
||||
</h3>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginLeft: "auto" }}>
|
||||
<RadioGroup
|
||||
value={sortDisplayMode}
|
||||
onValueChange={(value) => onChangeSortDisplayMode?.(value)}
|
||||
className="flex flex-row items-center gap-4"
|
||||
>
|
||||
<label
|
||||
htmlFor="sort-display-mode-buttons"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
color: "var(--text)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<RadioGroupItem id="sort-display-mode-buttons" value="buttons" />
|
||||
<span>按钮</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="sort-display-mode-dropdown"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
color: "var(--text)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<RadioGroupItem id="sort-display-mode-dropdown" value="dropdown" />
|
||||
<span>下拉单选</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -3348,6 +3348,35 @@ input[type="number"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* ========== GitHub 登录按钮样式 ========== */
|
||||
.github-login-btn {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.github-login-btn:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(34, 211, 238, 0.05);
|
||||
}
|
||||
|
||||
.github-login-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
[data-theme="light"] .github-login-btn {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="light"] .github-login-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3af;
|
||||
}
|
||||
|
||||
[data-theme="light"] .github-login-btn img {
|
||||
filter: brightness(0.2);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
28
app/lib/AGENTS.md
Normal file
28
app/lib/AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# app/lib/ — Core Utilities
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
4 utility modules: Supabase client, request cache, trading calendar, valuation time-series.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| File | Exports | Purpose |
|
||||
|------|---------|---------|
|
||||
| `supabase.js` | `supabase`, `isSupabaseConfigured` | Supabase client (or noop fallback). Auth + DB + realtime |
|
||||
| `cacheRequest.js` | `cachedRequest()`, `clearCachedRequest()` | In-memory request dedup + TTL cache |
|
||||
| `tradingCalendar.js` | `loadHolidaysForYear()`, `loadHolidaysForYears()`, `isTradingDay()` | Chinese stock market holiday detection via CDN |
|
||||
| `valuationTimeseries.js` | `recordValuation()`, `getValuationSeries()`, `clearFund()`, `getAllValuationSeries()` | Fund valuation time-series (localStorage) |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **supabase.js**: creates `createNoopSupabase()` when env vars missing — all auth/DB methods return safe defaults
|
||||
- **cacheRequest.js**: deduplicates concurrent requests for same key; default 10s TTL
|
||||
- **tradingCalendar.js**: downloads `chinese-days` JSON from cdn.jsdelivr.net; caches per-year in Map
|
||||
- **valuationTimeseries.js**: localStorage key `fundValuationTimeseries`; auto-clears old dates on new data
|
||||
|
||||
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||
|
||||
- **No error reporting** — all modules silently fail (console.warn at most)
|
||||
- **localStorage quota not handled** — valuationTimeseries writes without checking available space
|
||||
- **Cache only in-memory** — cacheRequest lost on page reload; no persistent cache
|
||||
- **No request cancellation** — JSONP scripts can't be aborted once injected
|
||||
@@ -33,6 +33,7 @@ const createNoopSupabase = () => ({
|
||||
data: { subscription: { unsubscribe: () => { } } }
|
||||
}),
|
||||
signInWithOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signInWithOAuth: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
verifyOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signOut: async () => ({ error: null })
|
||||
},
|
||||
|
||||
354
app/page.jsx
354
app/page.jsx
@@ -71,6 +71,13 @@ import packageJson from '../package.json';
|
||||
import PcFundTable from './components/PcFundTable';
|
||||
import MobileFundTable from './components/MobileFundTable';
|
||||
import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -129,6 +136,9 @@ export default function HomePage() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tempSeconds, setTempSeconds] = useState(60);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
const [showMarketIndexPc, setShowMarketIndexPc] = useState(true);
|
||||
const [showMarketIndexMobile, setShowMarketIndexMobile] = useState(true);
|
||||
const [isGroupSummarySticky, setIsGroupSummarySticky] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -141,6 +151,8 @@ export default function HomePage() {
|
||||
if (Number.isFinite(num)) {
|
||||
setContainerWidth(Math.min(2000, Math.max(600, num)));
|
||||
}
|
||||
if (typeof parsed?.showMarketIndexPc === 'boolean') setShowMarketIndexPc(parsed.showMarketIndexPc);
|
||||
if (typeof parsed?.showMarketIndexMobile === 'boolean') setShowMarketIndexMobile(parsed.showMarketIndexMobile);
|
||||
} catch { }
|
||||
}, []);
|
||||
|
||||
@@ -167,15 +179,19 @@ export default function HomePage() {
|
||||
{ id: 'default', label: '默认', enabled: true },
|
||||
// 估值涨幅为原始名称,“涨跌幅”为别名
|
||||
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
|
||||
// 昨日涨幅排序:默认隐藏
|
||||
{ id: 'yesterdayIncrease', label: '昨日涨幅', enabled: false },
|
||||
// 持仓金额排序:默认隐藏
|
||||
{ id: 'holdingAmount', label: '持仓金额', enabled: false },
|
||||
{ id: 'holding', label: '持有收益', enabled: true },
|
||||
{ id: 'name', label: '基金名称', alias: '名称', enabled: true },
|
||||
];
|
||||
const SORT_DISPLAY_MODES = new Set(['buttons', 'dropdown']);
|
||||
|
||||
// 排序状态
|
||||
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding, holdingAmount
|
||||
const [sortBy, setSortBy] = useState('default'); // default, name, yield, yesterdayIncrease, holding, holdingAmount
|
||||
const [sortOrder, setSortOrder] = useState('desc'); // asc | desc
|
||||
const [sortDisplayMode, setSortDisplayMode] = useState('buttons'); // buttons | dropdown
|
||||
const [isSortLoaded, setIsSortLoaded] = useState(false);
|
||||
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
|
||||
const [sortSettingOpen, setSortSettingOpen] = useState(false);
|
||||
@@ -197,6 +213,13 @@ export default function HomePage() {
|
||||
if (parsed && Array.isArray(parsed.localSortRules)) {
|
||||
rulesFromSettings = parsed.localSortRules;
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed.localSortDisplayMode === 'string' &&
|
||||
SORT_DISPLAY_MODES.has(parsed.localSortDisplayMode)
|
||||
) {
|
||||
setSortDisplayMode(parsed.localSortDisplayMode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -265,6 +288,7 @@ export default function HomePage() {
|
||||
const next = {
|
||||
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
||||
localSortRules: sortRules,
|
||||
localSortDisplayMode: sortDisplayMode,
|
||||
};
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(next));
|
||||
// 更新后标记 customSettings 脏并触发云端同步
|
||||
@@ -273,7 +297,7 @@ export default function HomePage() {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [sortBy, sortOrder, sortRules, isSortLoaded]);
|
||||
}, [sortBy, sortOrder, sortRules, sortDisplayMode, isSortLoaded]);
|
||||
|
||||
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
|
||||
useEffect(() => {
|
||||
@@ -394,6 +418,7 @@ export default function HomePage() {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [groups, currentTab]); // groups 或 tab 变化可能导致 filterBar 高度变化
|
||||
|
||||
const handleMobileSearchClick = (e) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
@@ -446,6 +471,13 @@ export default function HomePage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const shouldShowMarketIndex = isMobile ? showMarketIndexMobile : showMarketIndexPc;
|
||||
|
||||
// 当关闭大盘指数时,重置它的高度,避免 top/stickyTop 仍沿用旧值
|
||||
useEffect(() => {
|
||||
if (!shouldShowMarketIndex) setMarketIndexAccordionHeight(0);
|
||||
}, [shouldShowMarketIndex]);
|
||||
|
||||
// 检查更新
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState('');
|
||||
@@ -568,26 +600,30 @@ export default function HomePage() {
|
||||
|
||||
if (canCalcTodayProfit) {
|
||||
const amount = holding.share * currentNav;
|
||||
// 优先用 zzl (真实涨跌幅), 降级用 gszzl
|
||||
// 若 gztime 日期 > jzrq,说明估值更新晚于净值日期,优先使用 gszzl 计算当日盈亏
|
||||
const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
|
||||
const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null;
|
||||
const preferGszzl =
|
||||
!!gz &&
|
||||
!!jz &&
|
||||
gz.isValid() &&
|
||||
jz.isValid() &&
|
||||
gz.startOf('day').isAfter(jz.startOf('day'));
|
||||
|
||||
let rate;
|
||||
if (preferGszzl) {
|
||||
rate = Number(fund.gszzl);
|
||||
// 优先使用昨日净值直接计算(更精确,避免涨跌幅四舍五入误差)
|
||||
const lastNav = fund.lastNav != null && fund.lastNav !== '' ? Number(fund.lastNav) : null;
|
||||
if (lastNav && Number.isFinite(lastNav) && lastNav > 0) {
|
||||
profitToday = (currentNav - lastNav) * holding.share;
|
||||
} else {
|
||||
const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN;
|
||||
rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl);
|
||||
const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
|
||||
const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null;
|
||||
const preferGszzl =
|
||||
!!gz &&
|
||||
!!jz &&
|
||||
gz.isValid() &&
|
||||
jz.isValid() &&
|
||||
gz.startOf('day').isAfter(jz.startOf('day'));
|
||||
|
||||
let rate;
|
||||
if (preferGszzl) {
|
||||
rate = Number(fund.gszzl);
|
||||
} else {
|
||||
const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN;
|
||||
rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl);
|
||||
}
|
||||
if (!Number.isFinite(rate)) rate = 0;
|
||||
profitToday = amount - (amount / (1 + rate / 100));
|
||||
}
|
||||
if (!Number.isFinite(rate)) rate = 0;
|
||||
profitToday = amount - (amount / (1 + rate / 100));
|
||||
} else {
|
||||
profitToday = null;
|
||||
}
|
||||
@@ -686,6 +722,19 @@ export default function HomePage() {
|
||||
const amountB = pb?.amount ?? Number.NEGATIVE_INFINITY;
|
||||
return sortOrder === 'asc' ? amountA - amountB : amountB - amountA;
|
||||
}
|
||||
if (sortBy === 'yesterdayIncrease') {
|
||||
const valA = Number(a.zzl);
|
||||
const valB = Number(b.zzl);
|
||||
const hasA = Number.isFinite(valA);
|
||||
const hasB = Number.isFinite(valB);
|
||||
|
||||
// 无昨日涨幅数据(界面展示为 `—`)的基金统一排在最后
|
||||
if (!hasA && !hasB) return 0;
|
||||
if (!hasA) return 1;
|
||||
if (!hasB) return -1;
|
||||
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
}
|
||||
if (sortBy === 'holding') {
|
||||
const pa = getHoldingProfit(a, holdings[a.code]);
|
||||
const pb = getHoldingProfit(b, holdings[b.code]);
|
||||
@@ -745,6 +794,9 @@ export default function HomePage() {
|
||||
const holdingAmount =
|
||||
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
|
||||
const holdingAmountValue = amount;
|
||||
const holdingDaysValue = holding?.firstPurchaseDate
|
||||
? dayjs.tz(todayStr, TZ).diff(dayjs.tz(holding.firstPurchaseDate, TZ), 'day')
|
||||
: null;
|
||||
|
||||
const profitToday = profit ? profit.profitToday : null;
|
||||
const todayProfit =
|
||||
@@ -820,6 +872,7 @@ export default function HomePage() {
|
||||
estimateProfitPercent,
|
||||
holdingAmount,
|
||||
holdingAmountValue,
|
||||
holdingDaysValue,
|
||||
todayProfit,
|
||||
todayProfitPercent,
|
||||
todayProfitValue,
|
||||
@@ -2362,6 +2415,29 @@ export default function HomePage() {
|
||||
setLoginLoading(false);
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
setLoginError('');
|
||||
if (!isSupabaseConfigured) {
|
||||
showToast('未配置 Supabase,无法登录', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isExplicitLoginRef.current = true;
|
||||
setLoginLoading(true);
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: window.location.origin
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
} catch (err) {
|
||||
setLoginError(err.message || 'GitHub 登录失败,请稍后再试');
|
||||
isExplicitLoginRef.current = false;
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 登出
|
||||
const handleLogout = async () => {
|
||||
isLoggingOutRef.current = true;
|
||||
@@ -2770,19 +2846,41 @@ export default function HomePage() {
|
||||
await refreshAll(codes);
|
||||
};
|
||||
|
||||
const saveSettings = (e, secondsOverride) => {
|
||||
const saveSettings = (e, secondsOverride, showMarketIndexOverride, isMobileOverride) => {
|
||||
e?.preventDefault?.();
|
||||
const seconds = secondsOverride ?? tempSeconds;
|
||||
const ms = Math.max(30, Number(seconds)) * 1000;
|
||||
setTempSeconds(Math.round(ms / 1000));
|
||||
setRefreshMs(ms);
|
||||
const nextShowMarketIndex = typeof showMarketIndexOverride === 'boolean'
|
||||
? showMarketIndexOverride
|
||||
: isMobileOverride
|
||||
? showMarketIndexMobile
|
||||
: showMarketIndexPc;
|
||||
|
||||
const targetIsMobile = Boolean(isMobileOverride);
|
||||
if (targetIsMobile) setShowMarketIndexMobile(nextShowMarketIndex);
|
||||
else setShowMarketIndexPc(nextShowMarketIndex);
|
||||
storageHelper.setItem('refreshMs', String(ms));
|
||||
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
setContainerWidth(w);
|
||||
try {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w }));
|
||||
if (targetIsMobile) {
|
||||
// 仅更新当前运行端对应的开关键
|
||||
window.localStorage.setItem('customSettings', JSON.stringify({
|
||||
...parsed,
|
||||
pcContainerWidth: w,
|
||||
showMarketIndexMobile: nextShowMarketIndex,
|
||||
}));
|
||||
} else {
|
||||
window.localStorage.setItem('customSettings', JSON.stringify({
|
||||
...parsed,
|
||||
pcContainerWidth: w,
|
||||
showMarketIndexPc: nextShowMarketIndex,
|
||||
}));
|
||||
}
|
||||
triggerCustomSettingsSync();
|
||||
} catch { }
|
||||
setSettingsOpen(false);
|
||||
@@ -3485,7 +3583,6 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
const isAnyModalOpen =
|
||||
settingsOpen ||
|
||||
feedbackOpen ||
|
||||
addResultOpen ||
|
||||
addFundToGroupOpen ||
|
||||
@@ -3521,7 +3618,6 @@ export default function HomePage() {
|
||||
containerRef.current.style.overflow = '';
|
||||
};
|
||||
}, [
|
||||
settingsOpen,
|
||||
feedbackOpen,
|
||||
addResultOpen,
|
||||
addFundToGroupOpen,
|
||||
@@ -3940,13 +4036,15 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MarketIndexAccordion
|
||||
navbarHeight={navbarHeight}
|
||||
onHeightChange={setMarketIndexAccordionHeight}
|
||||
isMobile={isMobile}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
{shouldShowMarketIndex && (
|
||||
<MarketIndexAccordion
|
||||
navbarHeight={navbarHeight}
|
||||
onHeightChange={setMarketIndexAccordionHeight}
|
||||
isMobile={isMobile}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
<div className="grid">
|
||||
<div className="col-12">
|
||||
<div ref={filterBarRef} className="filter-bar" style={{ top: navbarHeight + marketIndexAccordionHeight, marginTop: 0, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
@@ -4070,40 +4168,81 @@ export default function HomePage() {
|
||||
<span className="muted">排序</span>
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
<div className="chips">
|
||||
{sortRules.filter((s) => s.enabled).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`chip ${sortBy === s.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (sortBy === s.id) {
|
||||
// 同一按钮重复点击,切换升序/降序
|
||||
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
// 切换到新的排序字段,默认用降序
|
||||
setSortBy(s.id);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
{sortDisplayMode === 'dropdown' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(nextSortBy) => {
|
||||
setSortBy(nextSortBy);
|
||||
if (nextSortBy !== sortBy) setSortOrder('desc');
|
||||
}}
|
||||
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span>{s.alias || s.label}</span>
|
||||
{s.id !== 'default' && sortBy === s.id && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
lineHeight: 1,
|
||||
fontSize: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: sortOrder === 'asc' ? 1 : 0.3 }}>▲</span>
|
||||
<span style={{ opacity: sortOrder === 'desc' ? 1 : 0.3 }}>▼</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SelectTrigger
|
||||
className="h-4 min-w-[110px] py-0 text-xs shadow-none"
|
||||
style={{ background: 'var(--card-bg)', height: 36 }}
|
||||
>
|
||||
<SelectValue placeholder="选择排序规则" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortRules.filter((s) => s.enabled).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.alias || s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onValueChange={(value) => setSortOrder(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-4 min-w-[84px] py-0 text-xs shadow-none"
|
||||
style={{ background: 'var(--card-bg)', height: 36 }}
|
||||
>
|
||||
<SelectValue placeholder="排序方向" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">降序</SelectItem>
|
||||
<SelectItem value="asc">升序</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chips">
|
||||
{sortRules.filter((s) => s.enabled).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`chip ${sortBy === s.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (sortBy === s.id) {
|
||||
// 同一按钮重复点击,切换升序/降序
|
||||
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
// 切换到新的排序字段,默认用降序
|
||||
setSortBy(s.id);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
}}
|
||||
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span>{s.alias || s.label}</span>
|
||||
{s.id !== 'default' && sortBy === s.id && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
lineHeight: 1,
|
||||
fontSize: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: sortOrder === 'asc' ? 1 : 0.3 }}>▲</span>
|
||||
<span style={{ opacity: sortOrder === 'desc' ? 1 : 0.3 }}>▼</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4122,49 +4261,14 @@ export default function HomePage() {
|
||||
groupName={getGroupName()}
|
||||
getProfit={getHoldingProfit}
|
||||
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||
isSticky={isGroupSummarySticky}
|
||||
onToggleSticky={(next) => setIsGroupSummarySticky(next)}
|
||||
masked={maskAmounts}
|
||||
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
||||
marketIndexAccordionHeight={marketIndexAccordionHeight}
|
||||
navbarHeight={navbarHeight}
|
||||
/>
|
||||
|
||||
{currentTab !== 'all' && currentTab !== 'fav' && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="button-dashed"
|
||||
onClick={() => setAddFundToGroupOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
border: '2px dashed var(--border)',
|
||||
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 = 'var(--border)';
|
||||
e.currentTarget.style.color = 'var(--muted)';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<PlusIcon width="18" height="18" />
|
||||
<span>添加基金到此分组</span>
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={viewMode}
|
||||
@@ -4173,6 +4277,7 @@ export default function HomePage() {
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
|
||||
style={{ marginTop: isGroupSummarySticky ? 50 : 0 }}
|
||||
>
|
||||
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
|
||||
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */}
|
||||
@@ -4376,6 +4481,45 @@ export default function HomePage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{currentTab !== 'all' && currentTab !== 'fav' && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="button-dashed"
|
||||
onClick={() => setAddFundToGroupOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
border: '2px dashed var(--border)',
|
||||
background: 'transparent',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '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 = 'var(--border)';
|
||||
e.currentTarget.style.color = 'var(--muted)';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<PlusIcon width="18" height="18" />
|
||||
<span>添加基金到此分组</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -4725,6 +4869,8 @@ export default function HomePage() {
|
||||
containerWidth={containerWidth}
|
||||
setContainerWidth={setContainerWidth}
|
||||
onResetContainerWidth={handleResetContainerWidth}
|
||||
showMarketIndexPc={showMarketIndexPc}
|
||||
showMarketIndexMobile={showMarketIndexMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4757,6 +4903,7 @@ export default function HomePage() {
|
||||
setLoginSuccess('');
|
||||
setLoginEmail('');
|
||||
setLoginOtp('');
|
||||
setLoginLoading(false);
|
||||
}}
|
||||
loginEmail={loginEmail}
|
||||
setLoginEmail={setLoginEmail}
|
||||
@@ -4767,6 +4914,7 @@ export default function HomePage() {
|
||||
loginSuccess={loginSuccess}
|
||||
handleSendOtp={handleSendOtp}
|
||||
handleVerifyEmailOtp={handleVerifyEmailOtp}
|
||||
handleGithubLogin={isSupabaseConfigured ? handleGithubLogin : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4777,6 +4925,8 @@ export default function HomePage() {
|
||||
isMobile={isMobile}
|
||||
rules={sortRules}
|
||||
onChangeRules={setSortRules}
|
||||
sortDisplayMode={sortDisplayMode}
|
||||
onChangeSortDisplayMode={setSortDisplayMode}
|
||||
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user