23 Commits

Author SHA1 Message Date
hzm
5b800f7308 feat: 发布 0.2.9 2026-03-22 22:50:18 +08:00
hzm
cdda55bf4a feat: 发送验证码后隐藏 github 登录 2026-03-22 22:37:28 +08:00
hzm
d07146b819 feat: 支持 github 登录 2026-03-22 22:28:45 +08:00
hzm
7beac75160 feat: 改变添加到分组按钮位置 2026-03-22 21:31:30 +08:00
hzm
84a720164c feat: 新增持有天数 2026-03-22 14:52:29 +08:00
hzm
303071f639 feat: 调整资产汇总误差计算方式 2026-03-22 13:56:51 +08:00
hzm
73ce520573 feat: 优化当日收益计算方式 2026-03-22 13:51:21 +08:00
hzm
270bc3ab08 feat: 全局设置新增显示大盘指数开关 2026-03-20 22:17:43 +08:00
hzm
9f6d1bb768 feat: 排序个性化新增排序形式切换 2026-03-20 09:03:51 +08:00
hzm
4f438d0dc5 feat: 排序新增按昨日涨幅排序 2026-03-20 08:20:22 +08:00
hzm
d751daeb74 feat: 优化业绩走势对比线的展示 2026-03-19 22:52:22 +08:00
hzm
0ce7d18585 feat: PC 表头改为居右对齐 2026-03-19 22:29:19 +08:00
hzm
e0f6d61aaa feat: 更新模型 key 2026-03-19 21:14:45 +08:00
hzm
6557371f09 fix: PC 端基金详情弹框滚动问题 2026-03-19 11:27:36 +08:00
hzm
8d7f2d33df feat: 更新项目文档 2026-03-18 22:34:55 +08:00
hzm
82bdecca0b feat: 发布 0.2.8 版本 2026-03-18 20:26:29 +08:00
hzm
cc605fb45b feat: 增加关联板块描述 2026-03-18 20:12:03 +08:00
hzm
e8bd65e499 feat: 设置持仓支持今日首次买入 2026-03-18 20:02:08 +08:00
hzm
12229e8eeb feat: 关联板块字体大小调整 2026-03-17 20:01:49 +08:00
hzm
fb0dc25341 feat: 测试关联板块 2026-03-17 19:49:33 +08:00
hzm
b489677d3e feat: 加仓自动获取费率数据 2026-03-17 15:41:19 +08:00
hzm
104a847d2a fix: 海外基金前10重仓股票数据 2026-03-17 15:22:32 +08:00
hzm
0a97b80499 fix: 修复同步问题 2026-03-17 14:10:17 +08:00
36 changed files with 2136 additions and 532 deletions

123
AGENTS.md Normal file
View File

