Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c575b000 | ||
|
|
4a3431cc1e | ||
|
|
12667521a6 | ||
|
|
84d021506b | ||
|
|
5b800f7308 | ||
|
|
cdda55bf4a | ||
|
|
d07146b819 | ||
|
|
7beac75160 | ||
|
|
84a720164c | ||
|
|
303071f639 | ||
|
|
73ce520573 | ||
|
|
270bc3ab08 | ||
|
|
9f6d1bb768 | ||
|
|
4f438d0dc5 | ||
|
|
d751daeb74 | ||
|
|
0ce7d18585 | ||
|
|
e0f6d61aaa | ||
|
|
e5b00515d3 | ||
|
|
6fad4ee487 | ||
|
|
6557371f09 | ||
|
|
8d7f2d33df | ||
|
|
82bdecca0b | ||
|
|
cc605fb45b | ||
|
|
e8bd65e499 | ||
|
|
12229e8eeb | ||
|
|
fb0dc25341 | ||
|
|
b489677d3e | ||
|
|
104a847d2a | ||
|
|
0a97b80499 | ||
|
|
7c48e94a5d | ||
|
|
02669020bc | ||
|
|
ba1687bf97 | ||
|
|
ac591c54c4 | ||
|
|
26bb966f90 | ||
|
|
a7eb537e67 | ||
|
|
5d97f8f83e | ||
|
|
e80ee0cad1 | ||
|
|
139116a0d3 | ||
|
|
ab9e8a5072 | ||
|
|
ce559664f1 | ||
|
|
d05002fd86 | ||
|
|
1a59087cd9 | ||
|
|
d8a4db34fe |
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
*.log
|
||||||
|
|
||||||
|
.cursor
|
||||||
|
.trae
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
*.md
|
||||||
|
*.txt
|
||||||
123
AGENTS.md
Normal file
123
AGENTS.md
Normal 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).
|
||||||
47
Dockerfile
47
Dockerfile
@@ -1,33 +1,36 @@
|
|||||||
# ===== 构建阶段 =====
|
# ===== 构建阶段(Alpine 减小体积)=====
|
||||||
FROM node:22-bullseye AS builder
|
# 未传入的 build-arg 使用占位符,便于运行阶段用环境变量替换
|
||||||
|
# Supabase 构建时会校验 URL,故使用合法占位 URL,运行时再替换
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
|
||||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ARG NEXT_PUBLIC_SUPABASE_URL=https://runtime-replace.supabase.co
|
||||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__
|
||||||
ARG NEXT_PUBLIC_GA_ID
|
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__
|
||||||
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
ARG NEXT_PUBLIC_GA_ID=__NEXT_PUBLIC_GA_ID__
|
||||||
|
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=__NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL__
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx next build
|
RUN npx next build
|
||||||
# ===== 运行阶段 =====
|
|
||||||
FROM node:22-bullseye AS runner
|
# ===== 运行阶段(仅静态资源 + nginx,启动时替换占位符)=====
|
||||||
WORKDIR /app
|
FROM nginx:alpine AS runner
|
||||||
ENV NODE_ENV=production
|
WORKDIR /usr/share/nginx/html
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
COPY --from=builder /app/out .
|
||||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
RUN chmod +x /entrypoint.sh
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3000 || exit 1
|
CMD curl -f http://localhost:3000/ || exit 1
|
||||||
CMD ["npm", "start"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 实时基金估值 (Real-time Fund Valuation)
|
# 实时基金估值 (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/)
|
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
|
||||||
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
||||||
@@ -20,9 +20,18 @@
|
|||||||
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
||||||
- **重仓追踪**:自动获取基金前 10 大重仓股票,并实时追踪重仓股的盘中涨跌情况。支持收起/展开展示。
|
- **重仓追踪**:自动获取基金前 10 大重仓股票,并实时追踪重仓股的盘中涨跌情况。支持收起/展开展示。
|
||||||
- **纯前端运行**:采用 JSONP 方案直连东方财富、腾讯财经等公开接口,彻底解决跨域问题,支持在 GitHub Pages 等静态环境直接部署。
|
- **纯前端运行**:采用 JSONP 方案直连东方财富、腾讯财经等公开接口,彻底解决跨域问题,支持在 GitHub Pages 等静态环境直接部署。
|
||||||
- **本地持久化**:使用 `localStorage` 存储已添加的基金列表及配置信息,刷新不丢失。
|
- **本地持久化**:使用 `localStorage` 存储已添加的基金列表、持仓、交易记录、定投计划及配置信息,刷新不丢失。
|
||||||
- **响应式设计**:完美适配 PC 与移动端。针对移动端优化了文字展示、间距及交互体验。
|
- **响应式设计**:完美适配 PC 与移动端。针对移动端优化了文字展示、间距及交互体验。
|
||||||
- **自选功能**:支持将基金添加至“自选”列表,通过 Tab 切换展示全部基金或仅自选基金。自选状态支持持久化及同步清理。
|
- **自选功能**:支持将基金添加至"自选"列表,通过 Tab 切换展示全部基金或仅自选基金。自选状态支持持久化及同步清理。
|
||||||
|
- **分组管理**:支持创建多个基金分组,方便按用途或类别管理基金。
|
||||||
|
- **持仓管理**:记录每只基金的持有份额和成本价,自动计算持仓收益和累计收益。
|
||||||
|
- **交易记录**:支持买入/卖出操作,记录交易历史,支持查看单个基金的交易明细。
|
||||||
|
- **定投计划**:支持设置自动定投计划,可按日/周/月等周期自动生成买入交易。
|
||||||
|
- **云端同步**:通过 Supabase 云端备份数据,支持多设备间数据同步与冲突处理。
|
||||||
|
- **自定义排序**:支持多种排序规则(估值涨跌幅、持仓收益、持有金额等),可自由组合和启用/禁用规则。
|
||||||
|
- **拖拽排序**:在默认排序模式下可通过拖拽调整基金顺序。
|
||||||
|
- **明暗主题**:支持亮色/暗色主题切换,一键换肤。
|
||||||
|
- **导入/导出**:支持将配置导出为 JSON 文件备份,或从文件导入恢复。
|
||||||
- **可自定义频率**:支持设置自动刷新间隔(5秒 - 300秒),并提供手动刷新按钮。
|
- **可自定义频率**:支持设置自动刷新间隔(5秒 - 300秒),并提供手动刷新按钮。
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
@@ -75,6 +84,12 @@
|
|||||||
NEXT_PUBLIC_Supabase_URL:Supabase控制台 → Project Settings → General → Project ID
|
NEXT_PUBLIC_Supabase_URL:Supabase控制台 → Project Settings → General → Project ID
|
||||||
NEXT_PUBLIC_Supabase_ANON_KEY: Supabase控制台 → Project Settings → API Keys → Publishable key
|
NEXT_PUBLIC_Supabase_ANON_KEY: Supabase控制台 → Project Settings → API Keys → Publishable key
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
2. 邮件数量修改
|
2. 邮件数量修改
|
||||||
|
|
||||||
Supabase 免费项目自带每小时2条邮件服务。如果觉得额度不够,可以改成自己的邮箱SMTP。修改路径在 Supabase控制台 → Authentication → Email → SMTP Settings。
|
Supabase 免费项目自带每小时2条邮件服务。如果觉得额度不够,可以改成自己的邮箱SMTP。修改路径在 Supabase控制台 → Authentication → Email → SMTP Settings。
|
||||||
@@ -93,7 +108,47 @@
|
|||||||
|
|
||||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
||||||
|
|
||||||
6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
|
6. 配置 GitHub 登录(可选)
|
||||||
|
|
||||||
|
如需支持 GitHub OAuth 登录,需完成以下配置:
|
||||||
|
|
||||||
|
**第一步:在 GitHub 创建 OAuth App**
|
||||||
|
- 访问 GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
|
||||||
|
- 填写信息:
|
||||||
|
- Application name:自定义应用名称
|
||||||
|
- Homepage URL:你的应用地址(如 `https://hzm0321.github.io/real-time-fund/`)
|
||||||
|
- Authorization callback URL:`https://<your-supabase-project-id>.supabase.co/auth/v1/callback`
|
||||||
|
- 创建后获取 **Client ID** 和 **Client Secret**
|
||||||
|
|
||||||
|
**第二步:在 Supabase 启用 GitHub Provider**
|
||||||
|
- Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → GitHub
|
||||||
|
- 开启 **GitHub** 开关
|
||||||
|
- 填入 GitHub OAuth App 的 **Client ID** 和 **Client Secret**
|
||||||
|
- 点击 **Save** 保存
|
||||||
|
|
||||||
|
**第三步:配置站点 URL(重要)**
|
||||||
|
- Supabase控制台 → Authentication → URL Configuration
|
||||||
|
- **Site URL**:设置为你的应用主域名(如 `https://hzm0321.github.io/`)
|
||||||
|
- **Redirect URLs**:添加你的应用完整路径(如 `https://hzm0321.github.io/real-time-fund/`)
|
||||||
|
|
||||||
|
配置完成后,用户即可通过 GitHub 账号一键登录。
|
||||||
|
|
||||||
|
7. 执行数据库初始化 SQL
|
||||||
|
|
||||||
|
项目需要创建 `user_configs` 表及相关策略才能使用云端同步功能。SQL 语句位于项目 `/doc/supabase.sql` 文件。
|
||||||
|
|
||||||
|
**执行步骤:**
|
||||||
|
- Supabase控制台 → SQL Editor → New query
|
||||||
|
- 复制 `/doc/supabase.sql` 文件中的全部内容,粘贴到编辑器
|
||||||
|
- 点击 **Run** 执行
|
||||||
|
|
||||||
|
SQL 脚本将完成以下操作:
|
||||||
|
- 创建 `user_configs` 表(存储用户配置数据)
|
||||||
|
- 启用行级安全(RLS),确保用户只能访问自己的数据
|
||||||
|
- 创建 SELECT / INSERT / UPDATE 策略
|
||||||
|
- 创建 `update_user_config_partial` 函数(用于增量更新配置)
|
||||||
|
|
||||||
|
执行成功后,可在 Table Editor 中看到 `user_configs` 表。
|
||||||
|
|
||||||
更多 Supabase 相关内容查阅官方文档。
|
更多 Supabase 相关内容查阅官方文档。
|
||||||
|
|
||||||
@@ -111,18 +166,27 @@ npm run build
|
|||||||
|
|
||||||
### Docker运行
|
### Docker运行
|
||||||
|
|
||||||
需先配置环境变量(与本地开发一致),否则构建出的镜像中 Supabase 等配置为空。可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
镜像支持两种配置方式:
|
||||||
|
|
||||||
1. 构建镜像(构建时会读取当前环境或同目录 `.env` 中的变量)
|
- **构建时写入**:构建时通过 `--build-arg` 或 `.env` 传入 `NEXT_PUBLIC_*`,值会打进镜像,运行时无需再传。
|
||||||
|
- **运行时替换**:构建时不传(或使用默认占位符),启动容器时通过 `-e` 或 `--env-file` 传入,入口脚本会在启动 Nginx 前替换静态资源中的占位符。
|
||||||
|
|
||||||
|
可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
||||||
|
|
||||||
|
1. 构建镜像
|
||||||
```bash
|
```bash
|
||||||
|
# 方式 A:运行时再注入配置(镜像内为占位符)
|
||||||
docker build -t real-time-fund .
|
docker build -t real-time-fund .
|
||||||
# 或通过 --build-arg 传入,例如:
|
|
||||||
# docker build -t real-time-fund --build-arg NEXT_PUBLIC_Supabase_URL=xxx --build-arg NEXT_PUBLIC_Supabase_ANON_KEY=xxx --build-arg NEXT_PUBLIC_GA_ID=G-xxxx .
|
# 方式 B:构建时写入配置
|
||||||
|
docker build -t real-time-fund --build-arg NEXT_PUBLIC_SUPABASE_URL=xxx --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx .
|
||||||
|
# 或依赖同目录 .env:docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 启动容器
|
2. 启动容器
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 --name fund real-time-fund
|
# 若构建时未写入配置,可在此注入(与 --env-file .env 二选一)
|
||||||
|
docker run -d -p 3000:3000 --name fund --env-file .env real-time-fund
|
||||||
```
|
```
|
||||||
|
|
||||||
#### docker-compose(会读取同目录 `.env` 作为 build-arg 与运行环境)
|
#### docker-compose(会读取同目录 `.env` 作为 build-arg 与运行环境)
|
||||||
@@ -131,6 +195,29 @@ docker run -d -p 3000:3000 --name fund real-time-fund
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Docker Hub
|
||||||
|
|
||||||
|
镜像已发布至 Docker Hub,可直接拉取运行,无需本地构建。
|
||||||
|
|
||||||
|
1. **拉取镜像**
|
||||||
|
```bash
|
||||||
|
docker pull hzm0321/real-time-fund:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动容器**
|
||||||
|
访问 [http://localhost:3000](http://localhost:3000) 即可使用。
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:3000 --name real-time-fund --restart always hzm0321/real-time-fund:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **使用自定义环境变量(运行时替换)**
|
||||||
|
镜像内已预置占位符,启动时通过环境变量即可覆盖,无需重新构建。例如使用本地 `.env`:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:3000 --name real-time-fund --restart always --env-file .env hzm0321/real-time-fund:latest
|
||||||
|
```
|
||||||
|
或单独指定变量:`-e NEXT_PUBLIC_SUPABASE_URL=xxx -e NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx`。
|
||||||
|
变量名与本地开发一致:`NEXT_PUBLIC_SUPABASE_URL`、`NEXT_PUBLIC_SUPABASE_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`、`NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`。
|
||||||
|
|
||||||
## 📖 使用说明
|
## 📖 使用说明
|
||||||
|
|
||||||
1. **添加基金**:在顶部输入框输入 6 位基金代码(如 `110022`),点击“添加”。
|
1. **添加基金**:在顶部输入框输入 6 位基金代码(如 `110022`),点击“添加”。
|
||||||
|
|||||||
38
app/api/AGENTS.md
Normal file
38
app/api/AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# app/api/ — Data Fetching Layer
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
Single file (`fund.js`, ~954 lines) containing ALL external data fetching for the entire application. Pure client-side: JSONP + script tag injection to bypass CORS.
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `fetchFundData(code)` | Main fund data (valuation + NAV + holdings). Uses 天天基金 JSONP |
|
||||||
|
| `fetchFundDataFallback(code)` | Backup data source when primary fails |
|
||||||
|
| `fetchSmartFundNetValue(code, date)` | Smart NAV lookup with date fallback |
|
||||||
|
| `searchFunds(val)` | Fund search by name/code (东方财富) |
|
||||||
|
| `fetchFundHistory(code, range)` | Historical NAV data via pingzhongdata |
|
||||||
|
| `fetchFundPingzhongdata(code)` | Raw eastmoney pingzhongdata (trend, grand total) |
|
||||||
|
| `fetchMarketIndices()` | 24 A-share/HK/US indices via 腾讯财经 |
|
||||||
|
| `fetchShanghaiIndexDate()` | Shanghai index date for trading day check |
|
||||||
|
| `parseFundTextWithLLM(text)` | OCR text → fund codes via LLM (apis.iflow.cn) |
|
||||||
|
| `loadScript(url)` | JSONP helper — creates script tag, waits for global var |
|
||||||
|
| `fetchRelatedSectors(code)` | Fund sector/track info (unused in main UI) |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- **JSONP pattern**: `loadScript(url)` → sets global callback → script.onload → reads `window.XXX` → cleanup
|
||||||
|
- **All functions return Promises** — async/await throughout
|
||||||
|
- **Cached via `cachedRequest()`** from `app/lib/cacheRequest.js`
|
||||||
|
- **Error handling**: try/catch returning null/empty — never throws to UI
|
||||||
|
- **Market indices**: `MARKET_INDEX_KEYS` array defines 24 indices with `code`, `varKey`, `name`
|
||||||
|
- **Stock code normalization**: `normalizeTencentCode()` handles A-share (6-digit), HK (5-digit), US (letter codes)
|
||||||
|
|
||||||
|
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||||
|
|
||||||
|
- **Hardcoded API keys** (lines 911-914) — plaintext LLM service keys in source
|
||||||
|
- **Empty catch blocks** — several `catch (e) {}` silently swallowing errors
|
||||||
|
- **Global window pollution** — JSONP callbacks assigned to `window.jsonpgz`, `window.SuggestData_*`, etc.
|
||||||
|
- **No retry logic** — failed requests return null, no exponential backoff
|
||||||
|
- **Script cleanup race conditions** — scripts removed from DOM after onload/onerror, but timeout may trigger after removal
|
||||||
232
app/api/fund.js
232
app/api/fund.js
@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
|
|||||||
const nowInTz = () => dayjs().tz(TZ);
|
const nowInTz = () => dayjs().tz(TZ);
|
||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
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) => {
|
export const loadScript = (url) => {
|
||||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||||
|
|
||||||
@@ -126,6 +155,38 @@ const parseLatestNetValueFromLsjzContent = (content) => {
|
|||||||
return null;
|
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) => {
|
const extractHoldingsReportDate = (html) => {
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
|
|
||||||
@@ -287,16 +348,19 @@ export const fetchFundData = async (c) => {
|
|||||||
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
||||||
};
|
};
|
||||||
const lsjzPromise = new Promise((resolveT) => {
|
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)
|
loadScript(url)
|
||||||
.then((apidata) => {
|
.then((apidata) => {
|
||||||
const content = apidata?.content || '';
|
const content = apidata?.content || '';
|
||||||
const latest = parseLatestNetValueFromLsjzContent(content);
|
const navList = parseNetValuesFromLsjzContent(content);
|
||||||
if (latest && latest.nav) {
|
if (navList.length > 0) {
|
||||||
|
const latest = navList[navList.length - 1];
|
||||||
|
const previousNav = navList.length > 1 ? navList[navList.length - 2] : null;
|
||||||
resolveT({
|
resolveT({
|
||||||
dwjz: String(latest.nav),
|
dwjz: String(latest.nav),
|
||||||
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||||
jzrq: latest.date
|
jzrq: latest.date,
|
||||||
|
lastNav: previousNav ? String(previousNav.nav) : null
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resolveT(null);
|
resolveT(null);
|
||||||
@@ -341,8 +405,12 @@ export const fetchFundData = async (c) => {
|
|||||||
let name = '';
|
let name = '';
|
||||||
let weight = '';
|
let weight = '';
|
||||||
if (idxCode >= 0 && tds[idxCode]) {
|
if (idxCode >= 0 && tds[idxCode]) {
|
||||||
const m = tds[idxCode].match(/(\d{6})/);
|
const raw = String(tds[idxCode] || '').trim();
|
||||||
code = m ? m[1] : tds[idxCode];
|
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 {
|
} else {
|
||||||
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
||||||
if (codeIdx >= 0) code = tds[codeIdx];
|
if (codeIdx >= 0) code = tds[codeIdx];
|
||||||
@@ -365,20 +433,67 @@ export const fetchFundData = async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
holdings = holdings.slice(0, 10);
|
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) {
|
if (needQuotes.length) {
|
||||||
try {
|
try {
|
||||||
const tencentCodes = needQuotes.map(h => {
|
const tencentCodes = needQuotes.map((x) => x.tencentCode).join(',');
|
||||||
const cd = String(h.code || '');
|
|
||||||
if (/^\d{6}$/.test(cd)) {
|
|
||||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
|
||||||
return `s_${pfx}${cd}`;
|
|
||||||
}
|
|
||||||
if (/^\d{5}$/.test(cd)) {
|
|
||||||
return `s_hk${cd}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}).filter(Boolean).join(',');
|
|
||||||
if (!tencentCodes) {
|
if (!tencentCodes) {
|
||||||
resolveH(holdings);
|
resolveH(holdings);
|
||||||
return;
|
return;
|
||||||
@@ -388,22 +503,15 @@ export const fetchFundData = async (c) => {
|
|||||||
const scriptQuote = document.createElement('script');
|
const scriptQuote = document.createElement('script');
|
||||||
scriptQuote.src = quoteUrl;
|
scriptQuote.src = quoteUrl;
|
||||||
scriptQuote.onload = () => {
|
scriptQuote.onload = () => {
|
||||||
needQuotes.forEach(h => {
|
needQuotes.forEach(({ h, tencentCode }) => {
|
||||||
const cd = String(h.code || '');
|
const varName = getTencentVarName(tencentCode);
|
||||||
let varName = '';
|
const dataStr = varName ? window[varName] : null;
|
||||||
if (/^\d{6}$/.test(cd)) {
|
|
||||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
|
||||||
varName = `v_s_${pfx}${cd}`;
|
|
||||||
} else if (/^\d{5}$/.test(cd)) {
|
|
||||||
varName = `v_s_hk${cd}`;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dataStr = window[varName];
|
|
||||||
if (dataStr) {
|
if (dataStr) {
|
||||||
const parts = dataStr.split('~');
|
const parts = dataStr.split('~');
|
||||||
if (parts.length > 5) {
|
const isUS = /^us/i.test(String(tencentCode || ''));
|
||||||
h.change = parseFloat(parts[5]);
|
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.dwjz = tData.dwjz;
|
||||||
gzData.jzrq = tData.jzrq;
|
gzData.jzrq = tData.jzrq;
|
||||||
gzData.zzl = tData.zzl;
|
gzData.zzl = tData.zzl;
|
||||||
|
gzData.lastNav = tData.lastNav;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
@@ -676,7 +785,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (typeof document === 'undefined' || !document.body) {
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
reject(new Error('无浏览器环境'));
|
reject(new Error('无浏览器环境'));
|
||||||
@@ -770,23 +879,62 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
default: start = start.subtract(1, 'month');
|
default: start = start.subtract(1, 'month');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend,
|
||||||
|
// 同时附带 Data_grandTotal(若存在,格式为 [{ name, data: [[ts, val], ...] }, ...])
|
||||||
try {
|
try {
|
||||||
const pz = await fetchFundPingzhongdata(code);
|
const pz = await fetchFundPingzhongdata(code);
|
||||||
const trend = pz?.Data_netWorthTrend;
|
const trend = pz?.Data_netWorthTrend;
|
||||||
|
const grandTotal = pz?.Data_grandTotal;
|
||||||
|
|
||||||
if (Array.isArray(trend) && trend.length) {
|
if (Array.isArray(trend) && trend.length) {
|
||||||
const startMs = start.startOf('day').valueOf();
|
const startMs = start.startOf('day').valueOf();
|
||||||
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
|
||||||
const endMs = end.endOf('day').valueOf();
|
const endMs = end.endOf('day').valueOf();
|
||||||
const out = trend
|
|
||||||
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
// 若起始日没有净值,则往前推到最近一日有净值的数据作为有效起始
|
||||||
|
const validTrend = trend
|
||||||
|
.filter((d) => d && typeof d.x === 'number' && Number.isFinite(Number(d.y)) && d.x <= endMs)
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
const startDayEndMs = startMs + 24 * 60 * 60 * 1000 - 1;
|
||||||
|
const hasPointOnStartDay = validTrend.some((d) => d.x >= startMs && d.x <= startDayEndMs);
|
||||||
|
let effectiveStartMs = startMs;
|
||||||
|
if (!hasPointOnStartDay) {
|
||||||
|
const lastBeforeStart = validTrend.filter((d) => d.x < startMs).pop();
|
||||||
|
if (lastBeforeStart) effectiveStartMs = lastBeforeStart.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = validTrend
|
||||||
|
.filter((d) => d.x >= effectiveStartMs && d.x <= endMs)
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
const value = Number(d.y);
|
const value = Number(d.y);
|
||||||
if (!Number.isFinite(value)) return null;
|
|
||||||
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
||||||
return { date, value };
|
return { date, value };
|
||||||
})
|
});
|
||||||
.filter(Boolean);
|
|
||||||
|
// 解析 Data_grandTotal 为多条对比曲线,使用同一有效起始日
|
||||||
|
if (Array.isArray(grandTotal) && grandTotal.length) {
|
||||||
|
const grandTotalSeries = grandTotal
|
||||||
|
.map((series) => {
|
||||||
|
if (!series || !series.data || !Array.isArray(series.data)) return null;
|
||||||
|
const name = series.name || '';
|
||||||
|
const points = series.data
|
||||||
|
.filter((item) => Array.isArray(item) && typeof item[0] === 'number')
|
||||||
|
.map(([ts, val]) => {
|
||||||
|
if (ts < effectiveStartMs || ts > endMs) return null;
|
||||||
|
const numVal = Number(val);
|
||||||
|
if (!Number.isFinite(numVal)) return null;
|
||||||
|
const date = dayjs(ts).tz(TZ).format('YYYY-MM-DD');
|
||||||
|
return { ts, date, value: numVal };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!points.length) return null;
|
||||||
|
return { name, points };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (grandTotalSeries.length) {
|
||||||
|
out.grandTotalSeries = grandTotalSeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (out.length) return out;
|
if (out.length) return out;
|
||||||
}
|
}
|
||||||
@@ -797,8 +945,8 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const API_KEYS = [
|
const API_KEYS = [
|
||||||
'sk-5b03d4e02ec22dd2ba233fb6d2dd549b',
|
'sk-25b8a4a3d88a49e82e87c981d9d8f6b4',
|
||||||
'sk-5f14ce9c6e94af922bf592942426285c'
|
'sk-1565f822d5bd745b6529cfdf28b55574'
|
||||||
// 添加更多 API Key 到这里
|
// 添加更多 API Key 到这里
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 182 KiB |
56
app/components/AGENTS.md
Normal file
56
app/components/AGENTS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# app/components/ — UI Components
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
47 app-specific React components (all client-side). Modals dominate (~26). Core display: FundCard, PcFundTable, MobileFundTable.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/
|
||||||
|
├── Core Display (6)
|
||||||
|
│ ├── FundCard.jsx # Individual fund card (valuation + holdings)
|
||||||
|
│ ├── PcFundTable.jsx # Desktop table layout
|
||||||
|
│ ├── MobileFundTable.jsx # Mobile list with swipe actions
|
||||||
|
│ ├── MobileFundCardDrawer.jsx# Mobile fund detail drawer
|
||||||
|
│ ├── GroupSummary.jsx # Group portfolio summary
|
||||||
|
│ └── MarketIndexAccordion.jsx# Market indices (24 A/HK/US)
|
||||||
|
├── Modals (26)
|
||||||
|
│ ├── Fund ops: AddFundToGroupModal, GroupManageModal, GroupModal, AddResultModal
|
||||||
|
│ ├── Trading: TradeModal, HoldingEditModal, HoldingActionModal, TransactionHistoryModal, PendingTradesModal, DcaModal, AddHistoryModal
|
||||||
|
│ ├── Settings: SettingsModal, MarketSettingModal, MobileSettingModal, PcTableSettingModal, SortSettingModal
|
||||||
|
│ ├── Auth: LoginModal, CloudConfigModal
|
||||||
|
│ ├── Scan: ScanPickModal, ScanProgressModal, ScanImportConfirmModal, ScanImportProgressModal
|
||||||
|
│ └── Misc: ConfirmModal, SuccessModal, DonateModal, FeedbackModal, WeChatModal, UpdatePromptModal, FundHistoryNetValueModal
|
||||||
|
├── Charts (3)
|
||||||
|
│ ├── FundIntradayChart.jsx # Intraday valuation chart (localStorage data)
|
||||||
|
│ ├── FundTrendChart.jsx # Fund trend chart (pingzhongdata)
|
||||||
|
│ └── FundHistoryNetValue.jsx # Historical NAV display
|
||||||
|
└── Utilities (7)
|
||||||
|
├── Icons.jsx # Custom SVG icons (Close, Eye, Moon, Sun, etc.)
|
||||||
|
├── Common.jsx # Shared UI helpers
|
||||||
|
├── FitText.jsx # Auto-fit text sizing
|
||||||
|
├── RefreshButton.jsx # Manual refresh control
|
||||||
|
├── EmptyStateCard.jsx # Empty state placeholder
|
||||||
|
├── Announcement.jsx # Banner announcement
|
||||||
|
├── ThemeColorSync.jsx # Theme meta tag sync
|
||||||
|
├── PwaRegister.jsx # Service worker registration
|
||||||
|
└── AnalyticsGate.jsx # Conditional GA loader
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- **All client components** — `'use client'` at top, no server components
|
||||||
|
- **State from parent** — page.jsx manages ALL state; components receive props only
|
||||||
|
- **shadcn/ui primitives** — imported from `@/components/ui/*`
|
||||||
|
- **Mobile/Desktop switching** — parent passes `isMobile` prop; 640px breakpoint
|
||||||
|
- **Modals**: use `useBodyScrollLock(open)` hook for scroll prevention
|
||||||
|
- **Icons**: mix of custom SVG (Icons.jsx) + lucide-react
|
||||||
|
- **Styling**: glassmorphism via CSS variables (globals.css), no component-level CSS
|
||||||
|
|
||||||
|
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||||
|
|
||||||
|
- **No prop drilling avoidance** — all state flows from page.jsx via props (30+ prop holes in FundCard)
|
||||||
|
- **Modal sprawl** — 26 modals could benefit from a modal manager/context
|
||||||
|
- **Swipe gesture duplication** — MobileFundTable and MobileFundCardDrawer both implement swipe logic
|
||||||
|
- **No loading skeletons** — components show spinners, not skeleton placeholders
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
import { CloseIcon, PlusIcon } from './Icons';
|
import { CloseIcon, PlusIcon } from './Icons';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -10,8 +11,17 @@ import {
|
|||||||
|
|
||||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdings = {}, onClose, onAdd }) {
|
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdings = {}, onClose, onAdd }) {
|
||||||
const [selected, setSelected] = useState(new Set());
|
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 getHoldingAmount = (fund) => {
|
||||||
const holding = holdings[fund?.code];
|
const holding = holdings[fund?.code];
|
||||||
@@ -44,6 +54,22 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdi
|
|||||||
overlayClassName="modal-overlay"
|
overlayClassName="modal-overlay"
|
||||||
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
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>
|
<DialogTitle className="sr-only">添加基金到分组</DialogTitle>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -55,10 +81,45 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{availableFunds.length === 0 ? (
|
||||||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
<p>所有基金已在该分组中</p>
|
<p>{searchQuery.trim() ? '未找到匹配的基金' : '所有基金已在该分组中'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="group-manage-list">
|
<div className="group-manage-list">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v17';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v20';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -75,16 +75,14 @@ export default function Announcement() {
|
|||||||
<span>公告</span>
|
<span>公告</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||||
<p>v0.2.6 更新内容:</p>
|
<p>v0.2.9 更新内容:</p>
|
||||||
<p>1. 新增大盘指数并支持个性化。</p>
|
<p>1. 排序新增按昨日涨幅排序。</p>
|
||||||
<p>2. 新增持仓金额排序以及排序个性化设置。</p>
|
<p>2. 排序个性化设置支持切换排序形式。</p>
|
||||||
<p>3. 新增历史净值。</p>
|
<p>3. 全局设置新增显示/隐藏大盘指数。</p>
|
||||||
<p>4. 表格视图斑马纹。</p>
|
<p>4. 新增持有天数。</p>
|
||||||
|
<p>5. 登录方式支持 Github。</p>
|
||||||
<br/>
|
<br/>
|
||||||
<p>下一版本更新内容:</p>
|
关联板块实时估值还在测试,会在近期上线。
|
||||||
<p>1. 关联板块。</p>
|
|
||||||
<p>2. 收益曲线。</p>
|
|
||||||
<p>3. 估值差异列。</p>
|
|
||||||
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
|||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
|
const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
|
||||||
|
|
||||||
@@ -83,16 +83,15 @@ export function DatePicker({ value, onChange }) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<motion.div
|
<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 }}
|
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"
|
className="date-picker-dropdown glass card"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
...(position === 'top' ? { bottom: '100%', marginBottom: 8 } : { top: '100%', marginTop: 8 }),
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: 8,
|
|
||||||
padding: 12,
|
padding: 12,
|
||||||
zIndex: 10
|
zIndex: 10
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function FundCard({
|
|||||||
dcaPlans,
|
dcaPlans,
|
||||||
holdings,
|
holdings,
|
||||||
percentModes,
|
percentModes,
|
||||||
|
todayPercentModes,
|
||||||
valuationSeries,
|
valuationSeries,
|
||||||
collapsedCodes,
|
collapsedCodes,
|
||||||
collapsedTrends,
|
collapsedTrends,
|
||||||
@@ -67,6 +68,7 @@ export default function FundCard({
|
|||||||
onHoldingClick,
|
onHoldingClick,
|
||||||
onActionClick,
|
onActionClick,
|
||||||
onPercentModeToggle,
|
onPercentModeToggle,
|
||||||
|
onTodayPercentModeToggle,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onToggleTrendCollapse,
|
onToggleTrendCollapse,
|
||||||
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||||
@@ -267,8 +269,42 @@ export default function FundCard({
|
|||||||
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
{holding?.firstPurchaseDate && !masked && (() => {
|
||||||
<span className="label">当日收益</span>
|
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"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (profit.profitToday != null) {
|
||||||
|
onTodayPercentModeToggle?.(f.code);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: profit.profitToday != null ? 'pointer' : 'default',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
title={profit.profitToday != null ? '点击切换金额/百分比' : ''}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
当日收益{todayPercentModes?.[f.code] ? '(%)' : ''}
|
||||||
|
{profit.profitToday != null && <SwitchIcon />}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`value ${
|
className={`value ${
|
||||||
profit.profitToday != null
|
profit.profitToday != null
|
||||||
@@ -283,7 +319,16 @@ export default function FundCard({
|
|||||||
{profit.profitToday != null
|
{profit.profitToday != null
|
||||||
? masked
|
? masked
|
||||||
? '******'
|
? '******'
|
||||||
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
: <>
|
||||||
|
{profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}
|
||||||
|
{todayPercentModes?.[f.code]
|
||||||
|
? `${Math.abs(
|
||||||
|
holding?.cost * holding?.share
|
||||||
|
? (profit.profitToday / (holding.cost * holding.share)) * 100
|
||||||
|
: 0,
|
||||||
|
).toFixed(2)}%`
|
||||||
|
: `¥${Math.abs(profit.profitToday).toFixed(2)}`}
|
||||||
|
</>
|
||||||
: '--'}
|
: '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,6 +427,15 @@ export default function FundCard({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
{hasHoldings && (
|
{hasHoldings && (
|
||||||
<TabsContent value="holdings" className="mt-3 outline-none">
|
<TabsContent value="holdings" className="mt-3 outline-none">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="muted">涨跌幅 / 占比</span>
|
||||||
|
</div>
|
||||||
<div className="list">
|
<div className="list">
|
||||||
{f.holdings.map((h, idx) => (
|
{f.holdings.map((h, idx) => (
|
||||||
<div className="item" key={idx}>
|
<div className="item" key={idx}>
|
||||||
@@ -409,7 +463,8 @@ export default function FundCard({
|
|||||||
code={f.code}
|
code={f.code}
|
||||||
isExpanded
|
isExpanded
|
||||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
transactions={transactions?.[f.code] || []}
|
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||||
|
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
hideHeader
|
hideHeader
|
||||||
/>
|
/>
|
||||||
@@ -480,7 +535,8 @@ export default function FundCard({
|
|||||||
code={f.code}
|
code={f.code}
|
||||||
isExpanded={!collapsedTrends?.has(f.code)}
|
isExpanded={!collapsedTrends?.has(f.code)}
|
||||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
transactions={transactions?.[f.code] || []}
|
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||||
|
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -56,12 +56,19 @@ function getChartThemeColors(theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||||
const [range, setRange] = useState('1m');
|
const [range, setRange] = useState('3m');
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const hoverTimeoutRef = useRef(null);
|
const hoverTimeoutRef = useRef(null);
|
||||||
|
const clearActiveIndexRef = useRef(null);
|
||||||
|
const [hiddenGrandSeries, setHiddenGrandSeries] = useState(() => new Set());
|
||||||
|
const [activeIndex, setActiveIndex] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearActiveIndexRef.current = () => setActiveIndex(null);
|
||||||
|
});
|
||||||
|
|
||||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||||
|
|
||||||
@@ -119,10 +126,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
const lineColor = change >= 0 ? upColor : downColor;
|
const lineColor = change >= 0 ? upColor : downColor;
|
||||||
const primaryColor = chartColors.primary;
|
const primaryColor = chartColors.primary;
|
||||||
|
|
||||||
|
const percentageData = useMemo(() => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
const firstValue = data[0].value ?? 1;
|
||||||
|
return data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
// Calculate percentage change based on the first data point
|
// Data_grandTotal:在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
|
||||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
|
||||||
const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
|
||||||
|
|
||||||
// Map transaction dates to chart indices
|
// Map transaction dates to chart indices
|
||||||
const dateToIndex = new Map(data.map((d, i) => [d.date, i]));
|
const dateToIndex = new Map(data.map((d, i) => [d.date, i]));
|
||||||
@@ -143,12 +155,65 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 将 Data_grandTotal 的多条曲线按日期对齐到主 labels 上
|
||||||
|
const labels = data.map(d => d.date);
|
||||||
|
// 对比线颜色:避免与主线红/绿(upColor/downColor)重复
|
||||||
|
// 第三条对比线需要在亮/暗主题下都足够清晰,因此使用高对比的橙色强调
|
||||||
|
const grandAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
|
||||||
|
const grandColors = [
|
||||||
|
primaryColor,
|
||||||
|
chartColors.muted,
|
||||||
|
grandAccent3,
|
||||||
|
chartColors.text,
|
||||||
|
];
|
||||||
|
// 隐藏第一条对比线(数据与图示);第二条用原第一条颜色,第三条用原第二条,顺延
|
||||||
|
const visibleGrandSeries = grandTotalSeries.filter((_, idx) => idx > 0);
|
||||||
|
const grandDatasets = visibleGrandSeries.map((series, displayIdx) => {
|
||||||
|
const color = grandColors[displayIdx % grandColors.length];
|
||||||
|
const idx = displayIdx + 1; // 原始索引,用于 hiddenGrandSeries 的 key
|
||||||
|
const key = `${series.name || 'series'}_${idx}`;
|
||||||
|
const isHidden = hiddenGrandSeries.has(key);
|
||||||
|
const pointsByDate = new Map(series.points.map(p => [p.date, p.value]));
|
||||||
|
|
||||||
|
// 方案 2:将对比线同样归一到当前区间首日,展示为“相对本区间首日的累计收益率(百分点变化)”
|
||||||
|
let baseValue = null;
|
||||||
|
for (const date of labels) {
|
||||||
|
const v = pointsByDate.get(date);
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
baseValue = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesData = labels.map(date => {
|
||||||
|
if (isHidden || baseValue == null) return null;
|
||||||
|
const v = pointsByDate.get(date);
|
||||||
|
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
||||||
|
// Data_grandTotal 中的 value 已是百分比,这里按区间首日做“差值”,保持同一坐标含义(相对区间首日的收益率变化)
|
||||||
|
return v - baseValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
label: series.name || '累计收益率',
|
||||||
|
data: seriesData,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.2,
|
||||||
|
order: 2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: data.map(d => d.date),
|
labels: data.map(d => d.date),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
label: '涨跌幅',
|
label: '本基金',
|
||||||
data: percentageData,
|
data: percentageData,
|
||||||
borderColor: lineColor,
|
borderColor: lineColor,
|
||||||
backgroundColor: (context) => {
|
backgroundColor: (context) => {
|
||||||
@@ -165,9 +230,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
tension: 0.2,
|
tension: 0.2,
|
||||||
order: 2
|
order: 2
|
||||||
},
|
},
|
||||||
|
...(['1y', '3y', 'all'].includes(range) ? [] : grandDatasets),
|
||||||
{
|
{
|
||||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||||
label: '买入',
|
label: '买入',
|
||||||
|
isTradePoint: true,
|
||||||
data: buyPoints,
|
data: buyPoints,
|
||||||
borderColor: '#ffffff',
|
borderColor: '#ffffff',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -181,6 +248,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
{
|
{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
label: '卖出',
|
label: '卖出',
|
||||||
|
isTradePoint: true,
|
||||||
data: sellPoints,
|
data: sellPoints,
|
||||||
borderColor: '#ffffff',
|
borderColor: '#ffffff',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -193,7 +261,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}, [data, transactions, lineColor, primaryColor, upColor]);
|
}, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData, range]);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const colors = getChartThemeColors(theme);
|
const colors = getChartThemeColors(theme);
|
||||||
@@ -265,9 +333,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录当前激活的横轴索引,用于图示下方展示对应百分比
|
||||||
|
if (Array.isArray(chartElement) && chartElement.length > 0) {
|
||||||
|
const idx = chartElement[0].index;
|
||||||
|
setActiveIndex(typeof idx === 'number' ? idx : null);
|
||||||
|
} else {
|
||||||
|
setActiveIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
|
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
|
||||||
},
|
},
|
||||||
onClick: () => {}
|
onClick: (_event, elements) => {
|
||||||
|
if (Array.isArray(elements) && elements.length > 0) {
|
||||||
|
const idx = elements[0].index;
|
||||||
|
setActiveIndex(typeof idx === 'number' ? idx : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
@@ -286,14 +367,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
afterEvent: (chart, args) => {
|
afterEvent: (chart, args) => {
|
||||||
const { event, replay } = args || {};
|
const { event, replay } = args || {};
|
||||||
if (!event || replay) return; // 忽略动画重放
|
if (!event || replay) return; // 忽略动画重放
|
||||||
|
|
||||||
const type = event.type;
|
const type = event.type;
|
||||||
if (type === 'mousemove' || type === 'click') {
|
if (type === 'mousemove' || type === 'click') {
|
||||||
if (hoverTimeoutRef.current) {
|
if (hoverTimeoutRef.current) {
|
||||||
clearTimeout(hoverTimeoutRef.current);
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
hoverTimeoutRef.current = null;
|
hoverTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
hoverTimeoutRef.current = setTimeout(() => {
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
chart.setActiveElements([]);
|
chart.setActiveElements([]);
|
||||||
@@ -301,6 +382,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||||
}
|
}
|
||||||
chart.update();
|
chart.update();
|
||||||
|
clearActiveIndexRef.current?.();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -374,27 +456,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
activeElements = chart.getActiveElements();
|
activeElements = chart.getActiveElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isBuyOrSellDataset = (ds) =>
|
||||||
|
!!ds && (ds.isTradePoint === true || ds.label === '买入' || ds.label === '卖出');
|
||||||
|
|
||||||
// 1. Draw default labels for first buy and sell points only when NOT focused/hovering
|
// 1. Draw default labels for first buy and sell points only when NOT focused/hovering
|
||||||
// Index 1 is Buy, Index 2 is Sell
|
// datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
|
||||||
if (!activeElements?.length && datasets[1] && datasets[1].data) {
|
const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
|
||||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
|
||||||
if (firstBuyIndex !== -1) {
|
|
||||||
let sellIndex = -1;
|
if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
|
||||||
if (datasets[2] && datasets[2].data) {
|
const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
if (firstBuyIndex !== -1) {
|
||||||
}
|
let sellIndex = -1;
|
||||||
const isCollision = (firstBuyIndex === sellIndex);
|
if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||||
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
sellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||||
}
|
}
|
||||||
|
const isCollision = (firstBuyIndex === sellIndex);
|
||||||
|
drawPointLabel(buyDatasetIndex, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!activeElements?.length && datasets[2] && datasets[2].data) {
|
|
||||||
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
if (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||||
if (firstSellIndex !== -1) {
|
const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||||
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
if (firstSellIndex !== -1) {
|
||||||
}
|
drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle active elements (hover crosshair)
|
// 2. Handle active elements (hover crosshair)
|
||||||
|
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
|
||||||
if (activeElements && activeElements.length) {
|
if (activeElements && activeElements.length) {
|
||||||
const activePoint = activeElements[0];
|
const activePoint = activeElements[0];
|
||||||
const x = activePoint.element.x;
|
const x = activePoint.element.x;
|
||||||
@@ -425,64 +515,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
// Draw Axis Labels based on the first point (main line)
|
// Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0)作为数值来源,
|
||||||
const datasetIndex = activePoint.datasetIndex;
|
// 避免对比线在悬停时显示自己的数值标签
|
||||||
const index = activePoint.index;
|
const baseIndex = activePoint.index;
|
||||||
|
|
||||||
const labels = chart.data.labels;
|
const labels = chart.data.labels;
|
||||||
|
const mainDataset = datasets[0];
|
||||||
|
|
||||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
if (labels && mainDataset && Array.isArray(mainDataset.data)) {
|
||||||
const dateStr = labels[index];
|
const dateStr = labels[baseIndex];
|
||||||
const value = datasets[datasetIndex].data[index];
|
const value = mainDataset.data[baseIndex];
|
||||||
|
|
||||||
if (dateStr !== undefined && value !== undefined) {
|
if (dateStr !== undefined && value !== undefined) {
|
||||||
// X axis label (date) with boundary clamping
|
// X axis label (date) with boundary clamping
|
||||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||||
const chartLeft = chart.scales.x.left;
|
const chartLeft = chart.scales.x.left;
|
||||||
const chartRight = chart.scales.x.right;
|
const chartRight = chart.scales.x.right;
|
||||||
let labelLeft = x - textWidth / 2;
|
let labelLeft = x - textWidth / 2;
|
||||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||||
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||||
const labelCenterX = labelLeft + textWidth / 2;
|
const labelCenterX = labelLeft + textWidth / 2;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||||
ctx.fillStyle = colors.crosshairText;
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||||
|
|
||||||
// Y axis label (value)
|
// Y axis label (value) — 始终基于主线百分比
|
||||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||||
ctx.fillStyle = colors.crosshairText;
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for collision between Buy (1) and Sell (2) in active elements
|
// Check for collision between Buy and Sell in active elements
|
||||||
const activeBuy = activeElements.find(e => e.datasetIndex === 1);
|
const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
|
||||||
const activeSell = activeElements.find(e => e.datasetIndex === 2);
|
const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
|
||||||
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index;
|
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index;
|
||||||
|
|
||||||
// Iterate through all active points to find transaction points and draw their labels
|
// Iterate through active points,仅为买入/卖出绘制标签
|
||||||
activeElements.forEach(element => {
|
activeElements.forEach(element => {
|
||||||
const dsIndex = element.datasetIndex;
|
const dsIndex = element.datasetIndex;
|
||||||
// Only for transaction datasets (index > 0)
|
const ds = datasets?.[dsIndex];
|
||||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
if (!isBuyOrSellDataset(ds)) return;
|
||||||
const label = datasets[dsIndex].label;
|
|
||||||
// Determine background color based on dataset index
|
|
||||||
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
|
||||||
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
|
||||||
|
|
||||||
// If collision, offset Buy label upwards
|
const label = ds.label;
|
||||||
let yOffset = 0;
|
const bgColor = label === '买入' ? primaryColor : colors.danger;
|
||||||
if (isCollision && dsIndex === 1) {
|
|
||||||
yOffset = -20;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
// 如果买入/卖出在同一天,买入标签上移避免遮挡
|
||||||
}
|
let yOffset = 0;
|
||||||
|
if (isCollision && label === '买入') {
|
||||||
|
yOffset = -20;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -491,8 +579,182 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}];
|
}];
|
||||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||||
|
|
||||||
|
const lastIndex = data.length > 0 ? data.length - 1 : null;
|
||||||
|
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
|
||||||
|
|
||||||
const chartBlock = (
|
const chartBlock = (
|
||||||
<>
|
<>
|
||||||
|
{/* 顶部图示:说明不同颜色/标记代表的含义 */}
|
||||||
|
<div
|
||||||
|
className="row"
|
||||||
|
style={{ marginBottom: 8, gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: lineColor
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="muted">本基金</span>
|
||||||
|
</div>
|
||||||
|
{currentIndex != null && percentageData[currentIndex] !== undefined && (
|
||||||
|
<span
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
paddingLeft: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percentageData[currentIndex].toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Array.isArray(data.grandTotalSeries) &&
|
||||||
|
!['1y', '3y', 'all'].includes(range) &&
|
||||||
|
data.grandTotalSeries
|
||||||
|
.filter((_, idx) => idx > 0)
|
||||||
|
.map((series, displayIdx) => {
|
||||||
|
const idx = displayIdx + 1;
|
||||||
|
const legendAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
|
||||||
|
const legendColors = [
|
||||||
|
primaryColor,
|
||||||
|
chartColors.muted,
|
||||||
|
legendAccent3,
|
||||||
|
chartColors.text,
|
||||||
|
];
|
||||||
|
const color = legendColors[displayIdx % legendColors.length];
|
||||||
|
const key = `${series.name || 'series'}_${idx}`;
|
||||||
|
const isHidden = hiddenGrandSeries.has(key);
|
||||||
|
let valueText = '--';
|
||||||
|
if (!isHidden && currentIndex != null && data[currentIndex]) {
|
||||||
|
const targetDate = data[currentIndex].date;
|
||||||
|
|
||||||
|
// 与折线一致:对比线显示“相对当前区间首日”的累计收益率变化
|
||||||
|
const pointsArray = Array.isArray(series.points) ? series.points : [];
|
||||||
|
const pointsByDate = new Map(pointsArray.map(p => [p.date, p.value]));
|
||||||
|
|
||||||
|
let baseValue = null;
|
||||||
|
for (const d of data) {
|
||||||
|
const v = pointsByDate.get(d.date);
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
baseValue = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意: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)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={series.name || idx}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHiddenGrandSeries(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: isHidden ? '#4b5563' : color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="muted"
|
||||||
|
style={{ opacity: isHidden ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{series.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="muted"
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ opacity: isHidden ? 0.4 : 0.9 }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
{isHidden && (
|
||||||
|
<line
|
||||||
|
x1="4"
|
||||||
|
y1="20"
|
||||||
|
x2="20"
|
||||||
|
y2="4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
paddingLeft: 14,
|
||||||
|
minHeight: 14,
|
||||||
|
visibility: isHidden || valueText === '--' ? 'hidden' : 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valueText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
|||||||
const [displayValue, setDisplayValue] = useState(value);
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
const previousValue = useRef(value);
|
const previousValue = useRef(value);
|
||||||
const isFirstChange = useRef(true);
|
const isFirstChange = useRef(true);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
const displayValueRef = useRef(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousValue.current === value) return;
|
if (previousValue.current === value) return;
|
||||||
@@ -15,13 +17,14 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
|||||||
if (isFirstChange.current) {
|
if (isFirstChange.current) {
|
||||||
isFirstChange.current = false;
|
isFirstChange.current = false;
|
||||||
previousValue.current = value;
|
previousValue.current = value;
|
||||||
|
displayValueRef.current = value;
|
||||||
setDisplayValue(value);
|
setDisplayValue(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = previousValue.current;
|
const start = displayValueRef.current;
|
||||||
const end = value;
|
const end = value;
|
||||||
const duration = 400;
|
const duration = 300;
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
const animate = (currentTime) => {
|
const animate = (currentTime) => {
|
||||||
@@ -29,16 +32,25 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
|||||||
const progress = Math.min(elapsed / duration, 1);
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
const ease = 1 - Math.pow(1 - progress, 4);
|
const ease = 1 - Math.pow(1 - progress, 4);
|
||||||
const current = start + (end - start) * ease;
|
const current = start + (end - start) * ease;
|
||||||
|
displayValueRef.current = current;
|
||||||
setDisplayValue(current);
|
setDisplayValue(current);
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(animate);
|
rafIdRef.current = requestAnimationFrame(animate);
|
||||||
} else {
|
} else {
|
||||||
previousValue.current = value;
|
previousValue.current = value;
|
||||||
|
rafIdRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
rafIdRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,12 +68,16 @@ export default function GroupSummary({
|
|||||||
groupName,
|
groupName,
|
||||||
getProfit,
|
getProfit,
|
||||||
stickyTop,
|
stickyTop,
|
||||||
|
isSticky = false,
|
||||||
|
onToggleSticky,
|
||||||
masked,
|
masked,
|
||||||
onToggleMasked,
|
onToggleMasked,
|
||||||
|
marketIndexAccordionHeight,
|
||||||
|
navbarHeight
|
||||||
}) {
|
}) {
|
||||||
const [showPercent, setShowPercent] = useState(true);
|
const [showPercent, setShowPercent] = useState(true);
|
||||||
|
const [showTodayPercent, setShowTodayPercent] = useState(false);
|
||||||
const [isMasked, setIsMasked] = useState(masked ?? false);
|
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
|
||||||
const rowRef = useRef(null);
|
const rowRef = useRef(null);
|
||||||
const [assetSize, setAssetSize] = useState(24);
|
const [assetSize, setAssetSize] = useState(24);
|
||||||
const [metricSize, setMetricSize] = useState(18);
|
const [metricSize, setMetricSize] = useState(18);
|
||||||
@@ -115,9 +131,10 @@ export default function GroupSummary({
|
|||||||
|
|
||||||
if (profit) {
|
if (profit) {
|
||||||
hasHolding = true;
|
hasHolding = true;
|
||||||
totalAsset += profit.amount;
|
totalAsset += Math.round(profit.amount * 100) / 100;
|
||||||
if (profit.profitToday != null) {
|
if (profit.profitToday != null) {
|
||||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
|
||||||
|
totalProfitToday += profit.profitToday;
|
||||||
hasAnyTodayData = true;
|
hasAnyTodayData = true;
|
||||||
}
|
}
|
||||||
if (profit.profitTotal !== null) {
|
if (profit.profitTotal !== null) {
|
||||||
@@ -129,14 +146,19 @@ export default function GroupSummary({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 将当日收益总和四舍五入到两位小数,和卡片展示保持一致
|
||||||
|
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
|
||||||
|
|
||||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||||
|
const todayReturnRate = totalCost > 0 ? (roundedTotalProfitToday / totalCost) * 100 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAsset,
|
totalAsset,
|
||||||
totalProfitToday,
|
totalProfitToday: roundedTotalProfitToday,
|
||||||
totalHoldingReturn,
|
totalHoldingReturn,
|
||||||
hasHolding,
|
hasHolding,
|
||||||
returnRate,
|
returnRate,
|
||||||
|
todayReturnRate,
|
||||||
hasAnyTodayData,
|
hasAnyTodayData,
|
||||||
};
|
};
|
||||||
}, [funds, holdings, getProfit]);
|
}, [funds, holdings, getProfit]);
|
||||||
@@ -161,12 +183,22 @@ export default function GroupSummary({
|
|||||||
metricSize,
|
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;
|
if (!summary.hasHolding) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={isSticky ? 'group-summary-sticky' : ''}
|
className={isSticky ? 'group-summary-sticky' : ''}
|
||||||
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
style={style}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="glass card group-summary-card"
|
className="glass card group-summary-card"
|
||||||
@@ -179,7 +211,9 @@ export default function GroupSummary({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="sticky-toggle-btn"
|
className="sticky-toggle-btn"
|
||||||
onClick={() => setIsSticky(!isSticky)}
|
onClick={() => {
|
||||||
|
onToggleSticky?.(!isSticky);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
@@ -258,9 +292,17 @@ export default function GroupSummary({
|
|||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div
|
<div
|
||||||
className="muted"
|
className="muted"
|
||||||
style={{ fontSize: '12px', marginBottom: 4 }}
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: 4,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
当日收益
|
当日收益{showTodayPercent ? '(%)' : ''}{' '}
|
||||||
|
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@@ -276,7 +318,10 @@ export default function GroupSummary({
|
|||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
|
cursor: summary.hasAnyTodayData ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
|
onClick={() => summary.hasAnyTodayData && setShowTodayPercent(!showTodayPercent)}
|
||||||
|
title="点击切换金额/百分比"
|
||||||
>
|
>
|
||||||
{isMasked ? (
|
{isMasked ? (
|
||||||
<span className="mask-text" style={{ fontSize: metricSize }}>
|
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||||
@@ -291,10 +336,18 @@ export default function GroupSummary({
|
|||||||
? '-'
|
? '-'
|
||||||
: ''}
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
<CountUp
|
{showTodayPercent ? (
|
||||||
value={Math.abs(summary.totalProfitToday)}
|
<CountUp
|
||||||
style={{ fontSize: metricSize }}
|
value={Math.abs(summary.todayReturnRate)}
|
||||||
/>
|
suffix="%"
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.totalProfitToday)}
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: metricSize }}>--</span>
|
<span style={{ fontSize: metricSize }}>--</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory, pendingCount }) {
|
||||||
const handleOpenChange = (open) => {
|
const handleOpenChange = (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@@ -39,11 +39,26 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
title="查看交易记录"
|
title="查看交易记录"
|
||||||
>
|
>
|
||||||
<span>📜</span>
|
<span>📜</span>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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 [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||||
|
const [dateMode, setDateMode] = useState('date'); // 'date' | 'days'
|
||||||
|
|
||||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||||
const dwjzRef = useRef(dwjz);
|
const dwjzRef = useRef(dwjz);
|
||||||
@@ -21,10 +33,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
const [cost, setCost] = useState('');
|
const [cost, setCost] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [profit, setProfit] = useState('');
|
const [profit, setProfit] = useState('');
|
||||||
|
const [firstPurchaseDate, setFirstPurchaseDate] = useState('');
|
||||||
|
const [holdingDaysInput, setHoldingDaysInput] = useState('');
|
||||||
|
|
||||||
const holdingSig = useMemo(() => {
|
const holdingSig = useMemo(() => {
|
||||||
if (!holding) return '';
|
if (!holding) return '';
|
||||||
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
|
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}|${holding.firstPurchaseDate ?? ''}`;
|
||||||
}, [holding]);
|
}, [holding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,6 +47,14 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
const c = holding.cost || 0;
|
const c = holding.cost || 0;
|
||||||
setShare(String(s));
|
setShare(String(s));
|
||||||
setCost(String(c));
|
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;
|
const price = dwjzRef.current;
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
@@ -42,7 +64,6 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
setProfit(p.toFixed(2));
|
setProfit(p.toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [holdingSig]);
|
}, [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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -94,9 +150,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
finalCost = finalShare > 0 ? principal / finalShare : 0;
|
finalCost = finalShare > 0 ? principal / finalShare : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedDate = firstPurchaseDate ? firstPurchaseDate.trim() : '';
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
share: finalShare,
|
share: finalShare,
|
||||||
cost: finalCost
|
cost: finalCost,
|
||||||
|
...(trimmedDate && { firstPurchaseDate: trimmedDate })
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -124,6 +183,23 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
<span>设置持仓</span>
|
<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>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
<CloseIcon width="20" height="20" />
|
<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 }}>
|
<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 type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
import { MailIcon } from './Icons';
|
import { MailIcon } from './Icons';
|
||||||
|
import githubImg from "../assets/github.svg";
|
||||||
|
|
||||||
export default function LoginModal({
|
export default function LoginModal({
|
||||||
onClose,
|
onClose,
|
||||||
@@ -13,7 +15,8 @@ export default function LoginModal({
|
|||||||
loginError,
|
loginError,
|
||||||
loginSuccess,
|
loginSuccess,
|
||||||
handleSendOtp,
|
handleSendOtp,
|
||||||
handleVerifyEmailOtp
|
handleVerifyEmailOtp,
|
||||||
|
handleGithubLogin
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -84,7 +87,6 @@ export default function LoginModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loginLoading}
|
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
@@ -98,6 +100,53 @@ export default function LoginModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export default function MarketIndexAccordion({
|
|||||||
onHeightChange,
|
onHeightChange,
|
||||||
isMobile,
|
isMobile,
|
||||||
onCustomSettingsChange,
|
onCustomSettingsChange,
|
||||||
|
refreshing = false,
|
||||||
}) {
|
}) {
|
||||||
const [indices, setIndices] = useState([]);
|
const [indices, setIndices] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -206,6 +207,7 @@ export default function MarketIndexAccordion({
|
|||||||
const [settingOpen, setSettingOpen] = useState(false);
|
const [settingOpen, setSettingOpen] = useState(false);
|
||||||
const [tickerIndex, setTickerIndex] = useState(0);
|
const [tickerIndex, setTickerIndex] = useState(0);
|
||||||
const rootRef = useRef(null);
|
const rootRef = useRef(null);
|
||||||
|
const hasInitializedSelectedCodes = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current;
|
const el = rootRef.current;
|
||||||
@@ -222,8 +224,9 @@ export default function MarketIndexAccordion({
|
|||||||
};
|
};
|
||||||
}, [onHeightChange, loading, indices.length]);
|
}, [onHeightChange, loading, indices.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadIndices = () => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
fetchMarketIndices()
|
fetchMarketIndices()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
|
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
|
||||||
@@ -234,12 +237,28 @@ export default function MarketIndexAccordion({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初次挂载时加载一次指数
|
||||||
|
const cleanup = loadIndices();
|
||||||
|
return cleanup;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 跟随基金刷新节奏:每次开始刷新时重新拉取指数
|
||||||
|
if (!refreshing) return;
|
||||||
|
const cleanup = loadIndices();
|
||||||
|
return cleanup;
|
||||||
|
}, [refreshing]);
|
||||||
|
|
||||||
// 初始化选中指数(本地偏好 > 默认集合)
|
// 初始化选中指数(本地偏好 > 默认集合)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!indices.length || typeof window === 'undefined') return;
|
if (!indices.length || typeof window === 'undefined') return;
|
||||||
|
if (hasInitializedSelectedCodes.current) return;
|
||||||
try {
|
try {
|
||||||
const stored = window.localStorage.getItem('marketIndexSelected');
|
const stored = window.localStorage.getItem('marketIndexSelected');
|
||||||
const availableCodes = new Set(indices.map((it) => it.code));
|
const availableCodes = new Set(indices.map((it) => it.code));
|
||||||
@@ -249,6 +268,7 @@ export default function MarketIndexAccordion({
|
|||||||
const filtered = parsed.filter((c) => availableCodes.has(c));
|
const filtered = parsed.filter((c) => availableCodes.has(c));
|
||||||
if (filtered.length) {
|
if (filtered.length) {
|
||||||
setSelectedCodes(filtered);
|
setSelectedCodes(filtered);
|
||||||
|
hasInitializedSelectedCodes.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +364,7 @@ export default function MarketIndexAccordion({
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
:global([data-theme='dark'] .market-index-accordion-root) {
|
:global([data-theme='dark'] .market-index-accordion-root) {
|
||||||
background-color: rgba(15, 23, 42, 0.9);
|
background-color: rgba(15, 23, 42);
|
||||||
}
|
}
|
||||||
.market-index-ticker {
|
.market-index-ticker {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function MobileFundCardDrawer({
|
|||||||
{children}
|
{children}
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent
|
<DrawerContent
|
||||||
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
|
className="h-[85vh] max-h-[90vh] mt-0 flex flex-col"
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
if (blockDrawerClose) return;
|
if (blockDrawerClose) return;
|
||||||
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
||||||
|
|||||||
@@ -28,22 +28,27 @@ import FitText from './FitText';
|
|||||||
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
||||||
import MobileSettingModal from './MobileSettingModal';
|
import MobileSettingModal from './MobileSettingModal';
|
||||||
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||||
|
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||||
|
|
||||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'relatedSector',
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
'totalChangePercent',
|
'totalChangePercent',
|
||||||
|
'holdingDays',
|
||||||
'todayProfit',
|
'todayProfit',
|
||||||
'holdingProfit',
|
'holdingProfit',
|
||||||
'latestNav',
|
'latestNav',
|
||||||
'estimateNav',
|
'estimateNav',
|
||||||
];
|
];
|
||||||
const MOBILE_COLUMN_HEADERS = {
|
const MOBILE_COLUMN_HEADERS = {
|
||||||
|
relatedSector: '关联板块',
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
estimateChangePercent: '估值涨幅',
|
estimateChangePercent: '估值涨幅',
|
||||||
totalChangePercent: '估算收益',
|
totalChangePercent: '估算收益',
|
||||||
|
holdingDays: '持有天数',
|
||||||
todayProfit: '当日收益',
|
todayProfit: '当日收益',
|
||||||
holdingProfit: '持有收益',
|
holdingProfit: '持有收益',
|
||||||
};
|
};
|
||||||
@@ -233,6 +238,9 @@ export default function MobileFundTable({
|
|||||||
const defaultVisibility = (() => {
|
const defaultVisibility = (() => {
|
||||||
const o = {};
|
const o = {};
|
||||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||||
|
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||||
|
o.relatedSector = false;
|
||||||
|
o.holdingDays = false;
|
||||||
return o;
|
return o;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -245,7 +253,12 @@ export default function MobileFundTable({
|
|||||||
})();
|
})();
|
||||||
const mobileColumnVisibility = (() => {
|
const mobileColumnVisibility = (() => {
|
||||||
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
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;
|
return defaultVisibility;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -340,9 +353,14 @@ export default function MobileFundTable({
|
|||||||
if (!stickySummaryWrapper) return stickyTop;
|
if (!stickySummaryWrapper) return stickyTop;
|
||||||
|
|
||||||
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
|
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 = () => {
|
const updateVerticalState = () => {
|
||||||
@@ -422,15 +440,60 @@ export default function MobileFundTable({
|
|||||||
const LAST_COLUMN_EXTRA = 12;
|
const LAST_COLUMN_EXTRA = 12;
|
||||||
const FALLBACK_WIDTHS = {
|
const FALLBACK_WIDTHS = {
|
||||||
fundName: 140,
|
fundName: 140,
|
||||||
|
relatedSector: 120,
|
||||||
latestNav: 64,
|
latestNav: 64,
|
||||||
estimateNav: 64,
|
estimateNav: 64,
|
||||||
yesterdayChangePercent: 72,
|
yesterdayChangePercent: 72,
|
||||||
estimateChangePercent: 80,
|
estimateChangePercent: 80,
|
||||||
totalChangePercent: 80,
|
totalChangePercent: 80,
|
||||||
|
holdingDays: 64,
|
||||||
todayProfit: 80,
|
todayProfit: 80,
|
||||||
holdingProfit: 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 columnWidthMap = useMemo(() => {
|
||||||
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||||
const nonNameCount = visibleNonNameIds.length;
|
const nonNameCount = visibleNonNameIds.length;
|
||||||
@@ -456,6 +519,8 @@ export default function MobileFundTable({
|
|||||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
allVisible[id] = true;
|
allVisible[id] = true;
|
||||||
});
|
});
|
||||||
|
allVisible.relatedSector = false;
|
||||||
|
allVisible.holdingDays = false;
|
||||||
setMobileColumnVisibility(allVisible);
|
setMobileColumnVisibility(allVisible);
|
||||||
};
|
};
|
||||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||||
@@ -654,6 +719,22 @@ export default function MobileFundTable({
|
|||||||
),
|
),
|
||||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
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',
|
accessorKey: 'latestNav',
|
||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
@@ -774,6 +855,23 @@ export default function MobileFundTable({
|
|||||||
},
|
},
|
||||||
meta: { align: 'right', cellClassName: 'total-change-cell', width: columnWidthMap.totalChangePercent },
|
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',
|
accessorKey: 'todayProfit',
|
||||||
header: '当日收益',
|
header: '当日收益',
|
||||||
@@ -784,7 +882,6 @@ export default function MobileFundTable({
|
|||||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||||
const percentStr = original.todayProfitPercent ?? '';
|
const percentStr = original.todayProfitPercent ?? '';
|
||||||
const isUpdated = original.isUpdated;
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<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}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</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 }}>
|
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
<FitText maxFontSize={11} minFontSize={9}>
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
{percentStr}
|
{percentStr}
|
||||||
@@ -834,7 +931,7 @@ export default function MobileFundTable({
|
|||||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
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({
|
const table = useReactTable({
|
||||||
@@ -945,7 +1042,7 @@ export default function MobileFundTable({
|
|||||||
|
|
||||||
const getAlignClass = (columnId) => {
|
const getAlignClass = (columnId) => {
|
||||||
if (columnId === 'fundName') return '';
|
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';
|
return 'text-right';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ export default function MobileSettingModal({
|
|||||||
估值涨幅与持有收益的汇总
|
估值涨幅与持有收益的汇总
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.id === 'relatedSector' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
仅 fund.cc.cd 地址支持
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleColumnVisibility && (
|
{onToggleColumnVisibility && (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -34,25 +34,31 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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 = [
|
const NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'relatedSector',
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
'totalChangePercent',
|
'totalChangePercent',
|
||||||
'holdingAmount',
|
'holdingAmount',
|
||||||
|
'holdingDays',
|
||||||
'todayProfit',
|
'todayProfit',
|
||||||
'holdingProfit',
|
'holdingProfit',
|
||||||
'latestNav',
|
'latestNav',
|
||||||
'estimateNav',
|
'estimateNav',
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLUMN_HEADERS = {
|
const COLUMN_HEADERS = {
|
||||||
|
relatedSector: '关联板块',
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
estimateChangePercent: '估值涨幅',
|
estimateChangePercent: '估值涨幅',
|
||||||
totalChangePercent: '估算收益',
|
totalChangePercent: '估算收益',
|
||||||
holdingAmount: '持仓金额',
|
holdingAmount: '持仓金额',
|
||||||
|
holdingDays: '持有天数',
|
||||||
todayProfit: '当日收益',
|
todayProfit: '当日收益',
|
||||||
holdingProfit: '持有收益',
|
holdingProfit: '持有收益',
|
||||||
};
|
};
|
||||||
@@ -282,10 +288,18 @@ export default function PcFundTable({
|
|||||||
})();
|
})();
|
||||||
const columnVisibility = (() => {
|
const columnVisibility = (() => {
|
||||||
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
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 = {};
|
const allVisible = {};
|
||||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||||
return allVisible;
|
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||||
|
allVisible.relatedSector = false;
|
||||||
|
allVisible.holdingDays = false;
|
||||||
|
return allVisible;
|
||||||
})();
|
})();
|
||||||
const columnSizing = (() => {
|
const columnSizing = (() => {
|
||||||
const s = currentGroupPc?.pcTableColumns;
|
const s = currentGroupPc?.pcTableColumns;
|
||||||
@@ -356,6 +370,8 @@ export default function PcFundTable({
|
|||||||
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
allVisible[id] = true;
|
allVisible[id] = true;
|
||||||
});
|
});
|
||||||
|
allVisible.relatedSector = false;
|
||||||
|
allVisible.holdingDays = false;
|
||||||
setColumnVisibility(allVisible);
|
setColumnVisibility(allVisible);
|
||||||
};
|
};
|
||||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||||
@@ -443,6 +459,51 @@ export default function PcFundTable({
|
|||||||
};
|
};
|
||||||
}, [stickyTop]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const tableEl = tableContainerRef.current;
|
const tableEl = tableContainerRef.current;
|
||||||
const portalEl = portalHeaderRef.current;
|
const portalEl = portalHeaderRef.current;
|
||||||
@@ -563,6 +624,27 @@ export default function PcFundTable({
|
|||||||
cellClassName: 'name-cell',
|
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',
|
accessorKey: 'latestNav',
|
||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
@@ -772,6 +854,28 @@ export default function PcFundTable({
|
|||||||
cellClassName: 'holding-amount-cell',
|
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',
|
accessorKey: 'todayProfit',
|
||||||
header: '当日收益',
|
header: '当日收益',
|
||||||
@@ -790,7 +894,7 @@ export default function PcFundTable({
|
|||||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
{percentStr && !isUpdated && !masked ? (
|
{percentStr && !masked ? (
|
||||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
<FitText maxFontSize={11} minFontSize={9}>
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
{percentStr}
|
{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({
|
const table = useReactTable({
|
||||||
@@ -970,19 +1074,22 @@ export default function PcFundTable({
|
|||||||
const isNameColumn =
|
const isNameColumn =
|
||||||
header.column.id === 'fundName' ||
|
header.column.id === 'fundName' ||
|
||||||
header.column.columnDef?.accessorKey === '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={`table-header-cell ${align}`}
|
className={`table-header-cell ${align}`}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
<div style={{ paddingRight: isRightAligned ? '20px' : '0' }}>
|
||||||
? null
|
{header.isPlaceholder
|
||||||
: flexRender(
|
? null
|
||||||
header.column.columnDef.header,
|
: flexRender(
|
||||||
header.getContext(),
|
header.column.columnDef.header,
|
||||||
)}
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!forPortal && (
|
{!forPortal && (
|
||||||
<div
|
<div
|
||||||
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||||
@@ -1001,46 +1108,38 @@ export default function PcFundTable({
|
|||||||
const totalHeaderWidth = headerGroup?.headers?.reduce((acc, h) => acc + h.column.getSize(), 0) ?? 0;
|
const totalHeaderWidth = headerGroup?.headers?.reduce((acc, h) => acc + h.column.getSize(), 0) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pc-fund-table" ref={tableContainerRef}>
|
<>
|
||||||
<style>{`
|
<div className="pc-fund-table" ref={tableContainerRef}>
|
||||||
|
<style>{`
|
||||||
.table-row-scroll {
|
.table-row-scroll {
|
||||||
--row-bg: var(--bg);
|
--row-bg: var(--bg);
|
||||||
background-color: var(--row-bg) !important;
|
background-color: var(--row-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 斑马纹行背景(非 hover 状态) */
|
||||||
.table-row-scroll:nth-child(even),
|
.table-row-scroll:nth-child(even),
|
||||||
.table-row-scroll.row-even {
|
.table-row-scroll.row-even {
|
||||||
background-color: var(--table-row-alt-bg) !important;
|
background-color: var(--table-row-alt-bg) !important;
|
||||||
}
|
}
|
||||||
.table-row-scroll:hover {
|
|
||||||
--row-bg: var(--table-row-hover-bg);
|
|
||||||
background-color: var(--row-bg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pinned cells inherit from parent row */
|
/* Pinned cells 继承所在行的背景(非 hover 状态) */
|
||||||
.table-row-scroll .pinned-cell {
|
.table-row-scroll .pinned-cell {
|
||||||
background-color: var(--row-bg) !important;
|
background-color: var(--row-bg) !important;
|
||||||
}
|
}
|
||||||
.table-row-scroll:nth-child(even) .pinned-cell,
|
.table-row-scroll:nth-child(even) .pinned-cell,
|
||||||
.table-row-scroll.row-even .pinned-cell {
|
.table-row-scroll.row-even .pinned-cell,
|
||||||
background-color: var(--table-row-alt-bg) !important;
|
|
||||||
}
|
|
||||||
.table-row-scroll:hover .pinned-cell {
|
|
||||||
background-color: var(--table-row-hover-bg) !important;
|
|
||||||
}
|
|
||||||
.table-row-scroll:nth-child(even) {
|
|
||||||
background-color: var(--table-row-alt-bg);
|
|
||||||
}
|
|
||||||
.table-row-scroll:hover {
|
|
||||||
--row-bg: var(--table-row-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pinned cells in even rows */
|
|
||||||
.row-even .pinned-cell {
|
.row-even .pinned-cell {
|
||||||
background-color: var(--table-row-alt-bg) !important;
|
background-color: var(--table-row-alt-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pinned cells on hover */
|
/* Hover 状态优先级最高,覆盖斑马纹和 pinned 背景 */
|
||||||
.table-row-scroll:hover .pinned-cell {
|
.table-row-scroll:hover,
|
||||||
|
.table-row-scroll.row-even:hover {
|
||||||
|
--row-bg: var(--table-row-hover-bg);
|
||||||
|
background-color: var(--table-row-hover-bg) !important;
|
||||||
|
}
|
||||||
|
.table-row-scroll:hover .pinned-cell,
|
||||||
|
.table-row-scroll.row-even:hover .pinned-cell {
|
||||||
background-color: var(--table-row-hover-bg) !important;
|
background-color: var(--table-row-hover-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,87 +1207,127 @@ export default function PcFundTable({
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{/* 表头 */}
|
{/* 表头 */}
|
||||||
{renderTableHeader(false)}
|
{renderTableHeader(false)}
|
||||||
|
|
||||||
{/* 表体 */}
|
{/* 表体 */}
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={data.map((item) => item.code)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="popLayout">
|
<SortableContext
|
||||||
{table.getRowModel().rows.map((row, index) => (
|
items={data.map((item) => item.code)}
|
||||||
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
|
strategy={verticalListSortingStrategy}
|
||||||
<div
|
>
|
||||||
className={`table-row table-row-scroll ${index % 2 === 1 ? 'row-even' : ''}`}
|
<AnimatePresence mode="popLayout">
|
||||||
>
|
{table.getRowModel().rows.map((row, index) => (
|
||||||
{row.getVisibleCells().map((cell) => {
|
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
|
||||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
<div
|
||||||
const isNameColumn = columnId === 'fundName';
|
className={`table-row table-row-scroll ${index % 2 === 1 ? 'row-even' : ''}`}
|
||||||
const rightAlignedColumns = new Set([
|
>
|
||||||
'latestNav',
|
{row.getVisibleCells().map((cell) => {
|
||||||
'estimateNav',
|
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||||
'yesterdayChangePercent',
|
const isNameColumn = columnId === 'fundName';
|
||||||
'estimateChangePercent',
|
const align = isNameColumn
|
||||||
'totalChangePercent',
|
? ''
|
||||||
'holdingAmount',
|
: NON_FROZEN_COLUMN_IDS.includes(columnId)
|
||||||
'todayProfit',
|
? 'text-right'
|
||||||
'holdingProfit',
|
: 'text-center';
|
||||||
]);
|
const cellClassName =
|
||||||
const align = isNameColumn
|
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||||
? ''
|
const style = getCommonPinningStyles(cell.column, false);
|
||||||
: rightAlignedColumns.has(columnId)
|
const isPinned = cell.column.getIsPinned();
|
||||||
? 'text-right'
|
return (
|
||||||
: 'text-center';
|
<div
|
||||||
const cellClassName =
|
key={cell.id}
|
||||||
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
className={`table-cell ${align} ${cellClassName} ${isPinned ? 'pinned-cell' : ''}`}
|
||||||
const style = getCommonPinningStyles(cell.column, false);
|
style={style}
|
||||||
const isPinned = cell.column.getIsPinned();
|
>
|
||||||
return (
|
{flexRender(
|
||||||
<div
|
cell.column.columnDef.cell,
|
||||||
key={cell.id}
|
cell.getContext(),
|
||||||
className={`table-cell ${align} ${cellClassName} ${isPinned ? 'pinned-cell' : ''}`}
|
)}
|
||||||
style={style}
|
</div>
|
||||||
>
|
);
|
||||||
{flexRender(
|
})}
|
||||||
cell.column.columnDef.cell,
|
</div>
|
||||||
cell.getContext(),
|
</SortableRow>
|
||||||
)}
|
))}
|
||||||
</div>
|
</AnimatePresence>
|
||||||
);
|
</SortableContext>
|
||||||
})}
|
</DndContext>
|
||||||
</div>
|
|
||||||
</SortableRow>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{table.getRowModel().rows.length === 0 && (
|
{table.getRowModel().rows.length === 0 && (
|
||||||
<div className="table-row empty-row">
|
<div className="table-row empty-row">
|
||||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||||
<span className="muted">暂无数据</span>
|
<span className="muted">暂无数据</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{resetConfirmOpen && (
|
||||||
{resetConfirmOpen && (
|
<ConfirmModal
|
||||||
<ConfirmModal
|
title="重置列宽"
|
||||||
title="重置列宽"
|
message="是否重置表格列宽为默认值?"
|
||||||
message="是否重置表格列宽为默认值?"
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
confirmVariant="primary"
|
||||||
confirmVariant="primary"
|
onConfirm={handleResetSizing}
|
||||||
onConfirm={handleResetSizing}
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
onCancel={() => setResetConfirmOpen(false)}
|
confirmText="重置"
|
||||||
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
|
<PcTableSettingModal
|
||||||
open={settingModalOpen}
|
open={settingModalOpen}
|
||||||
@@ -1205,74 +1344,36 @@ export default function PcFundTable({
|
|||||||
showFullFundName={showFullFundName}
|
showFullFundName={showFullFundName}
|
||||||
onToggleShowFullFundName={handleToggleShowFullFundName}
|
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"
|
|
||||||
>
|
|
||||||
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,6 +213,11 @@ export default function PcTableSettingModal({
|
|||||||
估值涨幅与持有收益的汇总
|
估值涨幅与持有收益的汇总
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.id === 'relatedSector' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
仅 fund.cc.cd 地址支持
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleColumnVisibility && (
|
{onToggleColumnVisibility && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { ResetIcon, SettingsIcon } from './Icons';
|
import { ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
@@ -19,10 +20,14 @@ export default function SettingsModal({
|
|||||||
containerWidth = 1200,
|
containerWidth = 1200,
|
||||||
setContainerWidth,
|
setContainerWidth,
|
||||||
onResetContainerWidth,
|
onResetContainerWidth,
|
||||||
|
showMarketIndexPc = true,
|
||||||
|
showMarketIndexMobile = true,
|
||||||
}) {
|
}) {
|
||||||
const [sliderDragging, setSliderDragging] = useState(false);
|
const [sliderDragging, setSliderDragging] = useState(false);
|
||||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||||
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
||||||
|
const [localShowMarketIndexPc, setLocalShowMarketIndexPc] = useState(showMarketIndexPc);
|
||||||
|
const [localShowMarketIndexMobile, setLocalShowMarketIndexMobile] = useState(showMarketIndexMobile);
|
||||||
const pageWidthTrackRef = useRef(null);
|
const pageWidthTrackRef = useRef(null);
|
||||||
|
|
||||||
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||||
@@ -55,6 +60,14 @@ export default function SettingsModal({
|
|||||||
setLocalSeconds(tempSeconds);
|
setLocalSeconds(tempSeconds);
|
||||||
}, [tempSeconds]);
|
}, [tempSeconds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalShowMarketIndexPc(showMarketIndexPc);
|
||||||
|
}, [showMarketIndexPc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalShowMarketIndexMobile(showMarketIndexMobile);
|
||||||
|
}, [showMarketIndexMobile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@@ -162,6 +175,22 @@ export default function SettingsModal({
|
|||||||
</div>
|
</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="form-group" style={{ marginBottom: 16 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
@@ -188,7 +217,12 @@ export default function SettingsModal({
|
|||||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={(e) => saveSettings(e, localSeconds)}
|
onClick={(e) => saveSettings(
|
||||||
|
e,
|
||||||
|
localSeconds,
|
||||||
|
isMobile ? localShowMarketIndexMobile : localShowMarketIndexPc,
|
||||||
|
isMobile
|
||||||
|
)}
|
||||||
disabled={localSeconds < 30}
|
disabled={localSeconds < 30}
|
||||||
>
|
>
|
||||||
保存并关闭
|
保存并关闭
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
|
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
|
||||||
import ConfirmModal from "./ConfirmModal";
|
import ConfirmModal from "./ConfirmModal";
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export default function SortSettingModal({
|
|||||||
rules = [],
|
rules = [],
|
||||||
onChangeRules,
|
onChangeRules,
|
||||||
onResetRules,
|
onResetRules,
|
||||||
|
sortDisplayMode = "buttons",
|
||||||
|
onChangeSortDisplayMode,
|
||||||
}) {
|
}) {
|
||||||
const [localRules, setLocalRules] = useState(rules);
|
const [localRules, setLocalRules] = useState(rules);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
@@ -120,6 +123,59 @@ export default function SortSettingModal({
|
|||||||
: "pc-table-setting-body"
|
: "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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
|
|||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import { fetchSmartFundNetValue } from '../api/fund';
|
import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
|
||||||
import { DatePicker, NumericInput } from './Common';
|
import { DatePicker, NumericInput } from './Common';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import PendingTradesModal from './PendingTradesModal';
|
import PendingTradesModal from './PendingTradesModal';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -39,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
const [share, setShare] = useState('');
|
const [share, setShare] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [feeRate, setFeeRate] = useState('0');
|
const [feeRate, setFeeRate] = useState('0');
|
||||||
|
const [minBuyAmount, setMinBuyAmount] = useState(0);
|
||||||
|
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
|
||||||
|
const [buyMetaError, setBuyMetaError] = useState(null);
|
||||||
const [date, setDate] = useState(() => {
|
const [date, setDate] = useState(() => {
|
||||||
return formatDate();
|
return formatDate();
|
||||||
});
|
});
|
||||||
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
||||||
const [calcShare, setCalcShare] = useState(null);
|
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(() => {
|
const currentPendingTrades = useMemo(() => {
|
||||||
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
||||||
}, [pendingTrades, fund]);
|
}, [pendingTrades, fund]);
|
||||||
@@ -148,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isValid = isBuy
|
const isValid = isBuy
|
||||||
? (!!amount && !!feeRate && !!date && calcShare !== null)
|
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta)
|
||||||
: (!!share && !!date);
|
: (!!share && !!date);
|
||||||
|
|
||||||
const handleSetShareFraction = (fraction) => {
|
const handleSetShareFraction = (fraction) => {
|
||||||
@@ -372,72 +426,105 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{isBuy ? (
|
{isBuy ? (
|
||||||
<>
|
<>
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
|
||||||
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
</label>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
<NumericInput
|
</label>
|
||||||
value={amount}
|
<div
|
||||||
onChange={setAmount}
|
style={{
|
||||||
step={100}
|
border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)',
|
||||||
min={0}
|
borderRadius: 12
|
||||||
placeholder="请输入加仓金额"
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<NumericInput
|
||||||
</div>
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
step={100}
|
||||||
|
min={0}
|
||||||
|
placeholder="请输入加仓金额"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
<NumericInput
|
<NumericInput
|
||||||
value={feeRate}
|
value={feeRate}
|
||||||
onChange={setFeeRate}
|
onChange={setFeeRate}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="0.12"
|
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>
|
</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 }}>
|
{loadingBuyMeta && (
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<div
|
||||||
交易时段
|
style={{
|
||||||
</label>
|
position: 'absolute',
|
||||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
inset: 0,
|
||||||
<button
|
display: 'flex',
|
||||||
type="button"
|
alignItems: 'center',
|
||||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
justifyContent: 'center',
|
||||||
onClick={() => setIsAfter3pm(false)}
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(0,0,0,0.25)',
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
WebkitBackdropFilter: 'blur(2px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
15:00前
|
<Spinner className="size-5" />
|
||||||
</button>
|
<span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,8 +651,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button"
|
className="button"
|
||||||
disabled={!isValid || loadingPrice}
|
disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
|
||||||
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
|
style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { UpdateIcon } from './Icons';
|
import { UpdateIcon } from './Icons';
|
||||||
|
|
||||||
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
|
export default function UpdatePromptModal({ updateContent, open, onClose, onRefresh }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open={open} onOpenChange={(v) => !v && onClose?.()}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
className="glass card"
|
||||||
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"
|
|
||||||
style={{ maxWidth: '400px' }}
|
style={{ maxWidth: '400px' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
showCloseButton={false}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="更新提示"
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
<DialogHeader>
|
||||||
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
<DialogTitle style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||||
<span>更新提示</span>
|
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
||||||
</div>
|
<span>更新提示</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
||||||
检测到新版本,是否刷新浏览器以更新?
|
检测到新版本,是否刷新浏览器以更新?
|
||||||
@@ -36,7 +29,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
</p>
|
</p>
|
||||||
{updateContent && (
|
{updateContent && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(0,0,0,0.2)',
|
background: 'var(--card)',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
@@ -44,13 +37,14 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
maxHeight: '200px',
|
maxHeight: '200px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
border: '1px solid var(--border)'
|
||||||
}}>
|
}}>
|
||||||
{updateContent}
|
{updateContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
|
||||||
|
<div className="flex-row" style={{ gap: 12, display: 'flex' }}>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -66,7 +60,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
刷新浏览器
|
刷新浏览器
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2093,10 +2093,12 @@ input[type="number"] {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 亮色主题下,PC 右侧抽屉里的 Switch 拇指使用浅色,以保证对比度 */
|
||||||
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
|
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 移动端表格设置底部抽屉 */
|
/* 移动端表格设置底部抽屉 */
|
||||||
.mobile-setting-overlay {
|
.mobile-setting-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2538,6 +2540,13 @@ input[type="number"] {
|
|||||||
transition: left 0.2s;
|
transition: left 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 亮色主题下:所有使用 dca-toggle 的拇指在浅底上统一用白色,保证对比度
|
||||||
|
- PC 右侧排序设置抽屉
|
||||||
|
- 移动端排序个性化设置 Drawer(以及其它区域) */
|
||||||
|
[data-theme="light"] .dca-toggle-thumb {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.dca-option-group {
|
.dca-option-group {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -3339,6 +3348,35 @@ input[type="number"] {
|
|||||||
color: var(--success);
|
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 {
|
.button.secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ function lockBodyScroll() {
|
|||||||
originalBodyPosition = document.body.style.position || "";
|
originalBodyPosition = document.body.style.position || "";
|
||||||
originalBodyTop = document.body.style.top || "";
|
originalBodyTop = document.body.style.top || "";
|
||||||
|
|
||||||
document.body.style.position = "fixed";
|
requestAnimationFrame(() => {
|
||||||
document.body.style.top = `-${lockedScrollY}px`;
|
document.body.style.top = `-${lockedScrollY}px`;
|
||||||
document.body.style.width = "100%";
|
document.body.style.width = "100%";
|
||||||
|
document.body.style.position = "fixed";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +30,15 @@ function unlockBodyScroll() {
|
|||||||
|
|
||||||
// 只有全部弹框都关闭时才恢复滚动位置
|
// 只有全部弹框都关闭时才恢复滚动位置
|
||||||
if (scrollLockCount === 0) {
|
if (scrollLockCount === 0) {
|
||||||
|
const scrollY = lockedScrollY;
|
||||||
|
|
||||||
document.body.style.position = originalBodyPosition;
|
document.body.style.position = originalBodyPosition;
|
||||||
document.body.style.top = originalBodyTop;
|
document.body.style.top = originalBodyTop;
|
||||||
document.body.style.width = "";
|
document.body.style.width = "";
|
||||||
|
|
||||||
// 恢复到锁定前的滚动位置,而不是跳到顶部
|
requestAnimationFrame(() => {
|
||||||
window.scrollTo(0, lockedScrollY);
|
window.scrollTo(0, scrollY);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,4 +62,4 @@ export function useBodyScrollLock(open) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/lib/AGENTS.md
Normal file
28
app/lib/AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# app/lib/ — Core Utilities
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
4 utility modules: Supabase client, request cache, trading calendar, valuation time-series.
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| File | Exports | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `supabase.js` | `supabase`, `isSupabaseConfigured` | Supabase client (or noop fallback). Auth + DB + realtime |
|
||||||
|
| `cacheRequest.js` | `cachedRequest()`, `clearCachedRequest()` | In-memory request dedup + TTL cache |
|
||||||
|
| `tradingCalendar.js` | `loadHolidaysForYear()`, `loadHolidaysForYears()`, `isTradingDay()` | Chinese stock market holiday detection via CDN |
|
||||||
|
| `valuationTimeseries.js` | `recordValuation()`, `getValuationSeries()`, `clearFund()`, `getAllValuationSeries()` | Fund valuation time-series (localStorage) |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- **supabase.js**: creates `createNoopSupabase()` when env vars missing — all auth/DB methods return safe defaults
|
||||||
|
- **cacheRequest.js**: deduplicates concurrent requests for same key; default 10s TTL
|
||||||
|
- **tradingCalendar.js**: downloads `chinese-days` JSON from cdn.jsdelivr.net; caches per-year in Map
|
||||||
|
- **valuationTimeseries.js**: localStorage key `fundValuationTimeseries`; auto-clears old dates on new data
|
||||||
|
|
||||||
|
## ANTI-PATTERNS (THIS DIRECTORY)
|
||||||
|
|
||||||
|
- **No error reporting** — all modules silently fail (console.warn at most)
|
||||||
|
- **localStorage quota not handled** — valuationTimeseries writes without checking available space
|
||||||
|
- **Cache only in-memory** — cacheRequest lost on page reload; no persistent cache
|
||||||
|
- **No request cancellation** — JSONP scripts can't be aborted once injected
|
||||||
@@ -33,6 +33,7 @@ const createNoopSupabase = () => ({
|
|||||||
data: { subscription: { unsubscribe: () => { } } }
|
data: { subscription: { unsubscribe: () => { } } }
|
||||||
}),
|
}),
|
||||||
signInWithOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
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' } }),
|
verifyOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||||
signOut: async () => ({ error: null })
|
signOut: async () => ({ error: null })
|
||||||
},
|
},
|
||||||
|
|||||||
488
app/page.jsx
488
app/page.jsx
@@ -71,6 +71,13 @@ import packageJson from '../package.json';
|
|||||||
import PcFundTable from './components/PcFundTable';
|
import PcFundTable from './components/PcFundTable';
|
||||||
import MobileFundTable from './components/MobileFundTable';
|
import MobileFundTable from './components/MobileFundTable';
|
||||||
import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher';
|
import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -129,6 +136,9 @@ export default function HomePage() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [tempSeconds, setTempSeconds] = useState(60);
|
const [tempSeconds, setTempSeconds] = useState(60);
|
||||||
const [containerWidth, setContainerWidth] = useState(1200);
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
const [showMarketIndexPc, setShowMarketIndexPc] = useState(true);
|
||||||
|
const [showMarketIndexMobile, setShowMarketIndexMobile] = useState(true);
|
||||||
|
const [isGroupSummarySticky, setIsGroupSummarySticky] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -141,6 +151,8 @@ export default function HomePage() {
|
|||||||
if (Number.isFinite(num)) {
|
if (Number.isFinite(num)) {
|
||||||
setContainerWidth(Math.min(2000, Math.max(600, 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 { }
|
} catch { }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -167,15 +179,19 @@ export default function HomePage() {
|
|||||||
{ id: 'default', label: '默认', enabled: true },
|
{ id: 'default', label: '默认', enabled: true },
|
||||||
// 估值涨幅为原始名称,“涨跌幅”为别名
|
// 估值涨幅为原始名称,“涨跌幅”为别名
|
||||||
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
|
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
|
||||||
|
// 昨日涨幅排序:默认隐藏
|
||||||
|
{ id: 'yesterdayIncrease', label: '昨日涨幅', enabled: false },
|
||||||
// 持仓金额排序:默认隐藏
|
// 持仓金额排序:默认隐藏
|
||||||
{ id: 'holdingAmount', label: '持仓金额', enabled: false },
|
{ id: 'holdingAmount', label: '持仓金额', enabled: false },
|
||||||
{ id: 'holding', label: '持有收益', enabled: true },
|
{ id: 'holding', label: '持有收益', enabled: true },
|
||||||
{ id: 'name', label: '基金名称', alias: '名称', 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 [sortOrder, setSortOrder] = useState('desc'); // asc | desc
|
||||||
|
const [sortDisplayMode, setSortDisplayMode] = useState('buttons'); // buttons | dropdown
|
||||||
const [isSortLoaded, setIsSortLoaded] = useState(false);
|
const [isSortLoaded, setIsSortLoaded] = useState(false);
|
||||||
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
|
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
|
||||||
const [sortSettingOpen, setSortSettingOpen] = useState(false);
|
const [sortSettingOpen, setSortSettingOpen] = useState(false);
|
||||||
@@ -197,6 +213,13 @@ export default function HomePage() {
|
|||||||
if (parsed && Array.isArray(parsed.localSortRules)) {
|
if (parsed && Array.isArray(parsed.localSortRules)) {
|
||||||
rulesFromSettings = parsed.localSortRules;
|
rulesFromSettings = parsed.localSortRules;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.localSortDisplayMode === 'string' &&
|
||||||
|
SORT_DISPLAY_MODES.has(parsed.localSortDisplayMode)
|
||||||
|
) {
|
||||||
|
setSortDisplayMode(parsed.localSortDisplayMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -217,12 +240,37 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rulesFromSettings && rulesFromSettings.length) {
|
if (rulesFromSettings && rulesFromSettings.length) {
|
||||||
const merged = DEFAULT_SORT_RULES.map((rule) => {
|
// 1)先按本地存储的顺序还原(包含 alias、enabled 等字段)
|
||||||
const found = rulesFromSettings.find((r) => r.id === rule.id);
|
const defaultMap = new Map(
|
||||||
return found
|
DEFAULT_SORT_RULES.map((rule) => [rule.id, rule])
|
||||||
? { ...rule, enabled: found.enabled !== false }
|
);
|
||||||
: rule;
|
const merged = [];
|
||||||
|
|
||||||
|
// 先遍历本地配置,保持用户自定义的顺序和别名/开关
|
||||||
|
for (const stored of rulesFromSettings) {
|
||||||
|
const base = defaultMap.get(stored.id);
|
||||||
|
if (!base) continue;
|
||||||
|
merged.push({
|
||||||
|
...base,
|
||||||
|
// 只用本地的 enabled / alias 等个性化字段,基础 label 仍以内置为准
|
||||||
|
enabled:
|
||||||
|
typeof stored.enabled === "boolean"
|
||||||
|
? stored.enabled
|
||||||
|
: base.enabled,
|
||||||
|
alias:
|
||||||
|
typeof stored.alias === "string" && stored.alias.trim()
|
||||||
|
? stored.alias.trim()
|
||||||
|
: base.alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再把本次版本新增、但本地还没记录过的规则追加到末尾
|
||||||
|
DEFAULT_SORT_RULES.forEach((rule) => {
|
||||||
|
if (!merged.some((r) => r.id === rule.id)) {
|
||||||
|
merged.push(rule);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSortRules(merged);
|
setSortRules(merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +288,7 @@ export default function HomePage() {
|
|||||||
const next = {
|
const next = {
|
||||||
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
||||||
localSortRules: sortRules,
|
localSortRules: sortRules,
|
||||||
|
localSortDisplayMode: sortDisplayMode,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem('customSettings', JSON.stringify(next));
|
window.localStorage.setItem('customSettings', JSON.stringify(next));
|
||||||
// 更新后标记 customSettings 脏并触发云端同步
|
// 更新后标记 customSettings 脏并触发云端同步
|
||||||
@@ -248,7 +297,7 @@ export default function HomePage() {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sortBy, sortOrder, sortRules, isSortLoaded]);
|
}, [sortBy, sortOrder, sortRules, sortDisplayMode, isSortLoaded]);
|
||||||
|
|
||||||
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
|
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -369,6 +418,7 @@ export default function HomePage() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [groups, currentTab]); // groups 或 tab 变化可能导致 filterBar 高度变化
|
}, [groups, currentTab]); // groups 或 tab 变化可能导致 filterBar 高度变化
|
||||||
|
|
||||||
const handleMobileSearchClick = (e) => {
|
const handleMobileSearchClick = (e) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
@@ -392,6 +442,7 @@ export default function HomePage() {
|
|||||||
const [historyModal, setHistoryModal] = useState({ open: false, fund: null });
|
const [historyModal, setHistoryModal] = useState({ open: false, fund: null });
|
||||||
const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null });
|
const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null });
|
||||||
const [percentModes, setPercentModes] = useState({}); // { [code]: boolean }
|
const [percentModes, setPercentModes] = useState({}); // { [code]: boolean }
|
||||||
|
const [todayPercentModes, setTodayPercentModes] = useState({}); // { [code]: boolean }
|
||||||
|
|
||||||
const holdingsRef = useRef(holdings);
|
const holdingsRef = useRef(holdings);
|
||||||
const pendingTradesRef = useRef(pendingTrades);
|
const pendingTradesRef = useRef(pendingTrades);
|
||||||
@@ -421,6 +472,13 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldShowMarketIndex = isMobile ? showMarketIndexMobile : showMarketIndexPc;
|
||||||
|
|
||||||
|
// 当关闭大盘指数时,重置它的高度,避免 top/stickyTop 仍沿用旧值
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldShowMarketIndex) setMarketIndexAccordionHeight(0);
|
||||||
|
}, [shouldShowMarketIndex]);
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
const [latestVersion, setLatestVersion] = useState('');
|
const [latestVersion, setLatestVersion] = useState('');
|
||||||
@@ -543,26 +601,30 @@ export default function HomePage() {
|
|||||||
|
|
||||||
if (canCalcTodayProfit) {
|
if (canCalcTodayProfit) {
|
||||||
const amount = holding.share * currentNav;
|
const amount = holding.share * currentNav;
|
||||||
// 优先用 zzl (真实涨跌幅), 降级用 gszzl
|
// 优先使用昨日净值直接计算(更精确,避免涨跌幅四舍五入误差)
|
||||||
// 若 gztime 日期 > jzrq,说明估值更新晚于净值日期,优先使用 gszzl 计算当日盈亏
|
const lastNav = fund.lastNav != null && fund.lastNav !== '' ? Number(fund.lastNav) : null;
|
||||||
const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
|
if (lastNav && Number.isFinite(lastNav) && lastNav > 0) {
|
||||||
const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null;
|
profitToday = (currentNav - lastNav) * holding.share;
|
||||||
const preferGszzl =
|
|
||||||
!!gz &&
|
|
||||||
!!jz &&
|
|
||||||
gz.isValid() &&
|
|
||||||
jz.isValid() &&
|
|
||||||
gz.startOf('day').isAfter(jz.startOf('day'));
|
|
||||||
|
|
||||||
let rate;
|
|
||||||
if (preferGszzl) {
|
|
||||||
rate = Number(fund.gszzl);
|
|
||||||
} else {
|
} else {
|
||||||
const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN;
|
const gz = isString(fund.gztime) ? toTz(fund.gztime) : null;
|
||||||
rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl);
|
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 {
|
} else {
|
||||||
profitToday = null;
|
profitToday = null;
|
||||||
}
|
}
|
||||||
@@ -661,6 +723,19 @@ export default function HomePage() {
|
|||||||
const amountB = pb?.amount ?? Number.NEGATIVE_INFINITY;
|
const amountB = pb?.amount ?? Number.NEGATIVE_INFINITY;
|
||||||
return sortOrder === 'asc' ? amountA - amountB : amountB - amountA;
|
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') {
|
if (sortBy === 'holding') {
|
||||||
const pa = getHoldingProfit(a, holdings[a.code]);
|
const pa = getHoldingProfit(a, holdings[a.code]);
|
||||||
const pb = getHoldingProfit(b, holdings[b.code]);
|
const pb = getHoldingProfit(b, holdings[b.code]);
|
||||||
@@ -720,6 +795,9 @@ export default function HomePage() {
|
|||||||
const holdingAmount =
|
const holdingAmount =
|
||||||
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
|
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
|
||||||
const holdingAmountValue = amount;
|
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 profitToday = profit ? profit.profitToday : null;
|
||||||
const todayProfit =
|
const todayProfit =
|
||||||
@@ -795,6 +873,7 @@ export default function HomePage() {
|
|||||||
estimateProfitPercent,
|
estimateProfitPercent,
|
||||||
holdingAmount,
|
holdingAmount,
|
||||||
holdingAmountValue,
|
holdingAmountValue,
|
||||||
|
holdingDaysValue,
|
||||||
todayProfit,
|
todayProfit,
|
||||||
todayProfitPercent,
|
todayProfitPercent,
|
||||||
todayProfitValue,
|
todayProfitValue,
|
||||||
@@ -858,7 +937,21 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const handleClearConfirm = () => {
|
const handleClearConfirm = () => {
|
||||||
if (clearConfirm?.fund) {
|
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);
|
setClearConfirm(null);
|
||||||
};
|
};
|
||||||
@@ -1015,6 +1108,11 @@ export default function HomePage() {
|
|||||||
setPendingTrades(next);
|
setPendingTrades(next);
|
||||||
storageHelper.setItem('pendingTrades', JSON.stringify(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' });
|
setTradeModal({ open: false, fund: null, type: 'buy' });
|
||||||
showToast('净值暂未更新,已加入待处理队列', 'info');
|
showToast('净值暂未更新,已加入待处理队列', 'info');
|
||||||
return;
|
return;
|
||||||
@@ -1624,7 +1722,9 @@ export default function HomePage() {
|
|||||||
if (key === 'funds') {
|
if (key === 'funds') {
|
||||||
const prevSig = getFundCodesSignature(prevValue);
|
const prevSig = getFundCodesSignature(prevValue);
|
||||||
const nextSig = getFundCodesSignature(nextValue);
|
const nextSig = getFundCodesSignature(nextValue);
|
||||||
if (prevSig === nextSig) return;
|
if (prevSig === nextSig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!skipSyncRef.current) {
|
if (!skipSyncRef.current) {
|
||||||
const now = nowInTz().toISOString();
|
const now = nowInTz().toISOString();
|
||||||
@@ -2316,6 +2416,29 @@ export default function HomePage() {
|
|||||||
setLoginLoading(false);
|
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 () => {
|
const handleLogout = async () => {
|
||||||
isLoggingOutRef.current = true;
|
isLoggingOutRef.current = true;
|
||||||
@@ -2724,19 +2847,41 @@ export default function HomePage() {
|
|||||||
await refreshAll(codes);
|
await refreshAll(codes);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = (e, secondsOverride) => {
|
const saveSettings = (e, secondsOverride, showMarketIndexOverride, isMobileOverride) => {
|
||||||
e?.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
const seconds = secondsOverride ?? tempSeconds;
|
const seconds = secondsOverride ?? tempSeconds;
|
||||||
const ms = Math.max(30, Number(seconds)) * 1000;
|
const ms = Math.max(30, Number(seconds)) * 1000;
|
||||||
setTempSeconds(Math.round(ms / 1000));
|
setTempSeconds(Math.round(ms / 1000));
|
||||||
setRefreshMs(ms);
|
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));
|
storageHelper.setItem('refreshMs', String(ms));
|
||||||
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||||
setContainerWidth(w);
|
setContainerWidth(w);
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem('customSettings');
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
const parsed = raw ? JSON.parse(raw) : {};
|
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();
|
triggerCustomSettingsSync();
|
||||||
} catch { }
|
} catch { }
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
@@ -3138,9 +3283,10 @@ export default function HomePage() {
|
|||||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
|
// 一次查询同时拿到 meta 与 data,方便两种模式复用
|
||||||
const { data: meta, error: metaError } = await supabase
|
const { data: meta, error: metaError } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
|
.select('id, data, updated_at')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -3154,44 +3300,19 @@ export default function HomePage() {
|
|||||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 冲突检查模式:使用 meta.data 弹出冲突确认弹窗
|
||||||
if (checkConflict) {
|
if (checkConflict) {
|
||||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
|
// 非冲突检查模式:直接复用上方查询到的 meta 数据,覆盖本地
|
||||||
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
|
if (meta.data && isPlainObject(meta.data) && Object.keys(meta.data).length > 0) {
|
||||||
|
await applyCloudConfig(meta.data, meta.updated_at);
|
||||||
return;
|
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' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取云端配置失败', e);
|
console.error('获取云端配置失败', e);
|
||||||
@@ -3463,7 +3584,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isAnyModalOpen =
|
const isAnyModalOpen =
|
||||||
settingsOpen ||
|
|
||||||
feedbackOpen ||
|
feedbackOpen ||
|
||||||
addResultOpen ||
|
addResultOpen ||
|
||||||
addFundToGroupOpen ||
|
addFundToGroupOpen ||
|
||||||
@@ -3499,7 +3619,6 @@ export default function HomePage() {
|
|||||||
containerRef.current.style.overflow = '';
|
containerRef.current.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
settingsOpen,
|
|
||||||
feedbackOpen,
|
feedbackOpen,
|
||||||
addResultOpen,
|
addResultOpen,
|
||||||
addFundToGroupOpen,
|
addFundToGroupOpen,
|
||||||
@@ -3692,7 +3811,7 @@ export default function HomePage() {
|
|||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="search-dropdown glass"
|
className="search-dropdown glass scrollbar-y-styled"
|
||||||
>
|
>
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
<div className="search-results">
|
<div className="search-results">
|
||||||
@@ -3918,12 +4037,15 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MarketIndexAccordion
|
{shouldShowMarketIndex && (
|
||||||
navbarHeight={navbarHeight}
|
<MarketIndexAccordion
|
||||||
onHeightChange={setMarketIndexAccordionHeight}
|
navbarHeight={navbarHeight}
|
||||||
isMobile={isMobile}
|
onHeightChange={setMarketIndexAccordionHeight}
|
||||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
isMobile={isMobile}
|
||||||
/>
|
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div className="col-12">
|
<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 }}>
|
<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 }}>
|
||||||
@@ -4047,40 +4169,81 @@ export default function HomePage() {
|
|||||||
<span className="muted">排序</span>
|
<span className="muted">排序</span>
|
||||||
<SettingsIcon width="14" height="14" />
|
<SettingsIcon width="14" height="14" />
|
||||||
</button>
|
</button>
|
||||||
<div className="chips">
|
{sortDisplayMode === 'dropdown' ? (
|
||||||
{sortRules.filter((s) => s.enabled).map((s) => (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button
|
<Select
|
||||||
key={s.id}
|
value={sortBy}
|
||||||
className={`chip ${sortBy === s.id ? 'active' : ''}`}
|
onValueChange={(nextSortBy) => {
|
||||||
onClick={() => {
|
setSortBy(nextSortBy);
|
||||||
if (sortBy === s.id) {
|
if (nextSortBy !== sortBy) setSortOrder('desc');
|
||||||
// 同一按钮重复点击,切换升序/降序
|
|
||||||
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>
|
<SelectTrigger
|
||||||
{s.id !== 'default' && sortBy === s.id && (
|
className="h-4 min-w-[110px] py-0 text-xs shadow-none"
|
||||||
<span
|
style={{ background: 'var(--card-bg)', height: 36 }}
|
||||||
style={{
|
>
|
||||||
display: 'inline-flex',
|
<SelectValue placeholder="选择排序规则" />
|
||||||
flexDirection: 'column',
|
</SelectTrigger>
|
||||||
lineHeight: 1,
|
<SelectContent>
|
||||||
fontSize: '8px',
|
{sortRules.filter((s) => s.enabled).map((s) => (
|
||||||
}}
|
<SelectItem key={s.id} value={s.id}>
|
||||||
>
|
{s.alias || s.label}
|
||||||
<span style={{ opacity: sortOrder === 'asc' ? 1 : 0.3 }}>▲</span>
|
</SelectItem>
|
||||||
<span style={{ opacity: sortOrder === 'desc' ? 1 : 0.3 }}>▼</span>
|
))}
|
||||||
</span>
|
</SelectContent>
|
||||||
)}
|
</Select>
|
||||||
</button>
|
<Select
|
||||||
))}
|
value={sortOrder}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4099,49 +4262,14 @@ export default function HomePage() {
|
|||||||
groupName={getGroupName()}
|
groupName={getGroupName()}
|
||||||
getProfit={getHoldingProfit}
|
getProfit={getHoldingProfit}
|
||||||
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||||
|
isSticky={isGroupSummarySticky}
|
||||||
|
onToggleSticky={(next) => setIsGroupSummarySticky(next)}
|
||||||
masked={maskAmounts}
|
masked={maskAmounts}
|
||||||
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
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">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={viewMode}
|
key={viewMode}
|
||||||
@@ -4150,6 +4278,7 @@ export default function HomePage() {
|
|||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
|
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 } : {}}>
|
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
|
||||||
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */}
|
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */}
|
||||||
@@ -4206,7 +4335,9 @@ export default function HomePage() {
|
|||||||
favorites,
|
favorites,
|
||||||
dcaPlans,
|
dcaPlans,
|
||||||
holdings,
|
holdings,
|
||||||
percentModes,
|
percentModes,
|
||||||
|
todayPercentModes,
|
||||||
|
todayPercentModes,
|
||||||
valuationSeries,
|
valuationSeries,
|
||||||
collapsedCodes,
|
collapsedCodes,
|
||||||
collapsedTrends,
|
collapsedTrends,
|
||||||
@@ -4222,6 +4353,8 @@ export default function HomePage() {
|
|||||||
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||||
onPercentModeToggle: (code) =>
|
onPercentModeToggle: (code) =>
|
||||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
|
onTodayPercentModeToggle: (code) =>
|
||||||
|
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
onToggleCollapse: toggleCollapse,
|
onToggleCollapse: toggleCollapse,
|
||||||
onToggleTrendCollapse: toggleTrendCollapse,
|
onToggleTrendCollapse: toggleTrendCollapse,
|
||||||
masked: maskAmounts,
|
masked: maskAmounts,
|
||||||
@@ -4298,6 +4431,8 @@ export default function HomePage() {
|
|||||||
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||||
onPercentModeToggle: (code) =>
|
onPercentModeToggle: (code) =>
|
||||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
|
onTodayPercentModeToggle: (code) =>
|
||||||
|
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
onToggleCollapse: toggleCollapse,
|
onToggleCollapse: toggleCollapse,
|
||||||
onToggleTrendCollapse: toggleTrendCollapse,
|
onToggleTrendCollapse: toggleTrendCollapse,
|
||||||
masked: maskAmounts,
|
masked: maskAmounts,
|
||||||
@@ -4327,6 +4462,7 @@ export default function HomePage() {
|
|||||||
dcaPlans={dcaPlans}
|
dcaPlans={dcaPlans}
|
||||||
holdings={holdings}
|
holdings={holdings}
|
||||||
percentModes={percentModes}
|
percentModes={percentModes}
|
||||||
|
todayPercentModes={todayPercentModes}
|
||||||
valuationSeries={valuationSeries}
|
valuationSeries={valuationSeries}
|
||||||
collapsedCodes={collapsedCodes}
|
collapsedCodes={collapsedCodes}
|
||||||
collapsedTrends={collapsedTrends}
|
collapsedTrends={collapsedTrends}
|
||||||
@@ -4343,6 +4479,9 @@ export default function HomePage() {
|
|||||||
onPercentModeToggle={(code) =>
|
onPercentModeToggle={(code) =>
|
||||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||||
}
|
}
|
||||||
|
onTodayPercentModeToggle={(code) =>
|
||||||
|
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||||
|
}
|
||||||
onToggleCollapse={toggleCollapse}
|
onToggleCollapse={toggleCollapse}
|
||||||
onToggleTrendCollapse={toggleTrendCollapse}
|
onToggleTrendCollapse={toggleTrendCollapse}
|
||||||
masked={maskAmounts}
|
masked={maskAmounts}
|
||||||
@@ -4353,6 +4492,45 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</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>
|
</div>
|
||||||
@@ -4485,6 +4663,7 @@ export default function HomePage() {
|
|||||||
onClose={() => setActionModal({ open: false, fund: null })}
|
onClose={() => setActionModal({ open: false, fund: null })}
|
||||||
onAction={(type) => handleAction(type, actionModal.fund)}
|
onAction={(type) => handleAction(type, actionModal.fund)}
|
||||||
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
||||||
|
pendingCount={pendingTrades.filter(t => t.fundCode === actionModal.fund?.code).length}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -4593,6 +4772,12 @@ export default function HomePage() {
|
|||||||
holding={holdings[holdingModal.fund?.code]}
|
holding={holdings[holdingModal.fund?.code]}
|
||||||
onClose={() => setHoldingModal({ open: false, fund: null })}
|
onClose={() => setHoldingModal({ open: false, fund: null })}
|
||||||
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
|
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>
|
</AnimatePresence>
|
||||||
@@ -4695,19 +4880,18 @@ export default function HomePage() {
|
|||||||
containerWidth={containerWidth}
|
containerWidth={containerWidth}
|
||||||
setContainerWidth={setContainerWidth}
|
setContainerWidth={setContainerWidth}
|
||||||
onResetContainerWidth={handleResetContainerWidth}
|
onResetContainerWidth={handleResetContainerWidth}
|
||||||
|
showMarketIndexPc={showMarketIndexPc}
|
||||||
|
showMarketIndexMobile={showMarketIndexMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新提示弹窗 */}
|
{/* 更新提示弹窗 */}
|
||||||
<AnimatePresence>
|
<UpdatePromptModal
|
||||||
{updateModalOpen && (
|
open={updateModalOpen}
|
||||||
<UpdatePromptModal
|
updateContent={updateContent}
|
||||||
updateContent={updateContent}
|
onClose={() => setUpdateModalOpen(false)}
|
||||||
onClose={() => setUpdateModalOpen(false)}
|
onRefresh={() => window.location.reload()}
|
||||||
onRefresh={() => window.location.reload()}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
@@ -4730,6 +4914,7 @@ export default function HomePage() {
|
|||||||
setLoginSuccess('');
|
setLoginSuccess('');
|
||||||
setLoginEmail('');
|
setLoginEmail('');
|
||||||
setLoginOtp('');
|
setLoginOtp('');
|
||||||
|
setLoginLoading(false);
|
||||||
}}
|
}}
|
||||||
loginEmail={loginEmail}
|
loginEmail={loginEmail}
|
||||||
setLoginEmail={setLoginEmail}
|
setLoginEmail={setLoginEmail}
|
||||||
@@ -4740,6 +4925,7 @@ export default function HomePage() {
|
|||||||
loginSuccess={loginSuccess}
|
loginSuccess={loginSuccess}
|
||||||
handleSendOtp={handleSendOtp}
|
handleSendOtp={handleSendOtp}
|
||||||
handleVerifyEmailOtp={handleVerifyEmailOtp}
|
handleVerifyEmailOtp={handleVerifyEmailOtp}
|
||||||
|
handleGithubLogin={isSupabaseConfigured ? handleGithubLogin : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4750,6 +4936,8 @@ export default function HomePage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
rules={sortRules}
|
rules={sortRules}
|
||||||
onChangeRules={setSortRules}
|
onChangeRules={setSortRules}
|
||||||
|
sortDisplayMode={sortDisplayMode}
|
||||||
|
onChangeSortDisplayMode={setSortDisplayMode}
|
||||||
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
|
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
28
components/ui/AGENTS.md
Normal file
28
components/ui/AGENTS.md
Normal 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/`
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -86,6 +85,8 @@ function DialogContent({
|
|||||||
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
className={cn(
|
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",
|
"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",
|
"mobile-dialog-glass",
|
||||||
|
|||||||
46
components/ui/radio-group.jsx
Normal file
46
components/ui/radio-group.jsx
Normal 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
21
components/ui/spinner.jsx
Normal 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 }
|
||||||
@@ -12,9 +12,10 @@
|
|||||||
|
|
||||||
### 1. funds
|
### 1. funds
|
||||||
|
|
||||||
**类型**: `Array<Object>`
|
**类型**: `Array<Object>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储用户添加的所有基金信息
|
**说明**: 存储用户添加的所有基金信息
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -43,9 +44,10 @@
|
|||||||
|
|
||||||
### 2. favorites
|
### 2. favorites
|
||||||
|
|
||||||
**类型**: `Array<string>`
|
**类型**: `Array<string>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储用户标记为自选的基金代码列表
|
**说明**: 存储用户标记为自选的基金代码列表
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -65,9 +67,10 @@
|
|||||||
|
|
||||||
### 3. groups
|
### 3. groups
|
||||||
|
|
||||||
**类型**: `Array<Object>`
|
**类型**: `Array<Object>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储用户创建的基金分组信息
|
**说明**: 存储用户创建的基金分组信息
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -89,9 +92,10 @@
|
|||||||
|
|
||||||
### 4. collapsedCodes
|
### 4. collapsedCodes
|
||||||
|
|
||||||
**类型**: `Array<string>`
|
**类型**: `Array<string>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储用户收起的基金代码列表(用于折叠基金详情)
|
**说明**: 存储用户收起的基金代码列表(用于折叠基金详情)
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -110,9 +114,10 @@
|
|||||||
|
|
||||||
### 5. collapsedTrends
|
### 5. collapsedTrends
|
||||||
|
|
||||||
**类型**: `Array<string>`
|
**类型**: `Array<string>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储用户收起的业绩走势图表的基金代码列表
|
**说明**: 存储用户收起的业绩走势图表的基金代码列表
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -131,10 +136,11 @@
|
|||||||
|
|
||||||
### 6. viewMode
|
### 6. viewMode
|
||||||
|
|
||||||
**类型**: `string`
|
**类型**: `string`
|
||||||
**默认值**: `'card'`
|
**默认值**: `'card'`
|
||||||
**可选值**: `'card'` | `'list'`
|
**可选值**: `'card'` | `'list'`
|
||||||
**说明**: 存储用户选择的视图模式
|
**说明**: 存储用户选择的视图模式
|
||||||
|
**云端同步**: 否(仅通过 customSettings 同步)
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -150,10 +156,11 @@
|
|||||||
|
|
||||||
### 7. refreshMs
|
### 7. refreshMs
|
||||||
|
|
||||||
**类型**: `number` (字符串存储)
|
**类型**: `number` (字符串存储)
|
||||||
**默认值**: `30000` (30秒)
|
**默认值**: `30000` (30秒)
|
||||||
**最小值**: `5000` (5秒)
|
**最小值**: `5000` (5秒)
|
||||||
**说明**: 存储数据刷新间隔时间(毫秒)
|
**说明**: 存储数据刷新间隔时间(毫秒)
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -169,9 +176,10 @@
|
|||||||
|
|
||||||
### 8. holdings
|
### 8. holdings
|
||||||
|
|
||||||
**类型**: `Object`
|
**类型**: `Object`
|
||||||
**默认值**: `{}`
|
**默认值**: `{}`
|
||||||
**说明**: 存储用户的持仓信息
|
**说明**: 存储用户的持仓信息
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -196,9 +204,10 @@
|
|||||||
|
|
||||||
### 9. pendingTrades
|
### 9. pendingTrades
|
||||||
|
|
||||||
**类型**: `Array<Object>`
|
**类型**: `Array<Object>`
|
||||||
**默认值**: `[]`
|
**默认值**: `[]`
|
||||||
**说明**: 存储待处理的交易记录(当净值未更新时)
|
**说明**: 存储待处理的交易记录(当净值未更新时)
|
||||||
|
**云端同步**: 是
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -215,7 +224,6 @@
|
|||||||
feeValue: number, // 手续费金额
|
feeValue: number, // 手续费金额
|
||||||
date: string, // 交易日期
|
date: string, // 交易日期
|
||||||
isAfter3pm: boolean, // 是否下午3点后
|
isAfter3pm: boolean, // 是否下午3点后
|
||||||
isAfter3pm: boolean, // 是否下午3点后
|
|
||||||
timestamp: number // 时间戳
|
timestamp: number // 时间戳
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -230,9 +238,10 @@
|
|||||||
|
|
||||||
### 10. localUpdatedAt
|
### 10. localUpdatedAt
|
||||||
|
|
||||||
**类型**: `string` (ISO 8601 格式)
|
**类型**: `string` (ISO 8601 格式)
|
||||||
**默认值**: `null`
|
**默认值**: `null`
|
||||||
**说明**: 存储本地数据最后更新时间戳,用于云端同步冲突检测
|
**说明**: 存储本地数据最后更新时间戳,用于云端同步冲突检测
|
||||||
|
**云端同步**: 否(本地专用)
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -245,12 +254,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 11. hasClosedAnnouncement_v7
|
### 11. hasClosedAnnouncement_v19
|
||||||
|
|
||||||
**类型**: `string`
|
**类型**: `string`
|
||||||
**默认值**: `null`
|
**默认值**: `null`
|
||||||
**可选值**: `'true'`
|
**可选值**: `'true'`
|
||||||
**说明**: 标记用户是否已关闭公告弹窗
|
**说明**: 标记用户是否已关闭公告弹窗(版本号后缀用于控制不同版本的公告)
|
||||||
|
**云端同步**: 否
|
||||||
|
|
||||||
**数据结构**:
|
**数据结构**:
|
||||||
```javascript
|
```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
|
- funds
|
||||||
- favorites
|
- favorites
|
||||||
- groups
|
- groups
|
||||||
- collapsedCodes
|
- collapsedCodes
|
||||||
- collapsedTrends
|
- collapsedTrends
|
||||||
- viewMode
|
|
||||||
- refreshMs
|
- refreshMs
|
||||||
- holdings
|
- holdings
|
||||||
- pendingTrades
|
- 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: [],
|
groups: [],
|
||||||
collapsedCodes: [],
|
collapsedCodes: [],
|
||||||
refreshMs: 30000,
|
refreshMs: 30000,
|
||||||
viewMode: 'card',
|
|
||||||
holdings: {},
|
holdings: {},
|
||||||
pendingTrades: [],
|
pendingTrades: [],
|
||||||
|
transactions: {},
|
||||||
|
dcaPlans: {},
|
||||||
|
customSettings: {},
|
||||||
exportedAt: '2024-01-15T10:30:00.000Z'
|
exportedAt: '2024-01-15T10:30:00.000Z'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -334,23 +587,40 @@ const dedupeByCode = (list) => {
|
|||||||
|
|
||||||
1. 清理无效的持仓数据(基金不存在的持仓)
|
1. 清理无效的持仓数据(基金不存在的持仓)
|
||||||
2. 清理无效的自选、分组、收起状态
|
2. 清理无效的自选、分组、收起状态
|
||||||
3. 确保数据类型正确
|
3. 清理无效的交易记录和定投计划
|
||||||
|
4. 确保数据类型正确
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 存储辅助工具
|
## 存储辅助工具
|
||||||
|
|
||||||
项目使用 `storageHelper` 对象来封装 localStorage 操作,提供统一的错误处理和日志记录。
|
项目使用 `storageHelper` 对象来封装 localStorage 操作,提供统一的错误处理和云端同步触发。
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const storageHelper = {
|
const storageHelper = {
|
||||||
setItem: (key, value) => { /* ... */ },
|
setItem: (key, value) => {
|
||||||
getItem: (key) => { /* ... */ },
|
// 1. 写入 localStorage
|
||||||
removeItem: (key) => { /* ... */ },
|
// 2. 触发云端同步(如果是同步键)
|
||||||
clear: () => { /* ... */ }
|
// 3. 更新 localUpdatedAt 时间戳
|
||||||
|
},
|
||||||
|
getItem: (key) => {
|
||||||
|
// 从 localStorage 读取
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
// 从 localStorage 删除
|
||||||
|
// 触发云端同步
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
// 清空所有 localStorage
|
||||||
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**特性**:
|
||||||
|
- 自动触发云端同步(对于参与同步的键)
|
||||||
|
- 自动更新 localUpdatedAt 时间戳
|
||||||
|
- funds 变更时比较签名,避免无意义同步
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
@@ -360,6 +630,7 @@ const storageHelper = {
|
|||||||
3. **错误处理**: 所有 localStorage 操作都应包含 try-catch 错误处理
|
3. **错误处理**: 所有 localStorage 操作都应包含 try-catch 错误处理
|
||||||
4. **数据格式**: 复杂数据必须使用 JSON.stringify/JSON.parse 进行序列化/反序列化
|
4. **数据格式**: 复杂数据必须使用 JSON.stringify/JSON.parse 进行序列化/反序列化
|
||||||
5. **版本控制**: 公告等配置使用版本号后缀,便于控制不同版本的显示
|
5. **版本控制**: 公告等配置使用版本号后缀,便于控制不同版本的显示
|
||||||
|
6. **fundValuationTimeseries**: 该数据不同步到云端,因为数据量较大且属于临时性数据
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -367,10 +638,15 @@ const storageHelper = {
|
|||||||
|
|
||||||
- `app/page.jsx` - 主要页面组件,包含所有 localStorage 操作
|
- `app/page.jsx` - 主要页面组件,包含所有 localStorage 操作
|
||||||
- `app/components/Announcement.jsx` - 公告组件
|
- `app/components/Announcement.jsx` - 公告组件
|
||||||
|
- `app/components/PcFundTable.jsx` - PC端基金表格组件
|
||||||
|
- `app/components/MobileFundTable.jsx` - 移动端基金表格组件
|
||||||
|
- `app/components/MarketIndexAccordion.jsx` - 市场指数组件
|
||||||
- `app/lib/supabase.js` - Supabase 客户端配置
|
- `app/lib/supabase.js` - Supabase 客户端配置
|
||||||
|
- `app/lib/valuationTimeseries.js` - 估值分时数据管理
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
|
- **2026-03-18**: 全面更新文档,补充 transactions、dcaPlans、fundValuationTimeseries、customSettings 等键的详细说明,修正云端同步键列表
|
||||||
- **2026-02-19**: 初始文档创建
|
- **2026-02-19**: 初始文档创建
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 183 KiB |
26
entrypoint.sh
Normal file
26
entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 在启动 Nginx 前,将静态资源中的占位符替换为运行时环境变量
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HTML_ROOT="/usr/share/nginx/html"
|
||||||
|
|
||||||
|
# 转义 sed 替换串中的特殊字符:\ & |
|
||||||
|
escape_sed() {
|
||||||
|
printf '%s' "$1" | sed 's/\\/\\\\/g; s/&/\\&/g; s/|/\\|/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 占位符与环境变量对应(占位符名 = 变量名)
|
||||||
|
replace_var() {
|
||||||
|
placeholder="$1"
|
||||||
|
value=$(escape_sed "${2:-}")
|
||||||
|
find "$HTML_ROOT" -type f \( -name '*.js' -o -name '*.html' \) -exec sed -i "s|${placeholder}|${value}|g" {} \;
|
||||||
|
}
|
||||||
|
|
||||||
|
# URL 构建时使用合法占位,此处替换为运行时环境变量
|
||||||
|
replace_var "https://runtime-replace.supabase.co" "${NEXT_PUBLIC_SUPABASE_URL}"
|
||||||
|
replace_var "__NEXT_PUBLIC_SUPABASE_ANON_KEY__" "${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||||
|
replace_var "__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__" "${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}"
|
||||||
|
replace_var "__NEXT_PUBLIC_GA_ID__" "${NEXT_PUBLIC_GA_ID}"
|
||||||
|
replace_var "__NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL__" "${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}"
|
||||||
|
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri.html $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.6",
|
"version": "0.2.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.6",
|
"version": "0.2.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^9.3.1",
|
"@dicebear/core": "^9.3.1",
|
||||||
@@ -5923,7 +5923,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"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==",
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.6",
|
"version": "0.2.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user