@@ -0,0 +1,123 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-03-21T03:22:46Z
**Commit:** 270bc3a
**Branch:** main
## OVERVIEW
Real-time mutual fund valuation tracker (基估宝). Next.js 16 App Router, pure JavaScript (JSX, no TypeScript), static export to GitHub Pages. Glassmorphism UI with heavy custom CSS variables (3557-line globals.css). All data via JSONP/script injection to external Chinese financial APIs (天天基金, 东方财富, 腾讯财经). localStorage as primary database; Supabase for optional cloud sync.
## STRUCTURE
```
real-time-fund/
├── app/ # Next.js App Router root
│ ├── page.jsx # MONOLITHIC SPA entry (~3000+ lines) — ALL state + logic here
│ ├── layout.jsx # Root layout (theme init, PWA, GA, Toaster)
│ ├── globals.css # Tailwind v4 + glassmorphism CSS variables (~3557 lines)
│ ├── api/fund.js # ALL external data fetching (~954 lines, JSONP + script injection)
│ ├── components/ # 47 app-specific UI components (modals, cards, tables, charts)
│ ├── lib/ # Core utilities: supabase, cacheRequest, tradingCalendar, valuationTimeseries
│ ├── hooks/ # Custom hooks: useBodyScrollLock, useFundFuzzyMatcher
│ └── assets/ # Static images (GitHub SVG, donation QR codes)
├── components/ui/ # 15 shadcn/ui primitives (accordion, button, dialog, drawer, etc.)
├── lib/utils.js # cn() helper only (clsx + tailwind-merge)
├── public/ # Static: allFund.json, PWA manifest, service worker, icon
├── doc/ # Documentation: localStorage schema, Supabase SQL, dev group QR
├── .github/workflows/ # CI/CD: nextjs.yml (GitHub Pages), docker-ci.yml (Docker build)
├── .husky/ # Pre-commit: lint-staged → ESLint
├── Dockerfile # Multi-stage: Node 22 build → Nginx Alpine serve
├── docker-compose.yml # Docker Compose config
├── entrypoint.sh # Runtime env var placeholder replacement
├── nginx.conf # Nginx config (port 3000, SPA fallback)
├── next.config.js # Static export, reactStrictMode, reactCompiler
├── jsconfig.json # Path aliases: @/* → ./*
├── eslint.config.mjs # ESLint flat config: next/core-web-vitals
├── postcss.config.mjs # Tailwind v4 PostCSS plugin
├── components.json # shadcn/ui config (new-york, JSX, RSC)
└── package.json # Node >= 20.9.0, lint-staged, husky
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Fund valuation logic | `app/api/fund.js` | JSONP to 天天基金, script injection to 腾讯财经 |
| Main UI orchestration | `app/page.jsx` | Monolithic — all useState, business logic, rendering |
| Fund card display | `app/components/FundCard.jsx` | Individual fund card with holdings |
| Desktop table | `app/components/PcFundTable.jsx` | PC-specific table layout |
| Mobile table | `app/components/MobileFundTable.jsx` | Mobile-specific layout, swipe actions |
| Holding calculations | `app/page.jsx` (getHoldingProfit) | Profit/loss computation |
| Cloud sync | `app/lib/supabase.js` + page.jsx sync functions | Supabase auth + data sync |
| Trading/DCA | `app/components/TradeModal.jsx`, `DcaModal.jsx` | Buy/sell, dollar-cost averaging |
| Fund fuzzy search | `app/hooks/useFundFuzzyMatcher.js` | Fuse.js based name/code matching |
| OCR import | `app/page.jsx` (processFiles) | Tesseract.js + LLM parsing |
| Valuation intraday chart | `app/lib/valuationTimeseries.js` | localStorage time-series |
| Trading calendar | `app/lib/tradingCalendar.js` | Chinese holiday detection via CDN |
| Request caching | `app/lib/cacheRequest.js` | In-memory cache with dedup |
| UI primitives | `components/ui/` | shadcn/ui — accordion, dialog, drawer, select, etc. |
| Global styles | `app/globals.css` | CSS variables, glassmorphism, responsive |
| CI/CD | `.github/workflows/nextjs.yml` | Build + deploy to GitHub Pages |
| Docker | `Dockerfile`, `docker-compose.yml` | Multi-stage build with runtime env injection |
| localStorage schema | `doc/localStorage 数据结构.md` | Full documentation of stored data shapes |
| Supabase schema | `doc/supabase.sql` | Database tables for cloud sync |
## CONVENTIONS
- **JavaScript only** — no TypeScript. `tsx: false` in shadcn config.
- **No src/ directory** — app/, components/, lib/ at root level.
- **Static export** — `output: 'export'` in next.config.js. No server-side runtime.
- **JSONP + script injection** — all external API calls bypass CORS via `<script>` tags, not fetch().
- **localStorage-first** — all user data stored locally; Supabase sync is optional/secondary.
- **Monolithic page.jsx** — entire app state and logic in one file (~3000+ lines). No state management library.
- **Dual responsive layouts** — `PcFundTable` and `MobileFundTable` switch at 640px breakpoint.
- **shadcn/ui conventions** — new-york style, CSS variables enabled, Lucide icons, path aliases (`@/components`, `@/lib/utils`).
- **Linting only** — ESLint + lint-staged on pre-commit. No Prettier, no auto-formatting.
- **React Compiler** — `reactCompiler: true` in next.config.js (experimental auto-memoization).
## ANTI-PATTERNS (THIS PROJECT)
- **No test infrastructure** — zero test files, no test framework, no test scripts.
- **Dual ESLint configs** — both `.eslintrc.json` (legacy) and `eslint.config.mjs` (flat) exist. Flat config is active.
- **`--legacy-peer-deps`** — Dockerfile uses this flag, indicating peer dependency conflicts.
- **Console statements** — 20 console.error/warn/log across codebase (mostly error logging in page.jsx).
- **2 eslint-disable comments** — `no-await-in-loop` in MobileFundTable, `react-hooks/exhaustive-deps` in HoldingEditModal.
- **Hardcoded API keys** — `app/api/fund.js` lines 911-914 contain plaintext API keys for LLM service.
- **Empty catch blocks** — several `catch (e) {}` blocks that swallow errors silently.
## UNIQUE STYLES
- **Glassmorphism design** — frosted glass effect via `backdrop-filter: blur()` + semi-transparent backgrounds.
- **CSS variable system** — 50+ CSS custom properties for colors, spacing, transitions in globals.css.
- **Runtime env injection** — Docker entrypoint replaces `__PLACEHOLDER__` strings in static JS/HTML at container start.
- **JSONP everywhere** — financial APIs (天天基金, 腾讯财经) accessed via script tag injection, not fetch().
- **OCR + LLM import** — Tesseract.js OCR → LLM text parsing → fund code extraction.
- **Multiple IDE configs** — .cursor/, .qoder/, .trae/ directories suggest active AI-assisted development.
## COMMANDS
```bash
# Development
npm run dev # Start dev server (localhost:3000)
npm run build # Static export to out/
npm run lint # ESLint check
npm run lint:fix # ESLint auto-fix
# Docker
docker build -t real-time-fund .
docker run -d -p 3000:3000 --env-file .env real-time-fund
docker compose up -d
# Environment
cp env.example .env.local # Copy template, fill NEXT_PUBLIC_* values
```
## NOTES
- **Fund code format**: 6-digit numeric codes (e.g., 110022). Stored in localStorage key `localFunds`.
- **Data sources**: 天天基金 (valuation JSONP), 东方财富 (holdings HTML parsing), 腾讯财经 (stock quotes script injection).
- **Deployment**: GitHub Actions auto-deploys main → GitHub Pages. Also supports Vercel, Cloudflare Pages, Docker.
- **Node requirement**: >= 20.9.0 (enforced in package.json engines).
- **License**: AGPL-3.0 — derivative works must be open-sourced under same license.
- **Chinese UI** — all user-facing text is Chinese (zh-CN). README is bilingual (Chinese primary).

View File

@@ -1,6 +1,6 @@
# 实时基金估值 (Real-time Fund Valuation)
一个基于 Next.js 开发的纯前端基金估值与重仓股实时追踪工具。采用玻璃拟态设计Glassmorphism支持移动端适配。
一个基于 Next.js 开发的基金估值与重仓股实时追踪工具。采用玻璃拟态设计Glassmorphism支持移动端适配。
预览地址:
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
@@ -20,9 +20,18 @@
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
- **重仓追踪**:自动获取基金前 10 大重仓股票,并实时追踪重仓股的盘中涨跌情况。支持收起/展开展示。
- **纯前端运行**:采用 JSONP 方案直连东方财富、腾讯财经等公开接口,彻底解决跨域问题,支持在 GitHub Pages 等静态环境直接部署。
- **本地持久化**:使用 `localStorage` 存储已添加的基金列表及配置信息,刷新不丢失。
- **本地持久化**:使用 `localStorage` 存储已添加的基金列表、持仓、交易记录、定投计划及配置信息,刷新不丢失。
- **响应式设计**:完美适配 PC 与移动端。针对移动端优化了文字展示、间距及交互体验。
- **自选功能**:支持将基金添加至自选列表,通过 Tab 切换展示全部基金或仅自选基金。自选状态支持持久化及同步清理。
- **自选功能**:支持将基金添加至"自选"列表,通过 Tab 切换展示全部基金或仅自选基金。自选状态支持持久化及同步清理。
- **分组管理**:支持创建多个基金分组,方便按用途或类别管理基金。
- **持仓管理**:记录每只基金的持有份额和成本价,自动计算持仓收益和累计收益。
- **交易记录**:支持买入/卖出操作,记录交易历史,支持查看单个基金的交易明细。
- **定投计划**:支持设置自动定投计划,可按日/周/月等周期自动生成买入交易。
- **云端同步**:通过 Supabase 云端备份数据,支持多设备间数据同步与冲突处理。
- **自定义排序**:支持多种排序规则(估值涨跌幅、持仓收益、持有金额等),可自由组合和启用/禁用规则。
- **拖拽排序**:在默认排序模式下可通过拖拽调整基金顺序。
- **明暗主题**:支持亮色/暗色主题切换,一键换肤。
- **导入/导出**:支持将配置导出为 JSON 文件备份,或从文件导入恢复。
- **可自定义频率**支持设置自动刷新间隔5秒 - 300秒并提供手动刷新按钮。
## 🛠 技术栈

38
app/api/AGENTS.md Normal file
View 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

View File

@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/**
* 获取基金「关联板块/跟踪标的」信息(走本地 API并做 1 天缓存)
* 接口:/api/related-sectors?code=xxxxxx
* 返回:{ code: string, relatedSectors: string }
*/
export const fetchRelatedSectors = async (code, { cacheTime = ONE_DAY_MS } = {}) => {
if (!code) return '';
const normalized = String(code).trim();
if (!normalized) return '';
const url = `/api/related-sectors?code=${encodeURIComponent(normalized)}`;
const cacheKey = `relatedSectors:${normalized}`;
try {
const data = await cachedRequest(async () => {
const res = await fetch(url);
if (!res.ok) return null;
return await res.json();
}, cacheKey, { cacheTime });
const relatedSectors = data?.relatedSectors;
return relatedSectors ? String(relatedSectors).trim() : '';
} catch (e) {
return '';
}
};
export const loadScript = (url) => {
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
@@ -126,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;
@@ -287,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);
@@ -341,8 +405,12 @@ export const fetchFundData = async (c) => {
let name = '';
let weight = '';
if (idxCode >= 0 && tds[idxCode]) {
const m = tds[idxCode].match(/(\d{6})/);
code = m ? m[1] : tds[idxCode];
const raw = String(tds[idxCode] || '').trim();
const mA = raw.match(/(\d{6})/);
const mHK = raw.match(/(\d{5})/);
// 海外股票常见为英文代码(如 AAPL / usAAPL / TSLA.US / 0700.HK
const mAlpha = raw.match(/\b([A-Za-z]{1,10})\b/);
code = mA ? mA[1] : (mHK ? mHK[1] : (mAlpha ? mAlpha[1].toUpperCase() : raw));
} else {
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
if (codeIdx >= 0) code = tds[codeIdx];
@@ -365,20 +433,67 @@ export const fetchFundData = async (c) => {
}
}
holdings = holdings.slice(0, 10);
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
const normalizeTencentCode = (input) => {
const raw = String(input || '').trim();
if (!raw) return null;
// already normalized tencent styles (normalize prefix casing)
const mPref = raw.match(/^(us|hk|sh|sz|bj)(.+)$/i);
if (mPref) {
const p = mPref[1].toLowerCase();
const rest = String(mPref[2] || '').trim();
// usAAPL / usIXIC: rest use upper; hk00700 keep digits
return `${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
}
const mSPref = raw.match(/^s_(sh|sz|bj|hk)(.+)$/i);
if (mSPref) {
const p = mSPref[1].toLowerCase();
const rest = String(mSPref[2] || '').trim();
return `s_${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
}
// A股/北证
if (/^\d{6}$/.test(raw)) {
const pfx =
raw.startsWith('6') || raw.startsWith('9')
? 'sh'
: raw.startsWith('4') || raw.startsWith('8')
? 'bj'
: 'sz';
return `s_${pfx}${raw}`;
}
// 港股(数字)
if (/^\d{5}$/.test(raw)) return `s_hk${raw}`;
// 形如 0700.HK / 00001.HK
const mHkDot = raw.match(/^(\d{4,5})\.(?:HK)$/i);
if (mHkDot) return `s_hk${mHkDot[1].padStart(5, '0')}`;
// 形如 AAPL / TSLA.US / AAPL.O / BRK.B腾讯接口对“.”支持不稳定,优先取主代码)
const mUsDot = raw.match(/^([A-Za-z]{1,10})(?:\.[A-Za-z]{1,6})$/);
if (mUsDot) return `us${mUsDot[1].toUpperCase()}`;
if (/^[A-Za-z]{1,10}$/.test(raw)) return `us${raw.toUpperCase()}`;
return null;
};
const getTencentVarName = (tencentCode) => {
const cd = String(tencentCode || '').trim();
if (!cd) return '';
// s_* uses v_s_*
if (/^s_/i.test(cd)) return `v_${cd}`;
// us/hk/sh/sz/bj uses v_{code}
return `v_${cd}`;
};
const needQuotes = holdings
.map((h) => ({
h,
tencentCode: normalizeTencentCode(h.code),
}))
.filter((x) => Boolean(x.tencentCode));
if (needQuotes.length) {
try {
const tencentCodes = needQuotes.map(h => {
const cd = String(h.code || '');
if (/^\d{6}$/.test(cd)) {
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
return `s_${pfx}${cd}`;
}
if (/^\d{5}$/.test(cd)) {
return `s_hk${cd}`;
}
return null;
}).filter(Boolean).join(',');
const tencentCodes = needQuotes.map((x) => x.tencentCode).join(',');
if (!tencentCodes) {
resolveH(holdings);
return;
@@ -388,22 +503,15 @@ export const fetchFundData = async (c) => {
const scriptQuote = document.createElement('script');
scriptQuote.src = quoteUrl;
scriptQuote.onload = () => {
needQuotes.forEach(h => {
const cd = String(h.code || '');
let varName = '';
if (/^\d{6}$/.test(cd)) {
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
varName = `v_s_${pfx}${cd}`;
} else if (/^\d{5}$/.test(cd)) {
varName = `v_s_hk${cd}`;
} else {
return;
}
const dataStr = window[varName];
needQuotes.forEach(({ h, tencentCode }) => {
const varName = getTencentVarName(tencentCode);
const dataStr = varName ? window[varName] : null;
if (dataStr) {
const parts = dataStr.split('~');
if (parts.length > 5) {
h.change = parseFloat(parts[5]);
const isUS = /^us/i.test(String(tencentCode || ''));
const idx = isUS ? 32 : 5;
if (parts.length > idx) {
h.change = parseFloat(parts[idx]);
}
}
});
@@ -433,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({
@@ -676,7 +785,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
};
};
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
return new Promise((resolve, reject) => {
if (typeof document === 'undefined' || !document.body) {
reject(new Error('无浏览器环境'));
@@ -836,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
View 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

View File

@@ -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">

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v18';
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v20';
export default function Announcement() {
const [isVisible, setIsVisible] = useState(false);
@@ -75,16 +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.7 更新内容</p>
<p>1. 业绩走势增加对比线</p>
<p>2. 修复排序存储别名问题</p>
<p>3. PC端斑马纹 hover 样式问题</p>
<p>4. 修复大盘指数刷新及用户数据同步问题</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>

View File

@@ -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
}}

View File

@@ -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

View File

@@ -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)}%`;

View File

@@ -56,12 +56,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);
@@ -115,9 +118,10 @@ export default function GroupSummary({
if (profit) {
hasHolding = true;
totalAsset += profit.amount;
totalAsset += Math.round(profit.amount * 100) / 100;
if (profit.profitToday != null) {
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
totalProfitToday += profit.profitToday;
hasAnyTodayData = true;
}
if (profit.profitTotal !== null) {
@@ -129,11 +133,14 @@ export default function GroupSummary({
}
});
// 将当日收益总和四舍五入到两位小数,和卡片展示保持一致
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
return {
totalAsset,
totalProfitToday,
totalProfitToday: roundedTotalProfitToday,
totalHoldingReturn,
hasHolding,
returnRate,
@@ -161,12 +168,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"
@@ -179,7 +196,9 @@ export default function GroupSummary({
>
<span
className="sticky-toggle-btn"
onClick={() => setIsSticky(!isSticky)}
onClick={() => {
onToggleSticky?.(!isSticky);
}}
style={{
position: 'absolute',
top: 4,

View File

@@ -7,7 +7,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory, pendingCount }) {
const handleOpenChange = (open) => {
if (!open) {
onClose?.();
@@ -39,11 +39,26 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
display: 'flex',
alignItems: 'center',
gap: 4,
position: 'relative',
}}
title="查看交易记录"
>
<span>📜</span>
<span>交易记录</span>
{pendingCount > 0 && (
<span
style={{
position: 'absolute',
top: -4,
right: -4,
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: '#ef4444',
border: '2px solid var(--background)',
}}
/>
)}
</button>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>

View File

@@ -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';
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
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 }) {
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 }) {
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 }) {
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 }) {
}
};
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 }) {
finalCost = finalShare > 0 ? principal / finalShare : 0;
}
const trimmedDate = firstPurchaseDate ? firstPurchaseDate.trim() : '';
onSave({
share: finalShare,
cost: finalCost
cost: finalCost,
...(trimmedDate && { firstPurchaseDate: trimmedDate })
});
onClose();
};
@@ -124,6 +183,23 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>设置持仓</span>
{typeof onOpenTrade === 'function' && (
<button
type="button"
onClick={onOpenTrade}
className="button secondary"
style={{
height: 28,
padding: '0 10px',
borderRadius: 999,
fontSize: 12,
background: 'rgba(255,255,255,0.06)',
color: 'var(--primary)',
}}
>
今日买入去加仓
</button>
)}
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
@@ -238,6 +314,49 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
</>
)}
<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

View File

@@ -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>
);

View File

@@ -28,22 +28,27 @@ import FitText from './FitText';
import MobileFundCardDrawer from './MobileFundCardDrawer';
import MobileSettingModal from './MobileSettingModal';
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
import { fetchRelatedSectors } from '@/app/api/fund';
const MOBILE_NON_FROZEN_COLUMN_IDS = [
'relatedSector',
'yesterdayChangePercent',
'estimateChangePercent',
'totalChangePercent',
'holdingDays',
'todayProfit',
'holdingProfit',
'latestNav',
'estimateNav',
];
const MOBILE_COLUMN_HEADERS = {
relatedSector: '关联板块',
latestNav: '最新净值',
estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
holdingDays: '持有天数',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
@@ -233,6 +238,9 @@ export default function MobileFundTable({
const defaultVisibility = (() => {
const o = {};
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
// 新增列:默认隐藏(用户可在表格设置中开启)
o.relatedSector = false;
o.holdingDays = false;
return o;
})();
@@ -245,7 +253,12 @@ export default function MobileFundTable({
})();
const mobileColumnVisibility = (() => {
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
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;
})();
@@ -340,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 = () => {
@@ -422,15 +440,60 @@ export default function MobileFundTable({
const LAST_COLUMN_EXTRA = 12;
const FALLBACK_WIDTHS = {
fundName: 140,
relatedSector: 120,
latestNav: 64,
estimateNav: 64,
yesterdayChangePercent: 72,
estimateChangePercent: 80,
totalChangePercent: 80,
holdingDays: 64,
todayProfit: 80,
holdingProfit: 80,
};
const relatedSectorEnabled = mobileColumnVisibility?.relatedSector !== false;
const relatedSectorCacheRef = useRef(new Map());
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
const runWithConcurrency = async (items, limit, worker) => {
const queue = [...items];
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
while (queue.length) {
const item = queue.shift();
if (item == null) continue;
await worker(item);
}
});
await Promise.all(runners);
};
useEffect(() => {
if (!relatedSectorEnabled) return;
if (!Array.isArray(data) || data.length === 0) return;
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
if (missing.length === 0) return;
let cancelled = false;
(async () => {
await runWithConcurrency(missing, 4, async (code) => {
const value = await fetchRelatedSector(code);
relatedSectorCacheRef.current.set(code, value);
if (cancelled) return;
setRelatedSectorByCode((prev) => {
if (prev[code] === value) return prev;
return { ...prev, [code]: value };
});
});
})();
return () => { cancelled = true; };
}, [relatedSectorEnabled, data]);
const columnWidthMap = useMemo(() => {
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
const nonNameCount = visibleNonNameIds.length;
@@ -456,6 +519,8 @@ export default function MobileFundTable({
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
allVisible.relatedSector = false;
allVisible.holdingDays = false;
setMobileColumnVisibility(allVisible);
};
const handleToggleMobileColumnVisibility = (columnId, visible) => {
@@ -654,6 +719,22 @@ export default function MobileFundTable({
),
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
},
{
id: 'relatedSector',
header: '关联板块',
cell: (info) => {
const original = info.row.original || {};
const code = original.code;
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
const display = value || '—';
return (
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '12px' }}>
{display}
</div>
);
},
meta: { align: 'left', cellClassName: 'related-sector-cell', width: columnWidthMap.relatedSector ?? 120 },
},
{
accessorKey: 'latestNav',
header: '最新净值',
@@ -774,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: '当日收益',
@@ -784,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 }}>
@@ -792,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}
@@ -834,7 +931,7 @@ export default function MobileFundTable({
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
},
],
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy]
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy, relatedSectorByCode]
);
const table = useReactTable({
@@ -945,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';
};

View File

@@ -187,6 +187,11 @@ export default function MobileSettingModal({
估值涨幅与持有收益的汇总
</span>
)}
{item.id === 'relatedSector' && (
<span className="muted" style={{ fontSize: '12px' }}>
fund.cc.cd 地址支持
</span>
)}
</div>
{onToggleColumnVisibility && (
<Switch

View File

@@ -34,25 +34,31 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
import { fetchRelatedSectors } from '@/app/api/fund';
const NON_FROZEN_COLUMN_IDS = [
'relatedSector',
'yesterdayChangePercent',
'estimateChangePercent',
'totalChangePercent',
'holdingAmount',
'holdingDays',
'todayProfit',
'holdingProfit',
'latestNav',
'estimateNav',
];
const COLUMN_HEADERS = {
relatedSector: '关联板块',
latestNav: '最新净值',
estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
holdingAmount: '持仓金额',
holdingDays: '持有天数',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
@@ -282,10 +288,18 @@ export default function PcFundTable({
})();
const columnVisibility = (() => {
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
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; });
return allVisible;
// 新增列:默认隐藏(用户可在表格设置中开启)
allVisible.relatedSector = false;
allVisible.holdingDays = false;
return allVisible;
})();
const columnSizing = (() => {
const s = currentGroupPc?.pcTableColumns;
@@ -356,6 +370,8 @@ export default function PcFundTable({
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
allVisible.relatedSector = false;
allVisible.holdingDays = false;
setColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
@@ -443,6 +459,51 @@ export default function PcFundTable({
};
}, [stickyTop]);
const relatedSectorEnabled = columnVisibility?.relatedSector !== false;
const relatedSectorCacheRef = useRef(new Map());
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
const runWithConcurrency = async (items, limit, worker) => {
const queue = [...items];
const results = [];
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
while (queue.length) {
const item = queue.shift();
if (item == null) continue;
results.push(await worker(item));
}
});
await Promise.all(runners);
return results;
};
useEffect(() => {
if (!relatedSectorEnabled) return;
if (!Array.isArray(data) || data.length === 0) return;
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
if (missing.length === 0) return;
let cancelled = false;
(async () => {
await runWithConcurrency(missing, 4, async (code) => {
const value = await fetchRelatedSector(code);
relatedSectorCacheRef.current.set(code, value);
if (cancelled) return;
setRelatedSectorByCode((prev) => {
if (prev[code] === value) return prev;
return { ...prev, [code]: value };
});
});
})();
return () => { cancelled = true; };
}, [relatedSectorEnabled, data]);
useEffect(() => {
const tableEl = tableContainerRef.current;
const portalEl = portalHeaderRef.current;
@@ -563,6 +624,27 @@ export default function PcFundTable({
cellClassName: 'name-cell',
},
},
{
id: 'relatedSector',
header: '关联板块',
size: 180,
minSize: 120,
cell: (info) => {
const original = info.row.original || {};
const code = original.code;
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
const display = value || '—';
return (
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '14px' }}>
{display}
</div>
);
},
meta: {
align: 'right',
cellClassName: 'related-sector-cell',
},
},
{
accessorKey: 'latestNav',
header: '最新净值',
@@ -772,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: '当日收益',
@@ -790,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}
@@ -895,7 +999,7 @@ export default function PcFundTable({
},
},
],
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked],
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked, relatedSectorByCode],
);
const table = useReactTable({
@@ -970,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}
@@ -1001,8 +1108,9 @@ export default function PcFundTable({
const totalHeaderWidth = headerGroup?.headers?.reduce((acc, h) => acc + h.column.getSize(), 0) ?? 0;
return (
<div className="pc-fund-table" ref={tableContainerRef}>
<style>{`
<>
<div className="pc-fund-table" ref={tableContainerRef}>
<style>{`
.table-row-scroll {
--row-bg: var(--bg);
background-color: var(--row-bg) !important;
@@ -1099,87 +1207,127 @@ export default function PcFundTable({
opacity: 0;
}
`}</style>
{/* 表头 */}
{renderTableHeader(false)}
{/* 表头 */}
{renderTableHeader(false)}
{/* 表体 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={data.map((item) => item.code)}
strategy={verticalListSortingStrategy}
{/* 表体 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row, index) => (
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
<div
className={`table-row table-row-scroll ${index % 2 === 1 ? 'row-even' : ''}`}
>
{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)
? 'text-right'
: 'text-center';
const cellClassName =
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
const style = getCommonPinningStyles(cell.column, false);
const isPinned = cell.column.getIsPinned();
return (
<div
key={cell.id}
className={`table-cell ${align} ${cellClassName} ${isPinned ? 'pinned-cell' : ''}`}
style={style}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
</div>
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
<SortableContext
items={data.map((item) => item.code)}
strategy={verticalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row, index) => (
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
<div
className={`table-row table-row-scroll ${index % 2 === 1 ? 'row-even' : ''}`}
>
{row.getVisibleCells().map((cell) => {
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
const isNameColumn = columnId === 'fundName';
const align = isNameColumn
? ''
: NON_FROZEN_COLUMN_IDS.includes(columnId)
? 'text-right'
: 'text-center';
const cellClassName =
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
const style = getCommonPinningStyles(cell.column, false);
const isPinned = cell.column.getIsPinned();
return (
<div
key={cell.id}
className={`table-cell ${align} ${cellClassName} ${isPinned ? 'pinned-cell' : ''}`}
style={style}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
</div>
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
{table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
{table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div>
</div>
)}
{resetConfirmOpen && (
<ConfirmModal
title="重置列宽"
message="是否重置表格列宽为默认值?"
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
confirmVariant="primary"
onConfirm={handleResetSizing}
onCancel={() => setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
{resetConfirmOpen && (
<ConfirmModal
title="重置列宽"
message="是否重置表格列宽为默认值?"
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
confirmVariant="primary"
onConfirm={handleResetSizing}
onCancel={() => setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
{showPortalHeader && ReactDOM.createPortal(
<div
className="pc-fund-table pc-fund-table-portal-header"
ref={portalHeaderRef}
style={{
position: 'fixed',
top: effectiveStickyTop,
left: portalHorizontal.left,
right: portalHorizontal.right,
zIndex: 10,
overflowX: 'auto',
scrollbarWidth: 'none',
}}
>
<div
className="table-header-row table-header-row-scroll"
style={{ minWidth: totalHeaderWidth, width: 'fit-content' }}
>
{headerGroup?.headers.map((header) => {
const style = getCommonPinningStyles(header.column, true);
const isNameColumn =
header.column.id === 'fundName' ||
header.column.columnDef?.accessorKey === 'fundName';
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}
>
<div style={{ paddingRight: isRightAligned ? '20px' : '0' }}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
</div>
);
})}
</div>
</div>,
document.body
)}
</div>
{!!(cardDialogRow && getFundCardProps) && (
<FundDetailDialog blockDialogClose={blockDialogClose} cardDialogRow={cardDialogRow} getFundCardProps={getFundCardProps} setCardDialogRow={setCardDialogRow} />
)}
<PcTableSettingModal
open={settingModalOpen}
@@ -1196,74 +1344,36 @@ export default function PcFundTable({
showFullFundName={showFullFundName}
onToggleShowFullFundName={handleToggleShowFullFundName}
/>
<Dialog
open={!!(cardDialogRow && getFundCardProps)}
onOpenChange={(open) => {
if (!open && !blockDialogClose) setCardDialogRow(null);
}}
>
<DialogContent
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-6 pb-4 pt-6 text-left border-b border-[var(--border)]">
<DialogTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DialogTitle>
</DialogHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-y-styled"
>
{cardDialogRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
) : null}
</div>
</DialogContent>
</Dialog>
</>
{showPortalHeader && ReactDOM.createPortal(
<div
className="pc-fund-table pc-fund-table-portal-header"
ref={portalHeaderRef}
style={{
position: 'fixed',
top: effectiveStickyTop,
left: portalHorizontal.left,
right: portalHorizontal.right,
zIndex: 10,
overflowX: 'auto',
scrollbarWidth: 'none',
}}
>
<div
className="table-header-row table-header-row-scroll"
style={{ minWidth: totalHeaderWidth, width: 'fit-content' }}
>
{headerGroup?.headers.map((header) => {
const style = getCommonPinningStyles(header.column, true);
const isNameColumn =
header.column.id === 'fundName' ||
header.column.columnDef?.accessorKey === 'fundName';
const align = isNameColumn ? '' : '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>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}
function FundDetailDialog({ blockDialogClose, cardDialogRow, getFundCardProps, setCardDialogRow}) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !blockDialogClose) setCardDialogRow(null);
}}
>
<DialogContent
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-6 pb-4 pt-6 text-left border-b border-[var(--border)]">
<DialogTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DialogTitle>
</DialogHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-y-styled"
>
{cardDialogRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
) : null}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -213,6 +213,11 @@ export default function PcTableSettingModal({
估值涨幅与持有收益的汇总
</span>
)}
{item.id === 'relatedSector' && (
<span className="muted" style={{ fontSize: '12px' }}>
fund.cc.cd 地址支持
</span>
)}
</div>
{onToggleColumnVisibility && (
<button

View File

@@ -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}
>
保存并关闭

View File

@@ -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",

View File

@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { isNumber } from 'lodash';
import { fetchSmartFundNetValue } from '../api/fund';
import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
import { DatePicker, NumericInput } from './Common';
import ConfirmModal from './ConfirmModal';
import { CloseIcon } from './Icons';
@@ -16,6 +16,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import PendingTradesModal from './PendingTradesModal';
import { Spinner } from '@/components/ui/spinner';
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -39,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
const [share, setShare] = useState('');
const [amount, setAmount] = useState('');
const [feeRate, setFeeRate] = useState('0');
const [minBuyAmount, setMinBuyAmount] = useState(0);
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
const [buyMetaError, setBuyMetaError] = useState(null);
const [date, setDate] = useState(() => {
return formatDate();
});
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
const [calcShare, setCalcShare] = useState(null);
const parseNumberish = (input) => {
if (input === null || typeof input === 'undefined') return null;
if (typeof input === 'number') return Number.isFinite(input) ? input : null;
const cleaned = String(input).replace(/[^\d.]/g, '');
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : null;
};
useEffect(() => {
if (!isBuy || !fund?.code) return;
let cancelled = false;
setLoadingBuyMeta(true);
setBuyMetaError(null);
fetchFundPingzhongdata(fund.code)
.then((pz) => {
if (cancelled) return;
const rate = parseNumberish(pz?.fund_Rate);
const minsg = parseNumberish(pz?.fund_minsg);
if (Number.isFinite(minsg)) {
setMinBuyAmount(minsg);
} else {
setMinBuyAmount(0);
}
if (Number.isFinite(rate)) {
setFeeRate((prev) => {
const prevNum = parseNumberish(prev);
const shouldOverride = prev === '' || prev === '0' || prevNum === 0 || prevNum === null;
return shouldOverride ? rate.toFixed(2) : prev;
});
}
})
.catch((e) => {
if (cancelled) return;
setBuyMetaError(e?.message || '买入信息加载失败');
setMinBuyAmount(0);
})
.finally(() => {
if (cancelled) return;
setLoadingBuyMeta(false);
});
return () => {
cancelled = true;
};
}, [isBuy, fund?.code]);
const currentPendingTrades = useMemo(() => {
return pendingTrades.filter(t => t.fundCode === fund?.code);
}, [pendingTrades, fund]);
@@ -148,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
};
const isValid = isBuy
? (!!amount && !!feeRate && !!date && calcShare !== null)
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
: (!!share && !!date);
const handleSetShareFraction = (fraction) => {
@@ -372,72 +426,112 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<form onSubmit={handleSubmit}>
{isBuy ? (
<>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={amount}
onChange={setAmount}
step={100}
min={0}
placeholder="请输入加仓金额"
/>
</div>
</div>
<div style={{ position: 'relative' }}>
<div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div
style={{
border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
? '1px solid var(--danger)'
: '1px solid var(--border)',
borderRadius: 12
}}
>
<NumericInput
value={amount}
onChange={setAmount}
step={100}
min={Number(minBuyAmount) || 0}
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
/>
</div>
{(Number(minBuyAmount) || 0) > 0 && (
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
最小加仓金额¥{Number(minBuyAmount)}
</div>
)}
</div>
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={feeRate}
onChange={setFeeRate}
step={0.01}
min={0}
placeholder="0.12"
/>
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={feeRate}
onChange={setFeeRate}
step={0.01}
min={0}
placeholder="0.12"
/>
</div>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段
</label>
<div className="trade-time-slot row" style={{ gap: 8 }}>
<button
type="button"
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(false)}
>
15:00
</button>
<button
type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{buyMetaError ? (
<span className="muted" style={{ color: 'var(--danger)' }}>{buyMetaError}</span>
) : null}
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
</div>
)}
</div>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段
</label>
<div className="trade-time-slot row" style={{ gap: 8 }}>
<button
type="button"
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(false)}
{loadingBuyMeta && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: 12,
borderRadius: 12,
background: 'rgba(0,0,0,0.25)',
backdropFilter: 'blur(2px)',
WebkitBackdropFilter: 'blur(2px)',
}}
>
15:00
</button>
<button
type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
<Spinner className="size-5" />
<span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
</div>
)}
</div>
@@ -564,8 +658,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<button
type="submit"
className="button"
disabled={!isValid || loadingPrice}
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
>
确定
</button>

View File

@@ -1,33 +1,26 @@
'use client';
import { motion } from 'framer-motion';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { UpdateIcon } from './Icons';
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
export default function UpdatePromptModal({ updateContent, open, onClose, onRefresh }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="更新提示"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 10002 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
<Dialog open={open} onOpenChange={(v) => !v && onClose?.()}>
<DialogContent
className="glass card"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
showCloseButton={false}
role="dialog"
aria-modal="true"
aria-label="更新提示"
>
<div className="title" style={{ marginBottom: 12 }}>
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
<span>更新提示</span>
</div>
<DialogHeader>
<DialogTitle style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
<span>更新提示</span>
</DialogTitle>
</DialogHeader>
<div style={{ marginBottom: 24 }}>
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
检测到新版本是否刷新浏览器以更新
@@ -36,7 +29,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
</p>
{updateContent && (
<div style={{
background: 'rgba(0,0,0,0.2)',
background: 'var(--card)',
padding: '12px',
borderRadius: '8px',
fontSize: '13px',
@@ -44,13 +37,14 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
maxHeight: '200px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
border: '1px solid rgba(255,255,255,0.1)'
border: '1px solid var(--border)'
}}>
{updateContent}
</div>
)}
</div>
<div className="row" style={{ gap: 12 }}>
<div className="flex-row" style={{ gap: 12, display: 'flex' }}>
<button
className="button secondary"
onClick={onClose}
@@ -66,7 +60,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
刷新浏览器
</button>
</div>
</motion.div>
</motion.div>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -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);

View File

@@ -15,9 +15,11 @@ function lockBodyScroll() {
originalBodyPosition = document.body.style.position || "";
originalBodyTop = document.body.style.top || "";
document.body.style.position = "fixed";
document.body.style.top = `-${lockedScrollY}px`;
document.body.style.width = "100%";
requestAnimationFrame(() => {
document.body.style.top = `-${lockedScrollY}px`;
document.body.style.width = "100%";
document.body.style.position = "fixed";
});
}
}
@@ -28,12 +30,15 @@ function unlockBodyScroll() {
// 只有全部弹框都关闭时才恢复滚动位置
if (scrollLockCount === 0) {
const scrollY = lockedScrollY;
document.body.style.position = originalBodyPosition;
document.body.style.top = originalBodyTop;
document.body.style.width = "";
// 恢复到锁定前的滚动位置,而不是跳到顶部
window.scrollTo(0, lockedScrollY);
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}
}
@@ -57,4 +62,4 @@ export function useBodyScrollLock(open) {
}
};
}, [open]);
}
}

28
app/lib/AGENTS.md Normal file
View 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

View File

@@ -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 })
},

View File

@@ -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,
@@ -883,7 +936,21 @@ export default function HomePage() {
const handleClearConfirm = () => {
if (clearConfirm?.fund) {
handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null });
const code = clearConfirm.fund.code;
handleSaveHolding(code, { share: null, cost: null });
setTransactions(prev => {
const next = { ...(prev || {}) };
delete next[code];
storageHelper.setItem('transactions', JSON.stringify(next));
return next;
});
setPendingTrades(prev => {
const next = prev.filter(trade => trade.fundCode !== code);
storageHelper.setItem('pendingTrades', JSON.stringify(next));
return next;
});
}
setClearConfirm(null);
};
@@ -1040,6 +1107,11 @@ export default function HomePage() {
setPendingTrades(next);
storageHelper.setItem('pendingTrades', JSON.stringify(next));
// 如果该基金没有持仓数据,初始化持仓金额为 0
if (!holdings[fund.code]) {
handleSaveHolding(fund.code, { share: 0, cost: 0 });
}
setTradeModal({ open: false, fund: null, type: 'buy' });
showToast('净值暂未更新,已加入待处理队列', 'info');
return;
@@ -2343,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;
@@ -2751,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);
@@ -3165,9 +3282,10 @@ export default function HomePage() {
const fetchCloudConfig = async (userId, checkConflict = false) => {
if (!userId) return;
try {
// 一次查询同时拿到 meta 与 data方便两种模式复用
const { data: meta, error: metaError } = await supabase
.from('user_configs')
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
.select('id, data, updated_at')
.eq('user_id', userId)
.maybeSingle();
@@ -3181,44 +3299,19 @@ export default function HomePage() {
setCloudConfigModal({ open: true, userId, type: 'empty' });
return;
}
// 冲突检查模式:使用 meta.data 弹出冲突确认弹窗
if (checkConflict) {
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
return;
}
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
// 非冲突检查模式:直接复用上方查询到的 meta 数据,覆盖本地
if (meta.data && isPlainObject(meta.data) && Object.keys(meta.data).length > 0) {
await applyCloudConfig(meta.data, meta.updated_at);
return;
}
const { data, error } = await supabase
.from('user_configs')
.select('id, data, updated_at')
.eq('user_id', userId)
.maybeSingle();
if (error) throw error;
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
const localPayload = collectLocalPayload();
const localComparable = getComparablePayload(localPayload);
const cloudComparable = getComparablePayload(data.data);
if (localComparable !== cloudComparable) {
// 如果数据不一致
if (checkConflict) {
// 只有明确要求检查冲突时才提示(例如刚登录时)
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
return;
}
// 否则直接覆盖本地(例如已登录状态下的刷新)
await applyCloudConfig(data.data, data.updated_at);
return;
}
await applyCloudConfig(data.data, data.updated_at);
return;
}
setCloudConfigModal({ open: true, userId, type: 'empty' });
} catch (e) {
console.error('获取云端配置失败', e);
@@ -3490,7 +3583,6 @@ export default function HomePage() {
useEffect(() => {
const isAnyModalOpen =
settingsOpen ||
feedbackOpen ||
addResultOpen ||
addFundToGroupOpen ||
@@ -3526,7 +3618,6 @@ export default function HomePage() {
containerRef.current.style.overflow = '';
};
}, [
settingsOpen,
feedbackOpen,
addResultOpen,
addFundToGroupOpen,
@@ -3945,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 }}>
@@ -4075,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>
@@ -4127,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}
@@ -4178,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 + 右侧冻结操作列 */}
@@ -4381,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>
@@ -4513,6 +4652,7 @@ export default function HomePage() {
onClose={() => setActionModal({ open: false, fund: null })}
onAction={(type) => handleAction(type, actionModal.fund)}
hasHistory={!!transactions[actionModal.fund?.code]?.length}
pendingCount={pendingTrades.filter(t => t.fundCode === actionModal.fund?.code).length}
/>
)}
</AnimatePresence>
@@ -4621,6 +4761,12 @@ export default function HomePage() {
holding={holdings[holdingModal.fund?.code]}
onClose={() => setHoldingModal({ open: false, fund: null })}
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
onOpenTrade={() => {
const f = holdingModal.fund;
if (!f) return;
setHoldingModal({ open: false, fund: null });
setTradeModal({ open: true, fund: f, type: 'buy' });
}}
/>
)}
</AnimatePresence>
@@ -4723,19 +4869,18 @@ export default function HomePage() {
containerWidth={containerWidth}
setContainerWidth={setContainerWidth}
onResetContainerWidth={handleResetContainerWidth}
showMarketIndexPc={showMarketIndexPc}
showMarketIndexMobile={showMarketIndexMobile}
/>
)}
{/* 更新提示弹窗 */}
<AnimatePresence>
{updateModalOpen && (
<UpdatePromptModal
updateContent={updateContent}
onClose={() => setUpdateModalOpen(false)}
onRefresh={() => window.location.reload()}
/>
)}
</AnimatePresence>
<UpdatePromptModal
open={updateModalOpen}
updateContent={updateContent}
onClose={() => setUpdateModalOpen(false)}
onRefresh={() => window.location.reload()}
/>
<AnimatePresence>
{isScanning && (
@@ -4758,6 +4903,7 @@ export default function HomePage() {
setLoginSuccess('');
setLoginEmail('');
setLoginOtp('');
setLoginLoading(false);
}}
loginEmail={loginEmail}
setLoginEmail={setLoginEmail}
@@ -4768,6 +4914,7 @@ export default function HomePage() {
loginSuccess={loginSuccess}
handleSendOtp={handleSendOtp}
handleVerifyEmailOtp={handleVerifyEmailOtp}
handleGithubLogin={isSupabaseConfigured ? handleGithubLogin : undefined}
/>
)}
@@ -4778,6 +4925,8 @@ export default function HomePage() {
isMobile={isMobile}
rules={sortRules}
onChangeRules={setSortRules}
sortDisplayMode={sortDisplayMode}
onChangeSortDisplayMode={setSortDisplayMode}
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
/>

28
components/ui/AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# components/ui/ — shadcn/ui Primitives
## OVERVIEW
15 shadcn/ui components (new-york style, JSX). Low-level primitives — do NOT modify manually.
## WHERE TO LOOK
```
accordion.jsx button.jsx dialog.jsx drawer.jsx
field.jsx input-otp.jsx label.jsx progress.jsx
radio-group.jsx select.jsx separator.jsx sonner.jsx
spinner.jsx switch.jsx tabs.jsx
```
## CONVENTIONS
- **Add via CLI**: `npx shadcn@latest add <component>` — never copy-paste manually
- **Style**: new-york, CSS variables enabled, neutral base color
- **Icons**: lucide-react
- **Path aliases**: `@/components/ui/*`, `@/lib/utils` (cn helper)
- **forwardRef pattern** — all components use React.forwardRef
- **Styling**: tailwind-merge via `cn()` in `lib/utils.js`
## ANTI-PATTERNS (THIS DIRECTORY)
- **Do not edit** — manual changes will be overwritten by shadcn CLI updates
- **No custom components here** — app-specific components belong in `app/components/`

View File

@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
@@ -86,6 +85,8 @@ function DialogContent({
<DialogOverlay className={overlayClassName} style={overlayStyle} />
<DialogPrimitive.Content
data-slot="dialog-content"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn(
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"mobile-dialog-glass",

View File

@@ -0,0 +1,46 @@
"use client"
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
ref={ref}
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
))
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(
"group/radio aspect-square size-4 shrink-0 rounded-full border shadow-xs outline-none",
"border-[var(--border)] bg-[var(--input)] text-[var(--primary)]",
"transition-[color,box-shadow,border-color,background-color] duration-200 ease-out",
"hover:border-[var(--muted-foreground)]",
"data-[state=checked]:border-[var(--primary)] data-[state=checked]:bg-[var(--background)]",
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--destructive)] aria-invalid:ring-[3px] aria-invalid:ring-[var(--destructive)] aria-invalid:ring-opacity-20",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="size-2 fill-current text-[var(--primary)]" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
))
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

21
components/ui/spinner.jsx Normal file
View File

@@ -0,0 +1,21 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({
className,
...props
}) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn(
"size-4 animate-spin text-muted-foreground motion-reduce:animate-none",
className
)}
{...props} />
);
}
export { Spinner }

View File

@@ -12,9 +12,10 @@
### 1. funds
**类型**: `Array<Object>`
**默认值**: `[]`
**类型**: `Array<Object>`
**默认值**: `[]`
**说明**: 存储用户添加的所有基金信息
**云端同步**: 是
**数据结构**:
```javascript
@@ -43,9 +44,10 @@
### 2. favorites
**类型**: `Array<string>`
**默认值**: `[]`
**类型**: `Array<string>`
**默认值**: `[]`
**说明**: 存储用户标记为自选的基金代码列表
**云端同步**: 是
**数据结构**:
```javascript
@@ -65,9 +67,10 @@
### 3. groups
**类型**: `Array<Object>`
**默认值**: `[]`
**类型**: `Array<Object>`
**默认值**: `[]`
**说明**: 存储用户创建的基金分组信息
**云端同步**: 是
**数据结构**:
```javascript
@@ -89,9 +92,10 @@
### 4. collapsedCodes
**类型**: `Array<string>`
**默认值**: `[]`
**类型**: `Array<string>`
**默认值**: `[]`
**说明**: 存储用户收起的基金代码列表(用于折叠基金详情)
**云端同步**: 是
**数据结构**:
```javascript
@@ -110,9 +114,10 @@
### 5. collapsedTrends
**类型**: `Array<string>`
**默认值**: `[]`
**类型**: `Array<string>`
**默认值**: `[]`
**说明**: 存储用户收起的业绩走势图表的基金代码列表
**云端同步**: 是
**数据结构**:
```javascript
@@ -131,10 +136,11 @@
### 6. viewMode
**类型**: `string`
**默认值**: `'card'`
**可选值**: `'card'` | `'list'`
**类型**: `string`
**默认值**: `'card'`
**可选值**: `'card'` | `'list'`
**说明**: 存储用户选择的视图模式
**云端同步**: 否(仅通过 customSettings 同步)
**数据结构**:
```javascript
@@ -150,10 +156,11 @@
### 7. refreshMs
**类型**: `number` (字符串存储)
**默认值**: `30000` (30秒)
**最小值**: `5000` (5秒)
**类型**: `number` (字符串存储)
**默认值**: `30000` (30秒)
**最小值**: `5000` (5秒)
**说明**: 存储数据刷新间隔时间(毫秒)
**云端同步**: 是
**数据结构**:
```javascript
@@ -169,9 +176,10 @@
### 8. holdings
**类型**: `Object`
**默认值**: `{}`
**类型**: `Object`
**默认值**: `{}`
**说明**: 存储用户的持仓信息
**云端同步**: 是
**数据结构**:
```javascript
@@ -196,9 +204,10 @@
### 9. pendingTrades
**类型**: `Array<Object>`
**默认值**: `[]`
**类型**: `Array<Object>`
**默认值**: `[]`
**说明**: 存储待处理的交易记录(当净值未更新时)
**云端同步**: 是
**数据结构**:
```javascript
@@ -215,7 +224,6 @@
feeValue: number, // 手续费金额
date: string, // 交易日期
isAfter3pm: boolean, // 是否下午3点后
isAfter3pm: boolean, // 是否下午3点后
timestamp: number // 时间戳
}
]
@@ -230,9 +238,10 @@
### 10. localUpdatedAt
**类型**: `string` (ISO 8601 格式)
**默认值**: `null`
**类型**: `string` (ISO 8601 格式)
**默认值**: `null`
**说明**: 存储本地数据最后更新时间戳,用于云端同步冲突检测
**云端同步**: 否(本地专用)
**数据结构**:
```javascript
@@ -245,12 +254,13 @@
---
### 11. hasClosedAnnouncement_v7
### 11. hasClosedAnnouncement_v19
**类型**: `string`
**默认值**: `null`
**可选值**: `'true'`
**说明**: 标记用户是否已关闭公告弹窗
**类型**: `string`
**默认值**: `null`
**可选值**: `'true'`
**说明**: 标记用户是否已关闭公告弹窗(版本号后缀用于控制不同版本的公告)
**云端同步**: 否
**数据结构**:
```javascript
@@ -259,7 +269,234 @@
**使用场景**:
- 控制公告弹窗显示
- 版本号后缀v7)用于控制公告版本
- 版本号后缀v19)用于控制公告版本
---
### 12. customSettings
**类型**: `Object`
**默认值**: `{}`
**说明**: 存储用户的高级设置和偏好
**云端同步**: 是
**数据结构**:
```javascript
{
localSortRules: [ // 排序规则配置
{
id: string, // 规则唯一标识
field: string, // 排序字段
label: string, // 显示标签
direction: 'asc' | 'desc', // 排序方向
enabled: boolean // 是否启用
}
],
pcContainerWidth: number, // PC端容器宽度桌面版
marketIndexSelected: Array<string>, // 选中的市场指数代码
// ... 其他自定义设置
}
```
**使用场景**:
- 排序规则持久化
- PC端布局宽度设置
- 市场指数选择
- 云端同步所有自定义设置
---
### 13. localSortBy / localSortOrder
**类型**: `string`
**默认值**: `'default'` / `'asc'`
**说明**: 存储当前排序字段和排序方向
**云端同步**: 否(通过 customSettings 同步)
**数据结构**:
```javascript
// localSortBy
'gszzl' // 按估算涨跌幅排序
'default' // 默认排序
// localSortOrder
'asc' // 升序
'desc' // 降序
```
**使用场景**:
- 快速访问当前排序状态
- 与 customSettings.localSortRules 保持同步
---
### 14. localSortRules (旧版)
**类型**: `Array<Object>`
**默认值**: `[]`
**说明**: 旧版排序规则存储,已迁移到 customSettings.localSortRules
**云端同步**: 否
**注意**: 该键已弃用,数据已迁移到 customSettings.localSortRules。代码中仍保留兼容性处理。
---
### 15. currentTab
**类型**: `string`
**默认值**: `'all'`
**说明**: 存储用户当前选中的标签页
**云端同步**: 否
**数据结构**:
```javascript
'all' // 全部资产
'fav' // 自选
groupId // 分组ID如 'group_xxx'
```
**使用场景**:
- 恢复用户上次查看的标签页
- 页面刷新后保持标签页状态
---
### 16. theme
**类型**: `string`
**默认值**: `'dark'`
**可选值**: `'light'` | `'dark'`
**说明**: 存储用户选择的主题模式
**云端同步**: 否
**数据结构**:
```javascript
'dark' // 暗色主题
'light' // 亮色主题
```
**使用场景**:
- 控制应用整体配色
- 页面加载时立即应用(通过 layout.jsx 内联脚本)
---
### 17. fundValuationTimeseries
**类型**: `Object`
**默认值**: `{}`
**说明**: 存储基金估值分时数据,用于走势图展示
**云端同步**: 否(测试中功能,暂不同步)
**数据结构**:
```javascript
{
"000001": [ // 按基金代码索引
{
time: string, // 时间点 "HH:mm"
value: number, // 估算净值
date: string // 日期 "YYYY-MM-DD"
}
],
"110022": [
// ...
]
}
```
**数据清理规则**:
- 当新数据日期大于已存储的最大日期时,清空该基金所有旧日期数据,只保留当日分时
- 同一日期内按时间顺序追加数据
**使用场景**:
- 基金详情页分时图展示
- 实时估值数据记录
---
### 18. transactions
**类型**: `Object`
**默认值**: `{}`
**说明**: 存储用户的交易历史记录
**云端同步**: 是
**数据结构**:
```javascript
{
"000001": [ // 按基金代码索引的交易列表
{
id: string, // 交易唯一标识
type: 'buy' | 'sell', // 交易类型
amount: number, // 交易金额
share: number, // 交易份额
price: number, // 成交价格
date: string, // 交易日期
timestamp: number // 时间戳
}
],
"110022": [
// ...
]
}
```
**使用场景**:
- 交易历史查询
- 收益计算
- 买入/卖出操作记录
---
### 19. dcaPlans (定投计划)
**类型**: `Object`
**默认值**: `{}`
**说明**: 存储用户的定投计划配置
**云端同步**: 是
**数据结构**:
```javascript
{
"000001": { // 按基金代码索引
amount: number, // 每次定投金额
feeRate: number, // 手续费率
cycle: string, // 定投周期
firstDate: string, // 首次定投日期
enabled: boolean // 是否启用
},
"110022": {
// ...
}
}
```
**使用场景**:
- 自动定投执行
- 定投计划管理
- 买入操作时设置
---
### 20. marketIndexSelected
**类型**: `Array<string>`
**默认值**: `[]`
**说明**: 存储用户选中的市场指数代码
**云端同步**: 否(通过 customSettings 同步)
**数据结构**:
```javascript
[
"sh000001", // 上证指数
"sz399001", // 深证成指
// ...
]
```
**使用场景**:
- 市场指数面板显示
- 指数选择管理
---
@@ -267,22 +504,36 @@
### 云端同步
项目支持通过 Supabase 进行云端数据同步:
项目支持通过 Supabase 进行云端数据同步。以下键参与云端同步
1. **上传到云端**: 用户登录后,本地数据会自动上传到云端
2. **从云端下载**: 用户在其他设备登录时,会从云端下载数据
3. **冲突处理**: 当本地和云端数据不一致时,会提示用户选择使用哪份数据
**同步的数据字段**:
**参与云端同步的键**:
- funds
- favorites
- groups
- collapsedCodes
- collapsedTrends
- viewMode
- refreshMs
- holdings
- pendingTrades
- transactions
- dcaPlans
- customSettings
**不参与云端同步的键**:
- localUpdatedAt本地专用
- hasClosedAnnouncement_v19本地专用
- localSortBy / localSortOrder通过 customSettings 同步)
- localSortRules旧版兼容通过 customSettings 同步)
- currentTab本地会话状态
- theme本地主题偏好
- fundValuationTimeseries测试中功能
- marketIndexSelected通过 customSettings 同步)
- viewMode通过 customSettings 同步)
**同步流程**:
1. 用户登录后,本地数据会自动上传到云端
2. 用户在其他设备登录时,会从云端下载数据
3. 当本地和云端数据不一致时,会提示用户选择使用哪份数据
### 导入/导出
@@ -296,9 +547,11 @@
groups: [],
collapsedCodes: [],
refreshMs: 30000,
viewMode: 'card',
holdings: {},
pendingTrades: [],
transactions: {},
dcaPlans: {},
customSettings: {},
exportedAt: '2024-01-15T10:30:00.000Z'
}
```
@@ -334,23 +587,40 @@ const dedupeByCode = (list) => {
1. 清理无效的持仓数据(基金不存在的持仓)
2. 清理无效的自选、分组、收起状态
3. 确保数据类型正确
3. 清理无效的交易记录和定投计划
4. 确保数据类型正确
---
## 存储辅助工具
项目使用 `storageHelper` 对象来封装 localStorage 操作,提供统一的错误处理和日志记录
项目使用 `storageHelper` 对象来封装 localStorage 操作,提供统一的错误处理和云端同步触发
```javascript
const storageHelper = {
setItem: (key, value) => { /* ... */ },
getItem: (key) => { /* ... */ },
removeItem: (key) => { /* ... */ },
clear: () => { /* ... */ }
setItem: (key, value) => {
// 1. 写入 localStorage
// 2. 触发云端同步(如果是同步键)
// 3. 更新 localUpdatedAt 时间戳
},
getItem: (key) => {
// 从 localStorage 读取
},
removeItem: (key) => {
// 从 localStorage 删除
// 触发云端同步
},
clear: () => {
// 清空所有 localStorage
}
};
```
**特性**:
- 自动触发云端同步(对于参与同步的键)
- 自动更新 localUpdatedAt 时间戳
- funds 变更时比较签名,避免无意义同步
---
## 注意事项
@@ -360,6 +630,7 @@ const storageHelper = {
3. **错误处理**: 所有 localStorage 操作都应包含 try-catch 错误处理
4. **数据格式**: 复杂数据必须使用 JSON.stringify/JSON.parse 进行序列化/反序列化
5. **版本控制**: 公告等配置使用版本号后缀,便于控制不同版本的显示
6. **fundValuationTimeseries**: 该数据不同步到云端,因为数据量较大且属于临时性数据
---
@@ -367,10 +638,15 @@ const storageHelper = {
- `app/page.jsx` - 主要页面组件,包含所有 localStorage 操作
- `app/components/Announcement.jsx` - 公告组件
- `app/components/PcFundTable.jsx` - PC端基金表格组件
- `app/components/MobileFundTable.jsx` - 移动端基金表格组件
- `app/components/MarketIndexAccordion.jsx` - 市场指数组件
- `app/lib/supabase.js` - Supabase 客户端配置
- `app/lib/valuationTimeseries.js` - 估值分时数据管理
---
## 更新日志
- **2026-03-18**: 全面更新文档,补充 transactions、dcaPlans、fundValuationTimeseries、customSettings 等键的详细说明,修正云端同步键列表
- **2026-02-19**: 初始文档创建

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 183 KiB

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "real-time-fund",
"version": "0.2.7",
"version": "0.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-time-fund",
"version": "0.2.7",
"version": "0.2.9",
"dependencies": {
"@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1",
@@ -5923,7 +5923,7 @@
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "real-time-fund",
"version": "0.2.7",
"version": "0.2.9",
"private": true,
"scripts": {
"dev": "next dev",