69 Commits

Author SHA1 Message Date
hzm
5b800f7308 feat: 发布 0.2.9 2026-03-22 22:50:18 +08:00
hzm
cdda55bf4a feat: 发送验证码后隐藏 github 登录 2026-03-22 22:37:28 +08:00
hzm
d07146b819 feat: 支持 github 登录 2026-03-22 22:28:45 +08:00
hzm
7beac75160 feat: 改变添加到分组按钮位置 2026-03-22 21:31:30 +08:00
hzm
84a720164c feat: 新增持有天数 2026-03-22 14:52:29 +08:00
hzm
303071f639 feat: 调整资产汇总误差计算方式 2026-03-22 13:56:51 +08:00
hzm
73ce520573 feat: 优化当日收益计算方式 2026-03-22 13:51:21 +08:00
hzm
270bc3ab08 feat: 全局设置新增显示大盘指数开关 2026-03-20 22:17:43 +08:00
hzm
9f6d1bb768 feat: 排序个性化新增排序形式切换 2026-03-20 09:03:51 +08:00
hzm
4f438d0dc5 feat: 排序新增按昨日涨幅排序 2026-03-20 08:20:22 +08:00
hzm
d751daeb74 feat: 优化业绩走势对比线的展示 2026-03-19 22:52:22 +08:00
hzm
0ce7d18585 feat: PC 表头改为居右对齐 2026-03-19 22:29:19 +08:00
hzm
e0f6d61aaa feat: 更新模型 key 2026-03-19 21:14:45 +08:00
hzm
6557371f09 fix: PC 端基金详情弹框滚动问题 2026-03-19 11:27:36 +08:00
hzm
8d7f2d33df feat: 更新项目文档 2026-03-18 22:34:55 +08:00
hzm
82bdecca0b feat: 发布 0.2.8 版本 2026-03-18 20:26:29 +08:00
hzm
cc605fb45b feat: 增加关联板块描述 2026-03-18 20:12:03 +08:00
hzm
e8bd65e499 feat: 设置持仓支持今日首次买入 2026-03-18 20:02:08 +08:00
hzm
12229e8eeb feat: 关联板块字体大小调整 2026-03-17 20:01:49 +08:00
hzm
fb0dc25341 feat: 测试关联板块 2026-03-17 19:49:33 +08:00
hzm
b489677d3e feat: 加仓自动获取费率数据 2026-03-17 15:41:19 +08:00
hzm
104a847d2a fix: 海外基金前10重仓股票数据 2026-03-17 15:22:32 +08:00
hzm
0a97b80499 fix: 修复同步问题 2026-03-17 14:10:17 +08:00
hzm
7c48e94a5d feat: 发布 0.2.7 2026-03-17 08:56:37 +08:00
hzm
02669020bc fix:修复业绩走势折线图展示问题 2026-03-17 08:53:49 +08:00
hzm
ba1687bf97 feat:业绩走势默认值改为近3月 2026-03-16 22:48:05 +08:00
hzm
ac591c54c4 feat:业绩走势对比线数据格式化问题 2026-03-16 21:44:49 +08:00
hzm
26bb966f90 feat: 业绩走势增加对比线 2026-03-16 21:04:04 +08:00
hzm
a7eb537e67 fix: 排序别名存储问题 2026-03-16 19:28:24 +08:00
hzm
5d97f8f83e fix: PC 斑马纹 hover 2026-03-16 13:28:10 +08:00
hzm
e80ee0cad1 Revert "fix: 修复同步方法"
This reverts commit ab9e8a5072.
2026-03-16 13:27:21 +08:00
hzm
139116a0d3 fix: 修复同步问题 2026-03-16 12:32:43 +08:00
hzm
ab9e8a5072 fix: 修复同步方法 2026-03-16 11:37:04 +08:00
hzm
ce559664f1 feat: 大盘指数刷新问题 2026-03-16 10:02:35 +08:00
hzm
d05002fd86 feat: 更新群聊图片 2026-03-16 09:45:04 +08:00
hzm
1a59087cd9 feat: 新增 docker hub 2026-03-16 09:03:14 +08:00
hzm
d8a4db34fe feat: 新增 dockerignore 2026-03-15 22:42:11 +08:00
hzm
37611ddff1 feat: 发布 0.2.6 2026-03-15 21:32:36 +08:00
hzm
3dd11f961d feat: 补充估算收益说明 2026-03-15 21:21:15 +08:00
hzm
510bee53e3 feat: 新增持仓金额排序 2026-03-15 21:18:41 +08:00
hzm
cb87906aa2 fix:修复估值涨幅排序 2026-03-15 21:11:17 +08:00
hzm
2ea3a26353 feat: 新增排序个性化设置 2026-03-15 20:47:43 +08:00
hzm
885a8fc782 feat: 确认导入基金弹框新增添加后展开详情开关 2026-03-15 19:36:03 +08:00
hzm
89f745741b feat: 历史净值 2026-03-15 19:25:00 +08:00
hzm
7296706bb2 feat: PC端指数排序 2026-03-15 11:34:21 +08:00
hzm
c24b6fb069 feat: 移动端指数排序 2026-03-15 10:46:20 +08:00
hzm
bc5ed496aa feat: 新增大盘指数 2026-03-15 00:03:21 +08:00
hzm
c85e0021cd feat: 移动端个性化设置列拖拽问题 2026-03-13 23:14:51 +08:00
hzm
9ac773f0c2 feat: PC端表格斑马纹 2026-03-13 22:44:40 +08:00
hzm
b8f3af4486 feat: 移动端表格斑马纹 2026-03-13 22:30:10 +08:00
hzm
e46ced6360 feat: 添加多个 API Key 并随机选择使用 2026-03-13 21:28:21 +08:00
hzm
26821c2bd1 feat:新增 shadcn skill 2026-03-13 21:08:40 +08:00
hzm
7c332cb89d feat: 发布 0.2.5 2026-03-13 11:01:11 +08:00
hzm
631336097f fix: 设置持仓输入回滚问题 2026-03-13 10:14:33 +08:00
hzm
5981440881 feat:隐藏持仓的时候同时隐藏颜色 2026-03-12 23:01:39 +08:00
hzm
2816a6c0dd feat:移动端连续弹框引起的滚动问题 2026-03-12 22:31:34 +08:00
hzm
e1eb3ea8ca feat:弹框样式调整 2026-03-12 22:22:42 +08:00
hzm
15df89a9dd feat:移动端列去掉年份显示 2026-03-12 22:00:30 +08:00
hzm
8849b547ce fix:解决移动端 Dialog 滚动问题 2026-03-12 21:53:11 +08:00
hzm
7953b906a5 feat: 登录时数据覆盖操作增加二次确认 2026-03-12 20:29:17 +08:00
hzm
d00c8cf3eb feat: 部分估值展示内容去除年份 2026-03-12 20:22:09 +08:00
hzm
966c853eb5 fix: 估算收益未设置持仓金额显示问题 2026-03-12 10:03:21 +08:00
hzm
063be7d08e fix:修复移动端drawer 自动滚动到顶部的行为 2026-03-12 08:45:39 +08:00
hzm
613b5f02e8 feat:分组统计适配小屏场景 2026-03-11 22:08:44 +08:00
hzm
643b23b97c feat:移动端基金详情亮色主题兼容 2026-03-11 21:42:22 +08:00
hzm
32df6fc196 feat:移动端 drawer 背景色调整 2026-03-11 21:09:45 +08:00
hzm
8c55e97d9c Revert "fix: 弹框居中写法调整,增强兼容性"
This reverts commit 5293a32748.
2026-03-11 14:13:09 +08:00
hzm0321
efe61a825a Merge pull request #61 from hzm0321/develop
fix: 弹框居中写法调整,增强兼容性
2026-03-11 14:10:19 +08:00
黄振敏
5293a32748 fix: 弹框居中写法调整,增强兼容性 2026-03-11 11:48:53 +08:00
64 changed files with 7576 additions and 901 deletions

View File

@@ -0,0 +1,240 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json 2>/dev/null || echo '{"error": "No shadcn project found. Run shadcn init first."}'`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (27 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem``SelectGroup`. `DropdownMenuItem``DropdownMenuGroup`. `CommandItem``CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `sonner`.** Use `toast()` from `sonner`.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### CLI
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 25 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components**`npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update**`npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
```
**Named presets:** `base-nova`, `radix-nova`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [customization.md](./customization.md) — Theming, CSS variables, extending components

View File

@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,255 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search <registries...> [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset base-nova` or `--preset radix-nova`
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
## Switching Presets
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.

View File

@@ -0,0 +1,202 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (01), chroma (0 = gray), and hue (0360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest init --preset a2r6bw --force
# Switch to a named preset.
npx shadcn@latest init --preset radix-nova --force
npx shadcn@latest init --reinstall # update existing components to match
# Use a custom theme URL.
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="max-w-md mx-auto">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

View File

@@ -0,0 +1,47 @@
{
"skill_name": "shadcn",
"evals": [
{
"id": 1,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
"files": [],
"expectations": [
"Includes DialogTitle for accessibility (visible or with sr-only class)",
"Avatar component includes AvatarFallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
"Uses asChild for custom triggers (radix preset)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
}
]
}

View File

@@ -0,0 +1,94 @@
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
|--------|------------|
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`

View File

@@ -0,0 +1,306 @@
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```

View File

@@ -0,0 +1,195 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |

View File

@@ -0,0 +1,192 @@
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (27 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 25 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (27 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.

View File

@@ -0,0 +1,101 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide``lucide-react`, `tabler``@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```

View File

@@ -0,0 +1,162 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants**`variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens**`bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4``flex flex-col gap-4`. `space-x-2``flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.

23
.dockerignore Normal file
View 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
View File

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

View File

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

View File

@@ -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秒并提供手动刷新按钮。
## 🛠 技术栈 ## 🛠 技术栈
@@ -93,7 +102,7 @@
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。 在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
6. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。 6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
更多 Supabase 相关内容查阅官方文档。 更多 Supabase 相关内容查阅官方文档。
@@ -111,18 +120,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 .
# 或依赖同目录 .envdocker 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 +149,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
View File

@@ -0,0 +1,38 @@
# app/api/ — Data Fetching Layer
## OVERVIEW
Single file (`fund.js`, ~954 lines) containing ALL external data fetching for the entire application. Pure client-side: JSONP + script tag injection to bypass CORS.
## WHERE TO LOOK
| Function | Purpose |
|----------|---------|
| `fetchFundData(code)` | Main fund data (valuation + NAV + holdings). Uses 天天基金 JSONP |
| `fetchFundDataFallback(code)` | Backup data source when primary fails |
| `fetchSmartFundNetValue(code, date)` | Smart NAV lookup with date fallback |
| `searchFunds(val)` | Fund search by name/code (东方财富) |
| `fetchFundHistory(code, range)` | Historical NAV data via pingzhongdata |
| `fetchFundPingzhongdata(code)` | Raw eastmoney pingzhongdata (trend, grand total) |
| `fetchMarketIndices()` | 24 A-share/HK/US indices via 腾讯财经 |
| `fetchShanghaiIndexDate()` | Shanghai index date for trading day check |
| `parseFundTextWithLLM(text)` | OCR text → fund codes via LLM (apis.iflow.cn) |
| `loadScript(url)` | JSONP helper — creates script tag, waits for global var |
| `fetchRelatedSectors(code)` | Fund sector/track info (unused in main UI) |
## CONVENTIONS
- **JSONP pattern**: `loadScript(url)` → sets global callback → script.onload → reads `window.XXX` → cleanup
- **All functions return Promises** — async/await throughout
- **Cached via `cachedRequest()`** from `app/lib/cacheRequest.js`
- **Error handling**: try/catch returning null/empty — never throws to UI
- **Market indices**: `MARKET_INDEX_KEYS` array defines 24 indices with `code`, `varKey`, `name`
- **Stock code normalization**: `normalizeTencentCode()` handles A-share (6-digit), HK (5-digit), US (letter codes)
## ANTI-PATTERNS (THIS DIRECTORY)
- **Hardcoded API keys** (lines 911-914) — plaintext LLM service keys in source
- **Empty catch blocks** — several `catch (e) {}` silently swallowing errors
- **Global window pollution** — JSONP callbacks assigned to `window.jsonpgz`, `window.SuggestData_*`, etc.
- **No retry logic** — failed requests return null, no exponential backoff
- **Script cleanup race conditions** — scripts removed from DOM after onload/onerror, but timeout may trigger after removal

View File

@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
const nowInTz = () => dayjs().tz(TZ); const 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({
@@ -513,6 +622,91 @@ export const fetchShanghaiIndexDate = async () => {
}); });
}; };
/** 大盘指数项name, code, price, change, changePercent
* 同时用于:
* - qt.gtimg.cn 实时快照code 用于 q= 参数varKey 为全局变量名)
* - 分时 mini 图code 传给 minute/query当不支持分时时会自动回退占位折线
*
* 参照产品图:覆盖主要 A 股宽基 + 创业/科创 + 部分海外与港股指数。
*/
const MARKET_INDEX_KEYS = [
// 行 1上证 / 深证
{ code: 'sh000001', varKey: 'v_sh000001', name: '上证指数' },
{ code: 'sh000016', varKey: 'v_sh000016', name: '上证50' },
{ code: 'sz399001', varKey: 'v_sz399001', name: '深证成指' },
{ code: 'sz399330', varKey: 'v_sz399330', name: '深证100' },
// 行 2北证 / 沪深300 / 创业板
{ code: 'bj899050', varKey: 'v_bj899050', name: '北证50' },
{ code: 'sh000300', varKey: 'v_sh000300', name: '沪深300' },
{ code: 'sz399006', varKey: 'v_sz399006', name: '创业板指' },
{ code: 'sz399102', varKey: 'v_sz399102', name: '创业板综' },
// 行 3创业板 50 / 科创
{ code: 'sz399673', varKey: 'v_sz399673', name: '创业板50' },
{ code: 'sh000688', varKey: 'v_sh000688', name: '科创50' },
{ code: 'sz399005', varKey: 'v_sz399005', name: '中小100' },
// 行 4中证系列
{ code: 'sh000905', varKey: 'v_sh000905', name: '中证500' },
{ code: 'sh000906', varKey: 'v_sh000906', name: '中证800' },
{ code: 'sh000852', varKey: 'v_sh000852', name: '中证1000' },
{ code: 'sh000903', varKey: 'v_sh000903', name: '中证A100' },
// 行 5等权 / 国证 / 纳指
{ code: 'sh000932', varKey: 'v_sh000932', name: '500等权' },
{ code: 'sz399303', varKey: 'v_sz399303', name: '国证2000' },
{ code: 'usIXIC', varKey: 'v_usIXIC', name: '纳斯达克' },
{ code: 'usNDX', varKey: 'v_usNDX', name: '纳斯达克100' },
// 行 6美股三大 + 恒生
{ code: 'usINX', varKey: 'v_usINX', name: '标普500' },
{ code: 'usDJI', varKey: 'v_usDJI', name: '道琼斯' },
{ code: 'hkHSI', varKey: 'v_hkHSI', name: '恒生指数' },
{ code: 'hkHSTECH', varKey: 'v_hkHSTECH', name: '恒生科技指数' },
];
function parseIndexRaw(data) {
if (!data || typeof data !== 'string') return null;
const parts = data.split('~');
if (parts.length < 33) return null;
const name = parts[1] || '';
const price = parseFloat(parts[3], 10);
const change = parseFloat(parts[31], 10);
const changePercent = parseFloat(parts[32], 10);
if (Number.isNaN(price)) return null;
return {
name,
price: Number.isFinite(price) ? price : 0,
change: Number.isFinite(change) ? change : 0,
changePercent: Number.isFinite(changePercent) ? changePercent : 0,
};
}
export const fetchMarketIndices = async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') return [];
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const codes = MARKET_INDEX_KEYS.map((item) => item.code).join(',');
script.src = `https://qt.gtimg.cn/q=${codes}&_t=${Date.now()}`;
script.onload = () => {
const list = MARKET_INDEX_KEYS.map(({ name: defaultName, varKey }) => {
const raw = window[varKey];
const parsed = parseIndexRaw(raw);
if (!parsed) return { name: defaultName, code: '', price: 0, change: 0, changePercent: 0 };
return { ...parsed, code: varKey.replace('v_', '') };
});
if (document.body.contains(script)) document.body.removeChild(script);
resolve(list);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
reject(new Error('指数数据加载失败'));
};
document.body.appendChild(script);
});
};
export const fetchLatestRelease = async () => { export const fetchLatestRelease = async () => {
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL; const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
if (!url) return null; if (!url) return null;
@@ -591,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('无浏览器环境'));
@@ -685,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;
} }
@@ -711,8 +944,20 @@ export const fetchFundHistory = async (code, range = '1m') => {
return []; return [];
}; };
const API_KEYS = [
'sk-25b8a4a3d88a49e82e87c981d9d8f6b4',
'sk-1565f822d5bd745b6529cfdf28b55574'
// 添加更多 API Key 到这里
];
// 随机从数组中选择一个 API Key
const getRandomApiKey = () => {
if (!API_KEYS.length) return null;
return API_KEYS[Math.floor(Math.random() * API_KEYS.length)];
};
export const parseFundTextWithLLM = async (text) => { export const parseFundTextWithLLM = async (text) => {
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c'; const apiKey = getRandomApiKey();
if (!apiKey || !text) return null; if (!apiKey || !text) return null;
try { try {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 182 KiB

56
app/components/AGENTS.md Normal file
View File

@@ -0,0 +1,56 @@
# app/components/ — UI Components
## OVERVIEW
47 app-specific React components (all client-side). Modals dominate (~26). Core display: FundCard, PcFundTable, MobileFundTable.
## STRUCTURE
```
app/components/
├── Core Display (6)
│ ├── FundCard.jsx # Individual fund card (valuation + holdings)
│ ├── PcFundTable.jsx # Desktop table layout
│ ├── MobileFundTable.jsx # Mobile list with swipe actions
│ ├── MobileFundCardDrawer.jsx# Mobile fund detail drawer
│ ├── GroupSummary.jsx # Group portfolio summary
│ └── MarketIndexAccordion.jsx# Market indices (24 A/HK/US)
├── Modals (26)
│ ├── Fund ops: AddFundToGroupModal, GroupManageModal, GroupModal, AddResultModal
│ ├── Trading: TradeModal, HoldingEditModal, HoldingActionModal, TransactionHistoryModal, PendingTradesModal, DcaModal, AddHistoryModal
│ ├── Settings: SettingsModal, MarketSettingModal, MobileSettingModal, PcTableSettingModal, SortSettingModal
│ ├── Auth: LoginModal, CloudConfigModal
│ ├── Scan: ScanPickModal, ScanProgressModal, ScanImportConfirmModal, ScanImportProgressModal
│ └── Misc: ConfirmModal, SuccessModal, DonateModal, FeedbackModal, WeChatModal, UpdatePromptModal, FundHistoryNetValueModal
├── Charts (3)
│ ├── FundIntradayChart.jsx # Intraday valuation chart (localStorage data)
│ ├── FundTrendChart.jsx # Fund trend chart (pingzhongdata)
│ └── FundHistoryNetValue.jsx # Historical NAV display
└── Utilities (7)
├── Icons.jsx # Custom SVG icons (Close, Eye, Moon, Sun, etc.)
├── Common.jsx # Shared UI helpers
├── FitText.jsx # Auto-fit text sizing
├── RefreshButton.jsx # Manual refresh control
├── EmptyStateCard.jsx # Empty state placeholder
├── Announcement.jsx # Banner announcement
├── ThemeColorSync.jsx # Theme meta tag sync
├── PwaRegister.jsx # Service worker registration
└── AnalyticsGate.jsx # Conditional GA loader
```
## CONVENTIONS
- **All client components** — `'use client'` at top, no server components
- **State from parent** — page.jsx manages ALL state; components receive props only
- **shadcn/ui primitives** — imported from `@/components/ui/*`
- **Mobile/Desktop switching** — parent passes `isMobile` prop; 640px breakpoint
- **Modals**: use `useBodyScrollLock(open)` hook for scroll prevention
- **Icons**: mix of custom SVG (Icons.jsx) + lucide-react
- **Styling**: glassmorphism via CSS variables (globals.css), no component-level CSS
## ANTI-PATTERNS (THIS DIRECTORY)
- **No prop drilling avoidance** — all state flows from page.jsx via props (30+ prop holes in FundCard)
- **Modal sprawl** — 26 modals could benefit from a modal manager/context
- **Swipe gesture duplication** — MobileFundTable and MobileFundCardDrawer both implement swipe logic
- **No loading skeletons** — components show spinners, not skeleton placeholders

View File

@@ -1,6 +1,7 @@
'use client'; '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">

View File

@@ -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_v15'; 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,15 @@ 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.4 版本更新内容如下</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>如有建议和问题欢迎进用户支持群反馈</p>
<p>2. 因交易日用户人数过多为控制服务器免费额度上限暂时减少数据自动同步频率新增手动同步按钮</p>
<p>如有建议欢迎进用户支持群反馈</p>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

View File

@@ -1,10 +1,53 @@
'use client'; 'use client';
import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import ConfirmModal from './ConfirmModal';
import { CloseIcon, CloudIcon } from './Icons'; import { CloseIcon, CloudIcon } from './Icons';
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) { export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
const [pendingAction, setPendingAction] = useState(null); // 'local' | 'cloud' | null
const isConflict = type === 'conflict'; const isConflict = type === 'conflict';
const handlePrimaryClick = () => {
if (isConflict) {
setPendingAction('local');
} else {
onConfirm?.();
}
};
const handleSecondaryClick = () => {
if (isConflict) {
setPendingAction('cloud');
} else {
onCancel?.();
}
};
const handleConfirmModalCancel = () => {
setPendingAction(null);
};
const handleConfirmModalConfirm = () => {
if (pendingAction === 'local') {
onConfirm?.();
} else if (pendingAction === 'cloud') {
onCancel?.();
}
setPendingAction(null);
};
const confirmTitle =
pendingAction === 'local'
? '确认使用本地配置覆盖云端?'
: '确认使用云端配置覆盖本地?';
const confirmMessage =
pendingAction === 'local'
? '此操作会将当前本地配置同步到云端,覆盖云端原有配置,且可能无法恢复,请谨慎操作。'
: '此操作会使用云端配置覆盖当前本地配置,导致本地修改丢失,且可能无法恢复,请谨慎操作。';
return ( return (
<motion.div <motion.div
className="modal-overlay" className="modal-overlay"
@@ -41,14 +84,25 @@ export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }
: '是否将本地配置同步到云端?'} : '是否将本地配置同步到云端?'}
</p> </p>
<div className="row" style={{ flexDirection: 'column', gap: 12 }}> <div className="row" style={{ flexDirection: 'column', gap: 12 }}>
<button className="button" onClick={onConfirm}> <button className="button secondary" onClick={handlePrimaryClick}>
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'} {isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
</button> </button>
<button className="button secondary" onClick={onCancel}> <button className="button" onClick={handleSecondaryClick}>
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'} {isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
</button> </button>
</div> </div>
</motion.div> </motion.div>
{pendingAction && (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
onConfirm={handleConfirmModalConfirm}
onCancel={handleConfirmModalCancel}
confirmText="确认覆盖"
icon={<CloudIcon width="20" height="20" />}
confirmVariant="danger"
/>
)}
</motion.div> </motion.div>
); );
} }

View File

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

View File

@@ -34,6 +34,17 @@ const getBrowserTimeZone = () => {
const TZ = getBrowserTimeZone(); const TZ = getBrowserTimeZone();
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ)); const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
const formatDisplayDate = (value) => {
if (!value) return '-';
const d = toTz(value);
if (!d.isValid()) return value;
const hasTime = /[T\s]\d{2}:\d{2}/.test(String(value));
return hasTime ? d.format('MM-DD HH:mm') : d.format('MM-DD');
};
export default function FundCard({ export default function FundCard({
fund: f, fund: f,
todayStr, todayStr,
@@ -70,7 +81,7 @@ export default function FundCard({
boxShadow: 'none', boxShadow: 'none',
paddingLeft: 0, paddingLeft: 0,
paddingRight: 0, paddingRight: 0,
background: 'transparent', background: theme === 'light' ? 'rgb(250,250,250)' : 'none',
} : {}; } : {};
return ( return (
@@ -91,6 +102,7 @@ export default function FundCard({
e.stopPropagation(); e.stopPropagation();
onRemoveFromGroup?.(f.code); onRemoveFromGroup?.(f.code);
}} }}
style={{backgroundColor: 'transparent'}}
title="从当前分组移除" title="从当前分组移除"
> >
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} /> <ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
@@ -125,7 +137,11 @@ export default function FundCard({
<div className="actions"> <div className="actions">
<div className="badge-v"> <div className="badge-v">
<span>{f.noValuation ? '净值日期' : '估值时间'}</span> <span>{f.noValuation ? '净值日期' : '估值时间'}</span>
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong> <strong>
{f.noValuation
? formatDisplayDate(f.jzrq)
: formatDisplayDate(f.gztime || f.time)}
</strong>
</div> </div>
<div className="row" style={{ gap: 4 }}> <div className="row" style={{ gap: 4 }}>
<button <button
@@ -251,6 +267,20 @@ export default function FundCard({
{masked ? '******' : `¥${profit.amount.toFixed(2)}`} {masked ? '******' : `¥${profit.amount.toFixed(2)}`}
</span> </span>
</div> </div>
{holding?.firstPurchaseDate && !masked && (() => {
const today = dayjs.tz(todayStr, TZ);
const purchaseDate = dayjs.tz(holding.firstPurchaseDate, TZ);
if (!purchaseDate.isValid()) return null;
const days = today.diff(purchaseDate, 'day');
return (
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">持有天数</span>
<span className="value">
{days}
</span>
</div>
);
})()}
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}> <div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">当日收益</span> <span className="label">当日收益</span>
<span <span
@@ -366,6 +396,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}>
@@ -393,7 +432,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
/> />
@@ -464,7 +504,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}
/> />
</> </>

View File

@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { fetchFundHistory } from '../api/fund';
import { cachedRequest } from '../lib/cacheRequest';
import FundHistoryNetValueModal from './FundHistoryNetValueModal';
/**
* 历史净值表格行:日期、净值、日涨幅(按日期降序,涨红跌绿)
*/
function buildRows(history) {
if (!Array.isArray(history) || history.length === 0) return [];
const reversed = [...history].reverse();
return reversed.map((item, i) => {
const prev = reversed[i + 1];
let dailyChange = null;
if (prev && Number.isFinite(item.value) && Number.isFinite(prev.value) && prev.value !== 0) {
dailyChange = ((item.value - prev.value) / prev.value) * 100;
}
return {
date: item.date,
netValue: item.value,
dailyChange,
};
});
}
const columns = [
{
accessorKey: 'date',
header: '日期',
cell: (info) => info.getValue(),
meta: { align: 'left' },
},
{
accessorKey: 'netValue',
header: '净值',
cell: (info) => {
const v = info.getValue();
return v != null && Number.isFinite(v) ? Number(v).toFixed(4) : '—';
},
meta: { align: 'center' },
},
{
accessorKey: 'dailyChange',
header: '日涨幅',
cell: (info) => {
const v = info.getValue();
if (v == null || !Number.isFinite(v)) return '—';
const sign = v > 0 ? '+' : '';
const cls = v > 0 ? 'up' : v < 0 ? 'down' : '';
return <span className={cls}>{sign}{v.toFixed(2)}%</span>;
},
meta: { align: 'right' },
},
];
export default function FundHistoryNetValue({ code, range = '1m', theme }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
useEffect(() => {
if (!code) {
setData([]);
setLoading(false);
return;
}
let active = true;
setLoading(true);
setError(null);
const cacheKey = `fund_history_${code}_${range}`;
cachedRequest(() => fetchFundHistory(code, range), cacheKey, { cacheTime: 10 * 60 * 1000 })
.then((res) => {
if (active) {
setData(buildRows(res || []));
setLoading(false);
}
})
.catch((err) => {
if (active) {
setError(err);
setData([]);
setLoading(false);
}
});
return () => { active = false; };
}, [code, range]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const visibleRows = table.getRowModel().rows.slice(0, 5);
if (!code) return null;
if (loading) {
return (
<div className="fund-history-net-value" style={{ padding: '12px 0' }}>
<span className="muted" style={{ fontSize: '13px' }}>加载历史净值...</span>
</div>
);
}
if (error || data.length === 0) {
return (
<div className="fund-history-net-value" style={{ padding: '12px 0' }}>
<span className="muted" style={{ fontSize: '13px' }}>
{error ? '加载失败' : '暂无历史净值'}
</span>
</div>
);
}
return (
<div className="fund-history-net-value">
<div
className="fund-history-table-wrapper"
style={{
marginTop: 8,
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
overflow: 'hidden',
background: 'var(--card)',
}}
>
<table
className="fund-history-table"
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '13px',
color: 'var(--text)',
}}
>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr
key={hg.id}
style={{
borderBottom: '1px solid var(--border)',
background: 'var(--table-row-alt-bg)',
}}
>
{hg.headers.map((h) => (
<th
key={h.id}
style={{
padding: '8px 12px',
fontWeight: 600,
color: 'var(--muted)',
textAlign: h.column.columnDef.meta?.align || 'left',
}}
>
{flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{visibleRows.map((row) => (
<tr
key={row.id}
style={{
borderBottom: '1px solid var(--border)',
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{
padding: '8px 12px',
color: 'var(--text)',
textAlign: cell.column.columnDef.meta?.align || 'left',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
<button
type="button"
className="muted"
style={{
fontSize: 12,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
}}
onClick={() => setModalOpen(true)}
>
加载更多历史净值
</button>
</div>
{modalOpen && (
<FundHistoryNetValueModal
open={modalOpen}
onOpenChange={setModalOpen}
code={code}
theme={theme}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { fetchFundHistory } from '../api/fund';
import { cachedRequest } from '../lib/cacheRequest';
import { CloseIcon } from './Icons';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
function buildRows(history) {
if (!Array.isArray(history) || history.length === 0) return [];
const reversed = [...history].reverse();
return reversed.map((item, i) => {
const prev = reversed[i + 1];
let dailyChange = null;
if (prev && Number.isFinite(item.value) && Number.isFinite(prev.value) && prev.value !== 0) {
dailyChange = ((item.value - prev.value) / prev.value) * 100;
}
return {
date: item.date,
netValue: item.value,
dailyChange,
};
});
}
const columns = [
{
accessorKey: 'date',
header: '日期',
cell: (info) => info.getValue(),
meta: { align: 'left' },
},
{
accessorKey: 'netValue',
header: '净值',
cell: (info) => {
const v = info.getValue();
return v != null && Number.isFinite(v) ? Number(v).toFixed(4) : '—';
},
meta: { align: 'center' },
},
{
accessorKey: 'dailyChange',
header: '日涨幅',
cell: (info) => {
const v = info.getValue();
if (v == null || !Number.isFinite(v)) return '—';
const sign = v > 0 ? '+' : '';
const cls = v > 0 ? 'up' : v < 0 ? 'down' : '';
return <span className={cls}>{sign}{v.toFixed(2)}%</span>;
},
meta: { align: 'right' },
},
];
export default function FundHistoryNetValueModal({ open, onOpenChange, code, theme }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [visibleCount, setVisibleCount] = useState(30);
const [isMobile, setIsMobile] = useState(false);
const scrollRef = useRef(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
useEffect(() => {
if (!open || !code) return;
let active = true;
setLoading(true);
setError(null);
setVisibleCount(30);
const cacheKey = `fund_history_${code}_all_modal`;
cachedRequest(() => fetchFundHistory(code, 'all'), cacheKey, { cacheTime: 10 * 60 * 1000 })
.then((res) => {
if (!active) return;
setData(buildRows(res || []));
setLoading(false);
})
.catch((err) => {
if (!active) return;
setError(err);
setData([]);
setLoading(false);
});
return () => {
active = false;
};
}, [open, code]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const rows = table.getRowModel().rows.slice(0, visibleCount);
const hasMore = table.getRowModel().rows.length > visibleCount;
const handleOpenChange = (next) => {
if (!next) {
onOpenChange?.(false);
}
};
const handleScroll = (e) => {
const target = e.currentTarget;
if (!target || !hasMore) return;
const distance = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distance < 40) {
setVisibleCount((prev) => {
const next = prev + 30;
const total = table.getRowModel().rows.length;
return next > total ? total : next;
});
}
};
const header = (
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>历史净值</span>
</div>
<button
type="button"
className="icon-button"
onClick={() => onOpenChange?.(false)}
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
);
const body = (
<div
ref={scrollRef}
style={{
maxHeight: '60vh',
overflowY: 'auto',
paddingRight: 4,
}}
onScroll={handleScroll}
>
{loading && (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<span className="muted" style={{ fontSize: 12 }}>加载历史净值...</span>
</div>
)}
{!loading && (error || data.length === 0) && (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<span className="muted" style={{ fontSize: 12 }}>
{error ? '加载失败' : '暂无历史净值'}
</span>
</div>
)}
{!loading && data.length > 0 && (
<div
className="fund-history-table-wrapper"
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
overflow: 'hidden',
background: 'var(--card)',
}}
>
<table
className="fund-history-table"
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '13px',
color: 'var(--text)',
}}
>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr
key={hg.id}
style={{
borderBottom: '1px solid var(--border)',
background: 'var(--table-row-alt-bg)',
boxShadow: '0 1px 0 0 var(--border)',
}}
>
{hg.headers.map((h) => (
<th
key={h.id}
style={{
padding: '8px 12px',
fontWeight: 600,
color: 'var(--muted)',
textAlign: h.column.columnDef.meta?.align || 'left',
background: 'var(--table-row-alt-bg)',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
{flexRender(h.column.columnDef.header, h.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
style={{
borderBottom: '1px solid var(--border)',
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{
padding: '8px 12px',
color: 'var(--text)',
textAlign: cell.column.columnDef.meta?.align || 'left',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{!loading && hasMore && (
<div style={{ padding: '12px 0', textAlign: 'center' }}>
<span className="muted" style={{ fontSize: 12 }}>向下滚动以加载更多...</span>
</div>
)}
</div>
);
if (!open) return null;
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange} direction="bottom">
<DrawerContent
className="glass"
defaultHeight="70vh"
minHeight="40vh"
maxHeight="90vh"
>
<DrawerHeader className="flex flex-row items-center justify-between gap-2 py-3">
<DrawerTitle className="flex items-center gap-2.5 text-left">
<span>历史净值</span>
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{
borderColor: 'transparent',
backgroundColor: 'transparent',
}}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div className="flex-1 px-4 pb-4">
{body}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="glass card modal"
overlayClassName="modal-overlay"
overlayStyle={{ zIndex: 9998 }}
style={{
maxWidth: '520px',
width: '90vw',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
zIndex: 9999,
}}
>
<DialogTitle className="sr-only">历史净值</DialogTitle>
{header}
{body}
</DialogContent>
</Dialog>
);
}

View File

@@ -16,7 +16,8 @@ import {
Filler Filler
} from 'chart.js'; } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import {cachedRequest} from "../lib/cacheRequest"; import { cachedRequest } from '../lib/cacheRequest';
import FundHistoryNetValue from './FundHistoryNetValue';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -55,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]);
@@ -118,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]));
@@ -142,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) => {
@@ -164,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,
@@ -180,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,
@@ -192,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);
@@ -264,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]);
@@ -300,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);
} }
}, },
@@ -373,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;
@@ -424,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();
@@ -490,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)' }}>
@@ -522,6 +785,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
</button> </button>
))} ))}
</div> </div>
<FundHistoryNetValue code={code} range={range} theme={theme} />
</> </>
); );

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion'; import { AnimatePresence, Reorder } from 'framer-motion';
import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons'; import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
@@ -56,129 +57,124 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
const isAllValid = items.every(it => it.name.trim() !== ''); const isAllValid = items.every(it => it.name.trim() !== '');
return ( return (
<motion.div <>
className="modal-overlay" <Dialog
role="dialog" open
aria-modal="true" onOpenChange={(open) => {
aria-label="管理分组" if (!open) onClose();
onClick={onClose} }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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: '500px', width: '90vw' }}
onClick={(e) => e.stopPropagation()}
> >
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}> <DialogContent
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> className="glass card modal"
<SettingsIcon width="20" height="20" /> overlayClassName="modal-overlay"
<span>管理分组</span> style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
</div> onOpenAutoFocus={(event) => event.preventDefault()}
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}> >
<CloseIcon width="20" height="20" /> <DialogTitle asChild>
</button> <div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
</div> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}> <span>管理分组</span>
{items.length === 0 ? ( </div>
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
<p>暂无自定义分组</p>
</div> </div>
) : ( </DialogTitle>
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
<AnimatePresence mode="popLayout"> <div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
{items.map((item) => ( {items.length === 0 ? (
<Reorder.Item <div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
key={item.id} <div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
value={item} <p>暂无自定义分组</p>
className="group-manage-item glass" </div>
layout ) : (
initial={{ opacity: 0, scale: 0.98 }} <Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
animate={{ opacity: 1, scale: 1 }} <AnimatePresence mode="popLayout">
exit={{ opacity: 0, scale: 0.98 }} {items.map((item) => (
transition={{ <Reorder.Item
type: 'spring', key={item.id}
stiffness: 500, value={item}
damping: 35, className="group-manage-item glass"
mass: 1, layout
layout: { duration: 0.2 } initial={{ opacity: 0, scale: 0.98 }}
}} animate={{ opacity: 1, scale: 1 }}
> exit={{ opacity: 0, scale: 0.98 }}
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}> transition={{
<DragIcon width="18" height="18" className="muted" /> type: 'spring',
</div> stiffness: 500,
<input damping: 35,
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`} mass: 1,
value={item.name} layout: { duration: 0.2 }
onChange={(e) => handleRename(item.id, e.target.value)}
placeholder="请输入分组名称..."
style={{
flex: 1,
height: '36px',
background: 'rgba(0,0,0,0.2)',
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
}} }}
/>
<button
className="icon-button danger"
onClick={() => handleDeleteClick(item.id, item.name)}
title="删除分组"
style={{ width: '36px', height: '36px', flexShrink: 0 }}
> >
<TrashIcon width="16" height="16" /> <div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
</button> <DragIcon width="18" height="18" className="muted" />
</Reorder.Item> </div>
))} <input
</AnimatePresence> className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
</Reorder.Group> value={item.name}
)} onChange={(e) => handleRename(item.id, e.target.value)}
<button placeholder="请输入分组名称..."
className="add-group-row-btn" style={{
onClick={handleAddRow} flex: 1,
style={{ height: '36px',
width: '100%', background: 'rgba(0,0,0,0.2)',
marginTop: 12, border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
padding: '10px', }}
borderRadius: '12px', />
border: '1px dashed var(--border)', <button
background: 'rgba(255,255,255,0.02)', className="icon-button danger"
color: 'var(--muted)', onClick={() => handleDeleteClick(item.id, item.name)}
fontSize: '14px', title="删除分组"
display: 'flex', style={{ width: '36px', height: '36px', flexShrink: 0 }}
alignItems: 'center', >
justifyContent: 'center', <TrashIcon width="16" height="16" />
gap: '8px', </button>
cursor: 'pointer', </Reorder.Item>
transition: 'all 0.2s ease' ))}
}} </AnimatePresence>
> </Reorder.Group>
<PlusIcon width="16" height="16" /> )}
<span>新增分组</span> <button
</button> className="add-group-row-btn"
</div> onClick={handleAddRow}
style={{
width: '100%',
marginTop: 12,
padding: '10px',
borderRadius: '12px',
border: '1px dashed var(--border)',
background: 'rgba(255,255,255,0.02)',
color: 'var(--muted)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<PlusIcon width="16" height="16" />
<span>新增分组</span>
</button>
</div>
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>
{!isAllValid && ( {!isAllValid && (
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}> <div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
所有分组名称均不能为空 所有分组名称均不能为空
</div> </div>
)} )}
<button <button
className="button" className="button"
onClick={handleConfirm} onClick={handleConfirm}
disabled={!isAllValid} disabled={!isAllValid}
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }} style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
> >
完成 完成
</button> </button>
</div> </div>
</motion.div> </DialogContent>
</Dialog>
<AnimatePresence> <AnimatePresence>
{deleteConfirm && ( {deleteConfirm && (
@@ -190,6 +186,6 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
/> />
)} )}
</AnimatePresence> </AnimatePresence>
</motion.div> </>
); );
} }

View File

@@ -20,7 +20,6 @@ export default function GroupModal({ onClose, onConfirm }) {
<DialogContent <DialogContent
overlayClassName="modal-overlay z-[9999]" overlayClassName="modal-overlay z-[9999]"
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')} className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
showCloseButton={false}
> >
<div className="glass card modal !max-w-[280px] !w-full"> <div className="glass card modal !max-w-[280px] !w-full">
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
@@ -30,16 +29,6 @@ export default function GroupModal({ onClose, onConfirm }) {
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span> <span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
</DialogTitle> </DialogTitle>
</div> </div>
<DialogClose asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--secondary)] transition-colors duration-200 cursor-pointer"
aria-label="关闭"
>
<CloseIcon className="w-5 h-5" />
</Button>
</DialogClose>
</div> </div>
<Field className="mb-5"> <Field className="mb-5">

View File

@@ -56,12 +56,15 @@ 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 [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);
@@ -76,6 +79,25 @@ export default function GroupSummary({
} }
}, []); }, []);
// 根据窗口宽度设置基础字号,保证小屏数字不会撑破布局
useEffect(() => {
if (!winW) return;
if (winW <= 360) {
setAssetSize(18);
setMetricSize(14);
} else if (winW <= 414) {
setAssetSize(22);
setMetricSize(16);
} else if (winW <= 768) {
setAssetSize(24);
setMetricSize(18);
} else {
setAssetSize(26);
setMetricSize(20);
}
}, [winW]);
useEffect(() => { useEffect(() => {
if (typeof masked === 'boolean') { if (typeof masked === 'boolean') {
setIsMasked(masked); setIsMasked(masked);
@@ -96,9 +118,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) {
@@ -110,11 +133,14 @@ 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;
return { return {
totalAsset, totalAsset,
totalProfitToday, totalProfitToday: roundedTotalProfitToday,
totalHoldingReturn, totalHoldingReturn,
hasHolding, hasHolding,
returnRate, returnRate,
@@ -142,12 +168,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"
@@ -160,7 +196,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,
@@ -225,6 +263,7 @@ export default function GroupSummary({
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span> <span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
{isMasked ? ( {isMasked ? (
<span <span
className="mask-text"
style={{ fontSize: assetSize, position: 'relative', top: 4 }} style={{ fontSize: assetSize, position: 'relative', top: 4 }}
> >
****** ******
@@ -259,7 +298,9 @@ export default function GroupSummary({
}} }}
> >
{isMasked ? ( {isMasked ? (
<span style={{ fontSize: metricSize }}>******</span> <span className="mask-text" style={{ fontSize: metricSize }}>
******
</span>
) : summary.hasAnyTodayData ? ( ) : summary.hasAnyTodayData ? (
<> <>
<span style={{ marginRight: 1 }}> <span style={{ marginRight: 1 }}>
@@ -312,7 +353,9 @@ export default function GroupSummary({
title="点击切换金额/百分比" title="点击切换金额/百分比"
> >
{isMasked ? ( {isMasked ? (
<span style={{ fontSize: metricSize }}>******</span> <span className="mask-text" style={{ fontSize: metricSize }}>
******
</span>
) : ( ) : (
<> <>
<span style={{ marginRight: 1 }}> <span style={{ marginRight: 1 }}>

View File

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

View File

@@ -1,22 +1,45 @@
'use client'; 'use client';
import { useEffect, 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);
useEffect(() => {
dwjzRef.current = dwjz;
}, [dwjz]);
const [share, setShare] = useState(''); const [share, setShare] = useState('');
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(() => {
if (!holding) return '';
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}|${holding.firstPurchaseDate ?? ''}`;
}, [holding]);
useEffect(() => { useEffect(() => {
if (holding) { if (holding) {
@@ -24,15 +47,25 @@ 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 (dwjz > 0) { if (holding.firstPurchaseDate) {
const a = s * dwjz; const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(holding.firstPurchaseDate, TZ), 'day');
const p = (dwjz - c) * s; setHoldingDaysInput(days > 0 ? String(days) : '');
} else {
setHoldingDaysInput('');
}
const price = dwjzRef.current;
if (price > 0) {
const a = s * price;
const p = (price - c) * s;
setAmount(a.toFixed(2)); setAmount(a.toFixed(2));
setProfit(p.toFixed(2)); setProfit(p.toFixed(2));
} }
} }
}, [holding, fund, dwjz]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [holdingSig]);
const handleModeChange = (newMode) => { const handleModeChange = (newMode) => {
if (newMode === mode) return; if (newMode === mode) return;
@@ -62,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();
@@ -82,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();
}; };
@@ -112,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" />
@@ -226,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

View File

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

View File

@@ -0,0 +1,505 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { fetchMarketIndices } from '@/app/api/fund';
import { ChevronRightIcon } from 'lucide-react';
import { SettingsIcon } from './Icons';
import { cn } from '@/lib/utils';
import MarketSettingModal from './MarketSettingModal';
/** 简单伪随机,用于稳定迷你图形状 */
function seeded(seed) {
return () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
}
/** 迷你走势:优先展示当日分时数据,失败时退回占位折线 */
function MiniTrendLine({ changePercent, code, className }) {
const isDown = changePercent <= 0;
const width = 80;
const height = 28;
const pad = 3;
const innerH = height - 2 * pad;
const innerW = width - 2 * pad;
// 占位伪走势(无真实历史数据)
const fallbackPath = useMemo(() => {
const points = 12;
const rnd = seeded(Math.abs(Math.floor(changePercent * 100)) + 1);
const arr = Array.from({ length: points }, (_, i) => {
const t = i / (points - 1);
const x = pad + t * innerW;
const y = isDown
? pad + innerH * (1 - t * 0.6) - (rnd() * 4 - 2)
: pad + innerH * (0.4 + t * 0.6) + (rnd() * 4 - 2);
return [x, Math.max(pad, Math.min(height - pad, y))];
});
return arr.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
}, [changePercent, isDown, innerH, innerW, pad, height]);
// 当日分时真实走势 path
const [realPath, setRealPath] = useState(null);
useEffect(() => {
if (!code || typeof window === 'undefined' || typeof document === 'undefined') {
setRealPath(null);
return;
}
let cancelled = false;
const varName = `min_data_${code}`;
const url = `https://web.ifzq.gtimg.cn/appstock/app/minute/query?_var=${varName}&code=${code}&_=${Date.now()}`;
const script = document.createElement('script');
script.src = url;
script.async = true;
const cleanup = () => {
if (document.body && document.body.contains(script)) {
document.body.removeChild(script);
}
try {
if (window[varName]) {
delete window[varName];
}
} catch (e) {
// ignore
}
};
script.onload = () => {
if (cancelled) {
cleanup();
return;
}
try {
const raw = window[varName];
const series =
raw &&
raw.data &&
raw.data[code] &&
raw.data[code].data &&
Array.isArray(raw.data[code].data.data)
? raw.data[code].data.data
: null;
if (!series || !series.length) {
setRealPath(null);
return;
}
// 解析 "HHMM price volume amount" 行,只关心 price
const points = series
.map((row) => {
const parts = String(row).split(' ');
const price = parseFloat(parts[1]);
if (!Number.isFinite(price)) return null;
return { price };
})
.filter(Boolean);
if (!points.length) {
setRealPath(null);
return;
}
const minP = points.reduce((m, p) => (p.price < m ? p.price : m), points[0].price);
const maxP = points.reduce((m, p) => (p.price > m ? p.price : m), points[0].price);
const span = maxP - minP || 1;
const n = points.length;
const pathPoints = points.map((p, idx) => {
const t = n > 1 ? idx / (n - 1) : 0;
const x = pad + t * innerW;
const norm = (p.price - minP) / span;
const y = pad + (1 - norm) * innerH;
return [x, Math.max(pad, Math.min(height - pad, y))];
});
const d = pathPoints
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`)
.join(' ');
setRealPath(d);
} finally {
cleanup();
}
};
script.onerror = () => {
if (!cancelled) {
setRealPath(null);
}
cleanup();
};
document.body.appendChild(script);
return () => {
cancelled = true;
cleanup();
};
}, [code, height, innerH, innerW, pad]);
const d = realPath || fallbackPath;
return (
<svg
width={width}
height={height}
className={cn('overflow-visible', className)}
aria-hidden
>
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={isDown ? 'text-[var(--success)]' : 'text-[var(--danger)]'}
/>
</svg>
);
}
function IndexCard({ item }) {
const isUp = item.change >= 0;
const colorClass = isUp ? 'text-[var(--danger)]' : 'text-[var(--success)]';
return (
<div
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-1.5 flex flex-col gap-0.5 w-full"
>
<div className="text-xs font-medium text-[var(--foreground)] truncate">{item.name}</div>
<div className={cn('text-sm font-semibold tabular-nums', colorClass)}>
{item.price.toFixed(2)}
</div>
<div className={cn('text-xs tabular-nums', colorClass)}>
{(item.change >= 0 ? '+' : '') + item.change.toFixed(2)}{' '}
{(item.changePercent >= 0 ? '+' : '') + item.changePercent.toFixed(2)}%
</div>
<div className="mt-0.5 flex items-center justify-center opacity-80">
<MiniTrendLine changePercent={item.changePercent} code={item.code} />
</div>
</div>
);
}
// 默认展示:上证指数、深证成指、创业板指
const DEFAULT_SELECTED_CODES = ['sh000001', 'sz399001', 'sz399006'];
export default function MarketIndexAccordion({
navbarHeight = 0,
onHeightChange,
isMobile,
onCustomSettingsChange,
refreshing = false,
}) {
const [indices, setIndices] = useState([]);
const [loading, setLoading] = useState(true);
const [openValue, setOpenValue] = useState('');
const [selectedCodes, setSelectedCodes] = useState([]);
const [settingOpen, setSettingOpen] = useState(false);
const [tickerIndex, setTickerIndex] = useState(0);
const rootRef = useRef(null);
const hasInitializedSelectedCodes = useRef(false);
useEffect(() => {
const el = rootRef.current;
if (!el || typeof onHeightChange !== 'function') return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) onHeightChange(entry.contentRect.height);
});
ro.observe(el);
onHeightChange(el.getBoundingClientRect().height);
return () => {
ro.disconnect();
onHeightChange(0);
};
}, [onHeightChange, loading, indices.length]);
const loadIndices = () => {
let cancelled = false;
setLoading(true);
fetchMarketIndices()
.then((data) => {
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
})
.catch(() => {
if (!cancelled) setIndices([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
};
useEffect(() => {
// 初次挂载时加载一次指数
const cleanup = loadIndices();
return cleanup;
}, []);
useEffect(() => {
// 跟随基金刷新节奏:每次开始刷新时重新拉取指数
if (!refreshing) return;
const cleanup = loadIndices();
return cleanup;
}, [refreshing]);
// 初始化选中指数(本地偏好 > 默认集合)
useEffect(() => {
if (!indices.length || typeof window === 'undefined') return;
if (hasInitializedSelectedCodes.current) return;
try {
const stored = window.localStorage.getItem('marketIndexSelected');
const availableCodes = new Set(indices.map((it) => it.code));
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const filtered = parsed.filter((c) => availableCodes.has(c));
if (filtered.length) {
setSelectedCodes(filtered);
hasInitializedSelectedCodes.current = true;
return;
}
}
}
const defaults = DEFAULT_SELECTED_CODES.filter((c) => availableCodes.has(c));
setSelectedCodes(defaults.length ? defaults : indices.map((it) => it.code).slice(0, 3));
} catch {
setSelectedCodes(indices.map((it) => it.code).slice(0, 3));
}
}, [indices]);
// 持久化用户选择
useEffect(() => {
if (typeof window === 'undefined') return;
if (!selectedCodes.length) return;
try {
// 本地首选 key独立存储便于快速读取
window.localStorage.setItem('marketIndexSelected', JSON.stringify(selectedCodes));
// 同步到 customSettings便于云端同步
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const next = parsed && typeof parsed === 'object' ? { ...parsed, marketIndexSelected: selectedCodes } : { marketIndexSelected: selectedCodes };
window.localStorage.setItem('customSettings', JSON.stringify(next));
onCustomSettingsChange?.();
} catch {
// ignore
}
}, [selectedCodes]);
// 用户已选择的指数列表(按 selectedCodes 顺序)
const visibleIndices = selectedCodes.length
? selectedCodes
.map((code) => indices.find((it) => it.code === code))
.filter(Boolean)
: indices;
// 重置 tickerIndex 确保索引合法
useEffect(() => {
if (tickerIndex >= visibleIndices.length) {
setTickerIndex(0);
}
}, [visibleIndices.length, tickerIndex]);
// 收起状态下轮播展示指数
useEffect(() => {
if (!visibleIndices.length) return;
if (openValue === 'indices') return;
if (visibleIndices.length <= 1) return;
const timer = setInterval(() => {
setTickerIndex((prev) => (prev + 1) % visibleIndices.length);
}, 4000);
return () => clearInterval(timer);
}, [visibleIndices.length, openValue]);
const current =
visibleIndices.length === 0
? null
: visibleIndices[openValue === 'indices' ? 0 : tickerIndex];
const isUp = current ? current.change >= 0 : false;
const colorClass = isUp ? 'text-[var(--danger)]' : 'text-[var(--success)]';
const topMargin = Number(navbarHeight) || 0;
const stickyStyle = {
marginTop: topMargin,
position: 'sticky',
top: topMargin,
zIndex: 10,
width: isMobile ? 'calc(100% + 24px)' : '100%',
marginLeft: isMobile ? -12 : 0,
};
if (loading && indices.length === 0) {
return (
<div
ref={rootRef}
className="market-index-accordion-root mt-2 mb-2 rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-3 flex items-center justify-between"
style={stickyStyle}
>
<span className="text-sm text-[var(--muted-foreground)]">加载大盘指数</span>
</div>
);
}
return (
<div
ref={rootRef}
className="market-index-accordion-root mt-2 mb-2 rounded-lg border border-[var(--border)] bg-[var(--card)] market-index-accordion"
style={stickyStyle}
>
<style jsx>{`
.market-index-accordion :global([data-slot="accordion-trigger"] > svg:last-of-type) {
display: none;
}
:global([data-theme='dark'] .market-index-accordion-root) {
background-color: rgba(15, 23, 42);
}
.market-index-ticker {
overflow: hidden;
}
.market-index-ticker-item {
display: inline-flex;
align-items: center;
gap: 0.75rem;
animation: market-index-ticker-slide 0.35s ease-out;
}
@keyframes market-index-ticker-slide {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
`}</style>
<Accordion
type="single"
collapsible
value={openValue}
onValueChange={setOpenValue}
>
<AccordionItem value="indices" className="border-b-0">
<AccordionTrigger
className="py-3 px-4 hover:no-underline hover:bg-[var(--card)] [&[data-state=open]>svg]:rotate-90"
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<div className="flex flex-1 items-center gap-3 min-w-0">
{current ? (
<div className="market-index-ticker">
<div
key={current.code || current.name}
className="market-index-ticker-item"
>
<span className="text-sm font-medium text-[var(--foreground)] shrink-0">
{current.name}
</span>
<span className={cn('tabular-nums font-medium', colorClass)}>
{current.price.toFixed(2)}
</span>
<span className={cn('tabular-nums text-sm', colorClass)}>
{(current.change >= 0 ? '+' : '') + current.change.toFixed(2)}
</span>
<span className={cn('tabular-nums text-sm', colorClass)}>
{(current.changePercent >= 0 ? '+' : '') + current.changePercent.toFixed(2)}%
</span>
</div>
</div>
) : (
<span className="text-sm text-[var(--muted-foreground)]">暂无指数数据</span>
)}
</div>
<div className="flex items-center gap-4 shrink-0 pl-3">
<div
role="button"
tabIndex={openValue === 'indices' ? 0 : -1}
className="icon-button"
style={{
border: 'none',
width: '28px',
height: '28px',
minWidth: '28px',
backgroundColor: 'transparent',
color: 'var(--text)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: openValue === 'indices' ? 1 : 0,
pointerEvents: openValue === 'indices' ? 'auto' : 'none',
transition: 'opacity 0.2s ease',
}}
onClick={(e) => {
e.stopPropagation();
setSettingOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setSettingOpen(true);
}
}}
aria-label="指数个性化设置"
>
<SettingsIcon width="18" height="18" />
</div>
<ChevronRightIcon
className={cn(
'w-4 h-4 text-[var(--muted-foreground)] transition-transform',
openValue === 'indices' ? 'rotate-90' : ''
)}
aria-hidden="true"
/>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-4 pt-0">
<div
className="flex flex-wrap w-full min-w-0"
style={{ gap: 12 }}
>
{visibleIndices.map((item, i) => (
<div
key={item.code || i}
style={{
flex: isMobile
? '0 0 calc((100% - 24px) / 3)'
: '0 0 calc((100% - 48px) / 5)',
}}
>
<IndexCard item={item} />
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<MarketSettingModal
open={settingOpen}
onClose={() => setSettingOpen(false)}
isMobile={isMobile}
indices={indices}
selectedCodes={selectedCodes}
onChangeSelected={setSelectedCodes}
onResetDefault={() => {
const availableCodes = new Set(indices.map((it) => it.code));
const defaults = DEFAULT_SELECTED_CODES.filter((c) => availableCodes.has(c));
setSelectedCodes(defaults.length ? defaults : indices.map((it) => it.code).slice(0, 3));
}}
/>
</div>
);
}

View File

@@ -0,0 +1,474 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import {
SortableContext,
rectSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerClose,
} from "@/components/ui/drawer";
import { CloseIcon, MinusIcon, ResetIcon, SettingsIcon } from "./Icons";
import ConfirmModal from "./ConfirmModal";
import { cn } from "@/lib/utils";
function SortableIndexItem({ item, canRemove, onRemove, isMobile }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.code });
const style = {
transform: CSS.Transform.toString(transform),
transition,
cursor: isDragging ? "grabbing" : "grab",
flex: isMobile
? "0 0 calc((100% - 24px) / 3)"
: "0 0 calc((100% - 48px) / 5)",
touchAction: "none",
...(isDragging && {
position: "relative",
zIndex: 10,
opacity: 0.9,
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
}),
};
const isUp = item.change >= 0;
const color = isUp ? "var(--danger)" : "var(--success)";
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"glass card",
"relative flex flex-col gap-1.5 rounded-xl border border-[var(--border)] bg-[var(--card)] px-3 py-2"
)}
{...attributes}
{...listeners}
>
{canRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(item.code);
}}
className="icon-button"
style={{
position: "absolute",
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: "999px",
backgroundColor: "rgba(255,96,96,0.1)",
color: "var(--danger)",
border: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
aria-label={`移除 ${item.name}`}
>
<MinusIcon width="10" height="10" />
</button>
)}
<div
style={{
fontSize: 13,
fontWeight: 500,
paddingRight: 18,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.name}
</div>
<div style={{ fontSize: 18, fontWeight: 600, color }}>
{item.price?.toFixed ? item.price.toFixed(2) : String(item.price ?? "-")}
</div>
<div style={{ fontSize: 12, color }}>
{(item.change >= 0 ? "+" : "") + item.change.toFixed(2)}{" "}
{(item.changePercent >= 0 ? "+" : "") + item.changePercent.toFixed(2)}%
</div>
</div>
);
}
/**
* 指数个性化设置弹框
*
* - 移动端:使用 Drawer自底向上抽屉
* - PC 端:使用 Dialog居中弹窗
*
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调
* @param {boolean} props.isMobile - 是否为移动端(由上层传入)
* @param {Array<{code:string,name:string,price:number,change:number,changePercent:number}>} props.indices - 当前可用的大盘指数列表
* @param {string[]} props.selectedCodes - 已选中的指数 code决定展示顺序
* @param {(codes: string[]) => void} props.onChangeSelected - 更新选中指数集合
* @param {() => void} props.onResetDefault - 恢复默认选中集合
*/
export default function MarketSettingModal({
open,
onClose,
isMobile,
indices = [],
selectedCodes = [],
onChangeSelected,
onResetDefault,
}) {
const selectedList = useMemo(() => {
if (!indices?.length || !selectedCodes?.length) return [];
const map = new Map(indices.map((it) => [it.code, it]));
return selectedCodes
.map((code) => map.get(code))
.filter(Boolean);
}, [indices, selectedCodes]);
const allIndices = indices || [];
const selectedSet = useMemo(
() => new Set(selectedCodes || []),
[selectedCodes]
);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
useEffect(() => {
if (!open) setResetConfirmOpen(false);
}, [open]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
const handleToggleCode = (code) => {
if (!code) return;
if (selectedSet.has(code)) {
// 至少保留一个指数,阻止把最后一个也移除
if (selectedCodes.length <= 1) return;
const next = selectedCodes.filter((c) => c !== code);
onChangeSelected?.(next);
} else {
const next = [...selectedCodes, code];
onChangeSelected?.(next);
}
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = selectedCodes.indexOf(active.id);
const newIndex = selectedCodes.indexOf(over.id);
if (oldIndex === -1 || newIndex === -1) return;
const next = arrayMove(selectedCodes, oldIndex, newIndex);
onChangeSelected?.(next);
};
const body = (
<div className="flex flex-col gap-4 px-4 pb-4 pt-2 text-[var(--text)]">
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 8,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>已添加指数</div>
<div
className="muted"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
拖动下方指数即可排序
</div>
</div>
</div>
{selectedList.length === 0 ? (
<div
className="muted"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
padding: "12px 0 4px",
}}
>
暂未添加指数请在下方选择想要关注的指数
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToParentElement]}
>
<SortableContext
items={selectedCodes}
strategy={rectSortingStrategy}
>
<div className="flex flex-wrap gap-3">
{selectedList.map((item) => (
<SortableIndexItem
key={item.code}
item={item}
canRemove={selectedCodes.length > 1}
onRemove={handleToggleCode}
isMobile={isMobile}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
marginBottom: 10,
}}
>
<div
className="muted"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
点击即可选指数
</div>
{onResetDefault && (
<button
type="button"
className="icon-button"
onClick={() => setResetConfirmOpen(true)}
style={{
border: "none",
width: 28,
height: 28,
backgroundColor: "transparent",
color: "var(--muted-foreground)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
aria-label="恢复默认指数"
>
<ResetIcon width="16" height="16" />
</button>
)}
</div>
<div
className="chips"
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
}}
>
{allIndices.map((item) => {
const active = selectedSet.has(item.code);
return (
<button
key={item.code || item.name}
type="button"
onClick={() => handleToggleCode(item.code)}
className={cn("chip", active && "active")}
style={{
height: 30,
fontSize: 12,
padding: "0 12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
}}
>
{item.name}
</button>
);
})}
</div>
</div>
</div>
);
if (!open) return null;
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(v) => {
if (!v) onClose?.();
}}
direction="bottom"
>
<DrawerContent
className="glass"
defaultHeight="77vh"
minHeight="40vh"
maxHeight="90vh"
>
<DrawerHeader className="flex flex-row items-center justify-between gap-2 py-4">
<DrawerTitle className="flex items-center gap-2.5 text-left">
<SettingsIcon width="20" height="20" />
<span>指数个性化设置</span>
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{
borderColor: "transparent",
backgroundColor: "transparent",
}}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div className="flex-1 overflow-y-auto">{body}</div>
</DrawerContent>
<AnimatePresence>
{resetConfirmOpen && (
<ConfirmModal
key="mobile-index-reset-confirm"
title="恢复默认指数"
message="是否恢复已添加指数为默认配置?"
icon={
<ResetIcon
width="20"
height="20"
className="shrink-0 text-[var(--primary)]"
/>
}
confirmVariant="primary"
confirmText="恢复默认"
onConfirm={() => {
onResetDefault?.();
setResetConfirmOpen(false);
}}
onCancel={() => setResetConfirmOpen(false)}
/>
)}
</AnimatePresence>
</Drawer>
);
}
const pcContent = (
<AnimatePresence>
{open && (
<motion.div
key="market-index-setting-overlay"
className="pc-table-setting-overlay"
role="dialog"
aria-modal="true"
aria-label="指数个性化设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{ zIndex: 10001 }}
>
<motion.aside
className="pc-market-setting-drawer pc-table-setting-drawer glass"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
style={{ width: 690 }}
>
<div className="pc-table-setting-header">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>指数个性化设置</span>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: "none", background: "transparent" }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="pc-table-setting-body">{body}</div>
</motion.aside>
</motion.div>
)}
{resetConfirmOpen && (
<ConfirmModal
key="pc-index-reset-confirm"
title="恢复默认指数"
message="是否恢复已添加指数为默认配置?"
icon={
<ResetIcon
width="20"
height="20"
className="shrink-0 text-[var(--primary)]"
/>
}
confirmVariant="primary"
confirmText="恢复默认"
onConfirm={() => {
onResetDefault?.();
setResetConfirmOpen(false);
}}
onCancel={() => setResetConfirmOpen(false)}
/>
)}
</AnimatePresence>
);
if (typeof document === "undefined") return null;
return createPortal(pcContent, document.body);
}

View File

@@ -0,0 +1,83 @@
'use client';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle, DrawerTrigger,
} from '@/components/ui/drawer';
import FundCard from './FundCard';
import { CloseIcon } from './Icons';
/**
* 移动端基金详情底部 Drawer 弹框
*
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {(open: boolean) => void} props.onOpenChange - 打开状态变化回调
* @param {boolean} [props.blockDrawerClose] - 是否禁止关闭(如上层有弹框时)
* @param {React.MutableRefObject<boolean>} [props.ignoreNextDrawerCloseRef] - 忽略下一次关闭(用于点击到内部 dialog 时)
* @param {Object|null} props.cardSheetRow - 当前选中的行数据,用于 getFundCardProps
* @param {(row: any) => Object} [props.getFundCardProps] - 根据行数据返回 FundCard 的 props
*/
export default function MobileFundCardDrawer({
open,
onOpenChange,
blockDrawerClose = false,
ignoreNextDrawerCloseRef,
cardSheetRow,
getFundCardProps,
children,
}) {
return (
<Drawer
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
if (ignoreNextDrawerCloseRef?.current) {
ignoreNextDrawerCloseRef.current = false;
return;
}
if (!blockDrawerClose) onOpenChange(false);
}
}}
>
<DrawerTrigger asChild>
{children}
</DrawerTrigger>
<DrawerContent
className="h-[85vh] max-h-[90vh] mt-0 flex flex-col"
onPointerDownOutside={(e) => {
if (blockDrawerClose) return;
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
if (ignoreNextDrawerCloseRef) ignoreNextDrawerCloseRef.current = true;
return;
}
onOpenChange(false);
}}
>
<DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
>
{cardSheetRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardSheetRow)} />
) : null}
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -24,33 +24,31 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import FitText from './FitText'; import FitText from './FitText';
import FundCard from './FundCard'; import MobileFundCardDrawer from './MobileFundCardDrawer';
import MobileSettingModal from './MobileSettingModal'; import MobileSettingModal from './MobileSettingModal';
import { CloseIcon, 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: '持有收益',
}; };
@@ -240,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;
})(); })();
@@ -252,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;
})(); })();
@@ -347,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 = () => {
@@ -429,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;
@@ -463,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) => {
@@ -561,7 +619,7 @@ export default function MobileFundTable({
} }
}} }}
> >
{masked ? '******' : holdingAmountDisplay} {masked ? <span className="mask-text">******</span> : holdingAmountDisplay}
{hasDca && <span className="dca-indicator"></span>} {hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>} {isUpdated && <span className="updated-indicator"></span>}
</span> </span>
@@ -661,12 +719,29 @@ 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: '最新净值',
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const date = original.latestNavDate ?? '-'; const date = original.latestNavDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}> <span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
@@ -674,7 +749,7 @@ export default function MobileFundTable({
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</FitText> </FitText>
</span> </span>
<span className="muted" style={{ fontSize: '10px' }}>{date}</span> <span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
</div> </div>
); );
}, },
@@ -687,15 +762,19 @@ export default function MobileFundTable({
const original = info.row.original || {}; const original = info.row.original || {};
const date = original.estimateNavDate ?? '-'; const date = original.estimateNavDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date; const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
const estimateNav = info.getValue();
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}> <span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}> <FitText maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'} {estimateNav ?? '—'}
</FitText> </FitText>
</span> </span>
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span> {hasEstimateNav && displayDate && displayDate !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
) : null}
</div> </div>
); );
}, },
@@ -708,13 +787,14 @@ export default function MobileFundTable({
const original = info.row.original || {}; const original = info.row.original || {};
const value = original.yesterdayChangeValue; const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-'; const date = original.yesterdayDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '10px' }}>{date}</span> <span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
</div> </div>
); );
}, },
@@ -730,12 +810,16 @@ export default function MobileFundTable({
const time = original.estimateTime ?? '-'; const time = original.estimateTime ?? '-';
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time; const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
const text = info.getValue();
const hasText = text != null && text !== '—';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {text ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span> {hasText && displayTime && displayTime !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
) : null}
</div> </div>
); );
}, },
@@ -756,10 +840,10 @@ export default function MobileFundTable({
<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 }}>
<FitText maxFontSize={14} minFontSize={10}> <FitText maxFontSize={14} minFontSize={10}>
{masked && hasProfit ? '******' : amountStr} {masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
</span> </span>
{percentStr && !masked ? ( {hasProfit && percentStr && !masked ? (
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} estimate-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}
@@ -771,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: '当日收益',
@@ -781,15 +882,14 @@ 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 }}>
<FitText maxFontSize={14} minFontSize={10}> <FitText maxFontSize={14} minFontSize={10}>
{masked && hasProfit ? '******' : 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}
@@ -815,7 +915,7 @@ export default function MobileFundTable({
<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 }}>
<FitText maxFontSize={14} minFontSize={10}> <FitText maxFontSize={14} minFontSize={10}>
{masked && hasTotal ? '******' : amountStr} {masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
</span> </span>
{percentStr && !masked ? ( {percentStr && !masked ? (
@@ -831,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({
@@ -942,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';
}; };
@@ -1010,7 +1110,7 @@ export default function MobileFundTable({
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row, index) => (
<SortableRow <SortableRow
key={row.original.code || row.id} key={row.original.code || row.id}
row={row} row={row}
@@ -1022,7 +1122,7 @@ export default function MobileFundTable({
ref={sortBy === 'default' && !isNameSortMode ? setActivatorNodeRef : undefined} ref={sortBy === 'default' && !isNameSortMode ? setActivatorNodeRef : undefined}
className="table-row" className="table-row"
style={{ style={{
background: 'var(--bg)', background: index % 2 === 0 ? 'var(--bg)' : 'var(--table-row-alt-bg)',
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}), ...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
@@ -1036,11 +1136,19 @@ export default function MobileFundTable({
const alignClass = getAlignClass(columnId); const alignClass = getAlignClass(columnId);
const cellClassName = cell.column.columnDef.meta?.cellClassName || ''; const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
const isLastColumn = cellIndex === row.getVisibleCells().length - 1; const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
const style = isLastColumn ? {paddingRight: LAST_COLUMN_EXTRA} : {};
if (cellIndex === 0) {
if (index % 2 !== 0) {
style.background = 'var(--table-row-alt-bg)';
}else {
style.background = 'var(--bg)';
}
}
return ( return (
<div <div
key={cell.id} key={cell.id}
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`} className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined} style={style}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </div>
@@ -1082,51 +1190,14 @@ export default function MobileFundTable({
/> />
)} )}
<Drawer <MobileFundCardDrawer
open={!!(cardSheetRow && getFundCardProps)} open={!!(cardSheetRow && getFundCardProps)}
onOpenChange={(open) => { onOpenChange={(open) => { if (!open) setCardSheetRow(null); }}
if (!open) { blockDrawerClose={blockDrawerClose}
if (ignoreNextDrawerCloseRef.current) { ignoreNextDrawerCloseRef={ignoreNextDrawerCloseRef}
ignoreNextDrawerCloseRef.current = false; cardSheetRow={cardSheetRow}
return; getFundCardProps={getFundCardProps}
} />
if (!blockDrawerClose) setCardSheetRow(null);
}
}}
>
<DrawerContent
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
onPointerDownOutside={(e) => {
if (blockDrawerClose) return;
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
ignoreNextDrawerCloseRef.current = true;
return;
}
setCardSheetRow(null);
}}
>
<DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
>
{cardSheetRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardSheetRow)} />
) : null}
</div>
</DrawerContent>
</Drawer>
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)} {!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
</div> </div>

View File

@@ -40,6 +40,7 @@ export default function MobileSettingModal({
onToggleShowFullFundName, onToggleShowFullFundName,
}) { }) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false); const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [isReordering, setIsReordering] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) setResetConfirmOpen(false); if (!open) setResetConfirmOpen(false);
@@ -58,6 +59,7 @@ export default function MobileSettingModal({
if (!v) onClose(); if (!v) onClose();
}} }}
direction="bottom" direction="bottom"
handleOnly={isReordering}
> >
<DrawerContent <DrawerContent
className="glass" className="glass"
@@ -142,6 +144,8 @@ export default function MobileSettingModal({
values={columns} values={columns}
onReorder={handleReorder} onReorder={handleReorder}
className="mobile-setting-list" className="mobile-setting-list"
layoutScroll
style={{ touchAction: 'none' }}
> >
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{columns.map((item, index) => ( {columns.map((item, index) => (
@@ -153,6 +157,8 @@ export default function MobileSettingModal({
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }} exit={{ opacity: 0, scale: 0.98 }}
onDragStart={() => setIsReordering(true)}
onDragEnd={() => setIsReordering(false)}
transition={{ transition={{
type: 'spring', type: 'spring',
stiffness: 500, stiffness: 500,
@@ -160,6 +166,7 @@ export default function MobileSettingModal({
mass: 1, mass: 1,
layout: { duration: 0.2 }, layout: { duration: 0.2 },
}} }}
style={{ touchAction: 'none' }}
> >
<div <div
className="drag-handle" className="drag-handle"
@@ -173,7 +180,19 @@ export default function MobileSettingModal({
> >
<DragIcon width="18" height="18" /> <DragIcon width="18" height="18" />
</div> </div>
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span> <div style={{ flex: 1, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
<span>{item.header}</span>
{item.id === 'totalChangePercent' && (
<span className="muted" style={{ fontSize: '12px' }}>
估值涨幅与持有收益的汇总
</span>
)}
{item.id === 'relatedSector' && (
<span className="muted" style={{ fontSize: '12px' }}>
fund.cc.cd 地址支持
</span>
)}
</div>
{onToggleColumnVisibility && ( {onToggleColumnVisibility && (
<Switch <Switch
checked={columnVisibility?.[item.id] !== false} checked={columnVisibility?.[item.id] !== false}

View File

@@ -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: '最新净值',
@@ -570,7 +652,8 @@ export default function PcFundTable({
minSize: 80, minSize: 80,
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const date = original.latestNavDate ?? '-'; const rawDate = original.latestNavDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div"> <FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
@@ -594,15 +677,20 @@ export default function PcFundTable({
minSize: 80, minSize: 80,
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const date = original.estimateNavDate ?? '-'; const rawDate = original.estimateNavDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
const estimateNav = info.getValue();
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div"> <FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'} {estimateNav ?? '—'}
</FitText> </FitText>
<span className="muted" style={{ fontSize: '11px' }}> {hasEstimateNav && date && date !== '-' ? (
{date} <span className="muted" style={{ fontSize: '11px' }}>
</span> {date}
</span>
) : null}
</div> </div>
); );
}, },
@@ -619,7 +707,8 @@ export default function PcFundTable({
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const value = original.yesterdayChangeValue; const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-'; const rawDate = original.yesterdayDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
const cls = value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
@@ -646,16 +735,21 @@ export default function PcFundTable({
const original = info.row.original || {}; const original = info.row.original || {};
const value = original.estimateChangeValue; const value = original.estimateChangeValue;
const isMuted = original.estimateChangeMuted; const isMuted = original.estimateChangeMuted;
const time = original.estimateTime ?? '-'; const rawTime = original.estimateTime ?? '-';
const time = typeof rawTime === 'string' && rawTime.length > 5 ? rawTime.slice(5) : rawTime;
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : ''; const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
const text = info.getValue();
const hasText = text != null && text !== '—';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div"> <FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'} {text ?? '—'}
</FitText> </FitText>
<span className="muted" style={{ fontSize: '11px' }}> {hasText && time && time !== '-' ? (
{time} <span className="muted" style={{ fontSize: '11px' }}>
</span> {time}
</span>
) : null}
</div> </div>
); );
}, },
@@ -680,9 +774,9 @@ export default function PcFundTable({
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<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 ? '******' : amountStr} {masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
{percentStr && !masked ? ( {hasProfit && percentStr && !masked ? (
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} estimate-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}
@@ -738,7 +832,7 @@ export default function PcFundTable({
> >
<div style={{ flex: '1 1 0', minWidth: 0 }}> <div style={{ flex: '1 1 0', minWidth: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}> <FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
{masked ? '******' : (info.getValue() ?? '—')} {masked ? <span className="mask-text">******</span> : (info.getValue() ?? '—')}
</FitText> </FitText>
</div> </div>
<button <button
@@ -760,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: '当日收益',
@@ -776,9 +892,9 @@ export default function PcFundTable({
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<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 ? '******' : 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}
@@ -808,7 +924,7 @@ export default function PcFundTable({
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<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 && hasTotal ? '******' : amountStr} {masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
</FitText> </FitText>
{percentStr && !masked ? ( {percentStr && !masked ? (
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
@@ -883,7 +999,7 @@ export default function PcFundTable({
}, },
}, },
], ],
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps], [currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked, relatedSectorByCode],
); );
const table = useReactTable({ const table = useReactTable({
@@ -942,7 +1058,7 @@ export default function PcFundTable({
left: isLeft ? `${column.getStart('left')}px` : undefined, left: isLeft ? `${column.getStart('left')}px` : undefined,
right: isRight ? `${column.getAfter('right')}px` : undefined, right: isRight ? `${column.getAfter('right')}px` : undefined,
zIndex: isHeader ? 11 : 10, zIndex: isHeader ? 11 : 10,
backgroundColor: isHeader ? 'var(--table-pinned-header-bg)' : 'var(--row-bg)', backgroundColor: isHeader ? 'var(--table-pinned-header-bg)' : 'var(--row-bg, var(--bg))',
boxShadow: 'none', boxShadow: 'none',
textAlign: isNameColumn ? 'left' : 'center', textAlign: isNameColumn ? 'left' : 'center',
justifyContent: isNameColumn ? 'flex-start' : 'center', justifyContent: isNameColumn ? 'flex-start' : 'center',
@@ -958,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}
@@ -989,14 +1108,39 @@ 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); background-color: var(--row-bg) !important;
} }
.table-row-scroll:hover {
/* 斑马纹行背景(非 hover 状态) */
.table-row-scroll:nth-child(even),
.table-row-scroll.row-even {
background-color: var(--table-row-alt-bg) !important;
}
/* Pinned cells 继承所在行的背景(非 hover 状态) */
.table-row-scroll .pinned-cell {
background-color: var(--row-bg) !important;
}
.table-row-scroll:nth-child(even) .pinned-cell,
.table-row-scroll.row-even .pinned-cell,
.row-even .pinned-cell {
background-color: var(--table-row-alt-bg) !important;
}
/* Hover 状态优先级最高,覆盖斑马纹和 pinned 背景 */
.table-row-scroll:hover,
.table-row-scroll.row-even:hover {
--row-bg: var(--table-row-hover-bg); --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;
} }
/* 覆盖 grid 布局为 flex 以支持动态列宽 */ /* 覆盖 grid 布局为 flex 以支持动态列宽 */
@@ -1063,86 +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) => ( 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" <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}
return ( >
<div {flexRender(
key={cell.id} cell.column.columnDef.cell,
className={`table-cell ${align} ${cellClassName}`} cell.getContext(),
style={style} )}
> </div>
{flexRender( );
cell.column.columnDef.cell, })}
cell.getContext(), </div>
)} </SortableRow>
</div> ))}
); </AnimatePresence>
})} </SortableContext>
</div> </DndContext>
</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}
@@ -1159,84 +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"
showCloseButton={false}
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>
<button
type="button"
className="icon-button rounded-lg"
aria-label="关闭"
onClick={() => setCardDialogRow(null)}
style={{ padding: 4, borderColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</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>
)
}

View File

@@ -206,7 +206,19 @@ export default function PcTableSettingModal({
> >
<DragIcon width="18" height="18" /> <DragIcon width="18" height="18" />
</div> </div>
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span> <div style={{ flex: 1, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
<span>{item.header}</span>
{item.id === 'totalChangePercent' && (
<span className="muted" style={{ fontSize: '12px' }}>
估值涨幅与持有收益的汇总
</span>
)}
{item.id === 'relatedSector' && (
<span className="muted" style={{ fontSize: '12px' }}>
fund.cc.cd 地址支持
</span>
)}
</div>
{onToggleColumnVisibility && ( {onToggleColumnVisibility && (
<button <button
type="button" type="button"

View File

@@ -10,6 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export default function ScanImportConfirmModal({ export default function ScanImportConfirmModal({
scannedFunds, scannedFunds,
@@ -22,9 +23,10 @@ export default function ScanImportConfirmModal({
isOcrScan = false isOcrScan = false
}) { }) {
const [selectedGroupId, setSelectedGroupId] = useState('all'); const [selectedGroupId, setSelectedGroupId] = useState('all');
const [expandAfterAdd, setExpandAfterAdd] = useState(true);
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(selectedGroupId); onConfirm(selectedGroupId, expandAfterAdd);
}; };
const formatAmount = (val) => { const formatAmount = (val) => {
@@ -126,6 +128,13 @@ export default function ScanImportConfirmModal({
); );
})} })}
</div> </div>
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span className="muted" style={{ fontSize: 13 }}>添加后展开详情</span>
<Switch
checked={expandAfterAdd}
onCheckedChange={(checked) => setExpandAfterAdd(!!checked)}
/>
</div>
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组</span> <span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组</span>
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}> <Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>

View File

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

View File

@@ -0,0 +1,575 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, Reorder } from "framer-motion";
import { createPortal } from "react-dom";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerClose,
} from "@/components/ui/drawer";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
import ConfirmModal from "./ConfirmModal";
/**
* 排序个性化设置弹框
*
* - 移动端:使用 Drawer自底向上抽屉参考市场指数设置
* - PC 端:使用右侧侧弹框(样式参考 PcTableSettingModal
*
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调
* @param {boolean} props.isMobile - 是否为移动端(由上层传入)
* @param {Array<{id: string, label: string, enabled: boolean}>} props.rules - 排序规则列表
* @param {(nextRules: Array<{id: string, label: string, enabled: boolean}>) => void} props.onChangeRules - 规则变更回调
*/
export default function SortSettingModal({
open,
onClose,
isMobile,
rules = [],
onChangeRules,
onResetRules,
sortDisplayMode = "buttons",
onChangeSortDisplayMode,
}) {
const [localRules, setLocalRules] = useState(rules);
const [editingId, setEditingId] = useState(null);
const [editingAlias, setEditingAlias] = useState("");
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
useEffect(() => {
if (open) {
const defaultRule = (rules || []).find((item) => item.id === "default");
const otherRules = (rules || []).filter((item) => item.id !== "default");
const ordered = defaultRule ? [defaultRule, ...otherRules] : otherRules;
setLocalRules(ordered);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}
}, [open, rules]);
const handleReorder = (nextItems) => {
// 基于当前 localRules 计算新顺序(默认规则固定在首位)
const defaultRule = (localRules || []).find((item) => item.id === "default");
const combined = defaultRule ? [defaultRule, ...nextItems] : nextItems;
setLocalRules(combined);
if (onChangeRules) {
queueMicrotask(() => {
onChangeRules(combined);
});
}
};
const handleToggle = (id) => {
const next = (localRules || []).map((item) =>
item.id === id ? { ...item, enabled: !item.enabled } : item
);
setLocalRules(next);
if (onChangeRules) {
queueMicrotask(() => {
onChangeRules(next);
});
}
};
const startEditAlias = (item) => {
if (!item || item.id === "default") return;
setEditingId(item.id);
setEditingAlias(item.alias || "");
};
const commitAlias = () => {
if (!editingId) return;
let nextRules = null;
setLocalRules((prev) => {
const next = prev.map((item) =>
item.id === editingId
? { ...item, alias: editingAlias.trim() || undefined }
: item
);
nextRules = next;
return next;
});
if (nextRules) {
// 将父组件状态更新放到微任务中,避免在 SortSettingModal 渲染过程中直接更新 HomePage
queueMicrotask(() => {
onChangeRules?.(nextRules);
});
}
setEditingId(null);
setEditingAlias("");
};
const cancelAlias = () => {
setEditingId(null);
setEditingAlias("");
};
if (!open) return null;
const body = (
<div
className={
isMobile
? "mobile-setting-body flex flex-1 flex-col overflow-y-auto"
: "pc-table-setting-body"
}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<h3
className="pc-table-setting-subtitle"
style={{ margin: 0, fontSize: 14 }}
>
排序形式
</h3>
<div style={{ display: "flex", justifyContent: "flex-end", marginLeft: "auto" }}>
<RadioGroup
value={sortDisplayMode}
onValueChange={(value) => onChangeSortDisplayMode?.(value)}
className="flex flex-row items-center gap-4"
>
<label
htmlFor="sort-display-mode-buttons"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--text)",
cursor: "pointer",
}}
>
<RadioGroupItem id="sort-display-mode-buttons" value="buttons" />
<span>按钮</span>
</label>
<label
htmlFor="sort-display-mode-dropdown"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--text)",
cursor: "pointer",
}}
>
<RadioGroupItem id="sort-display-mode-dropdown" value="dropdown" />
<span>下拉单选</span>
</label>
</RadioGroup>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
marginBottom: 16,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<h3
className="pc-table-setting-subtitle"
style={{ margin: 0, fontSize: 14 }}
>
排序规则
</h3>
{onResetRules && (
<button
type="button"
className="icon-button"
onClick={() => setResetConfirmOpen(true)}
title="重置排序规则"
style={{
border: "none",
width: 28,
height: 28,
backgroundColor: "transparent",
color: "var(--muted-foreground)",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ResetIcon width="16" height="16" />
</button>
)}
</div>
<p
className="muted"
style={{ fontSize: 12, margin: 0, color: "var(--muted-foreground)" }}
>
可拖拽调整优先级右侧开关控制是否启用该排序规则点击规则名称可编辑别名例如估值涨幅的别名为涨跌幅
</p>
</div>
{localRules.length === 0 ? (
<div
className="muted"
style={{
textAlign: "center",
padding: "24px 0",
fontSize: 14,
}}
>
暂无可配置的排序规则
</div>
) : (
<>
{/* 默认排序固定在顶部,且不可排序、不可关闭 */}
{localRules.find((item) => item.id === "default") && (
<div
className={
(isMobile ? "mobile-setting-item" : "pc-table-setting-item") +
" glass"
}
style={{
display: "flex",
alignItems: "center",
marginBottom: 8,
}}
>
<div
style={{
width: 18,
height: 18,
marginLeft: 4,
}}
/>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={{ fontSize: 14 }}>
{localRules.find((item) => item.id === "default")?.label ||
"默认"}
</span>
</div>
</div>
)}
{/* 其他规则支持拖拽和开关 */}
<Reorder.Group
axis="y"
values={localRules.filter((item) => item.id !== "default")}
onReorder={handleReorder}
className={isMobile ? "mobile-setting-list" : "pc-table-setting-list"}
layoutScroll={isMobile}
style={isMobile ? { touchAction: "none" } : undefined}
>
<AnimatePresence mode="popLayout">
{localRules
.filter((item) => item.id !== "default")
.map((item) => (
<Reorder.Item
key={item.id}
value={item}
className={
(isMobile ? "mobile-setting-item" : "pc-table-setting-item") +
" glass"
}
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 1,
layout: { duration: 0.2 },
}}
style={isMobile ? { touchAction: "none" } : undefined}
>
<div
className="drag-handle"
style={{
cursor: "grab",
display: "flex",
alignItems: "center",
padding: "0 8px",
color: "var(--muted)",
}}
>
<DragIcon width="18" height="18" />
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{editingId === item.id ? (
<div style={{ display: "flex", gap: 6 }}>
<input
autoFocus
value={editingAlias}
onChange={(e) => setEditingAlias(e.target.value)}
onBlur={commitAlias}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitAlias();
} else if (e.key === "Escape") {
e.preventDefault();
cancelAlias();
}
}}
placeholder="输入别名,如涨跌幅"
style={{
flex: 1,
// 使用 >=16px 的字号,避免移动端聚焦时页面放大
fontSize: 16,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text)",
outline: "none",
}}
/>
</div>
) : (
<>
<button
type="button"
onClick={() => startEditAlias(item)}
style={{
padding: 0,
margin: 0,
border: "none",
background: "transparent",
textAlign: "left",
fontSize: 14,
color: "inherit",
cursor: "pointer",
}}
title="点击修改别名"
>
{item.label}
</button>
{item.alias && (
<span
className="muted"
style={{
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
{item.alias}
</span>
)}
</>
)}
</div>
{item.id !== "default" && (
<button
type="button"
className={
isMobile ? "icon-button" : "icon-button pc-table-column-switch"
}
onClick={(e) => {
e.stopPropagation();
handleToggle(item.id);
}}
title={item.enabled ? "关闭" : "开启"}
style={
isMobile
? {
border: "none",
backgroundColor: "transparent",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
}
: {
border: "none",
padding: "0 4px",
backgroundColor: "transparent",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
}
}
>
<span
className={`dca-toggle-track ${
item.enabled ? "enabled" : ""
}`}
>
<span
className="dca-toggle-thumb"
style={{ left: item.enabled ? 16 : 2 }}
/>
</span>
</button>
)}
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
</>
)}
</div>
);
const resetConfirm = (
<AnimatePresence>
{resetConfirmOpen && (
<ConfirmModal
key="reset-sort-rules-confirm"
title="重置排序规则"
message="是否将排序规则恢复为默认配置?这会重置顺序、开关状态以及别名设置。"
icon={
<ResetIcon
width="20"
height="20"
className="shrink-0 text-[var(--primary)]"
/>
}
confirmVariant="primary"
confirmText="恢复默认"
onConfirm={() => {
setResetConfirmOpen(false);
queueMicrotask(() => {
onResetRules?.();
});
}}
onCancel={() => setResetConfirmOpen(false)}
/>
)}
</AnimatePresence>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(v) => {
if (!v) onClose?.();
}}
direction="bottom"
>
<DrawerContent
className="glass"
defaultHeight="70vh"
minHeight="40vh"
maxHeight="90vh"
>
<DrawerHeader className="flex flex-row items-center justify-between gap-2 py-4">
<DrawerTitle className="flex items-center gap-2.5 text-left">
<SettingsIcon width="20" height="20" />
<span>排序个性化设置</span>
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{
borderColor: "transparent",
backgroundColor: "transparent",
}}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div className="flex-1 overflow-y-auto">{body}</div>
</DrawerContent>
{resetConfirm}
</Drawer>
);
}
if (typeof document === "undefined") return null;
const content = (
<AnimatePresence>
{open && (
<motion.div
key="sort-setting-overlay"
className="pc-table-setting-overlay"
role="dialog"
aria-modal="true"
aria-label="排序个性化设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{ zIndex: 10001, alignItems: "stretch" }}
>
<motion.aside
className="pc-table-setting-drawer glass"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
style={{
width: 420,
maxWidth: 480,
}}
>
<div className="pc-table-setting-header">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>排序个性化设置</span>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: "none", background: "transparent" }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
{body}
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(
<>
{content}
{resetConfirm}
</>,
document.body
);
}

View File

@@ -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 && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
: (!!share && !!date); : (!!share && !!date);
const handleSetShareFraction = (fraction) => { const handleSetShareFraction = (fraction) => {
@@ -372,72 +426,112 @@ 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 || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
min={0} ? '1px solid var(--danger)'
placeholder="请输入加仓金额" : '1px solid var(--border)',
/> borderRadius: 12
</div> }}
</div> >
<NumericInput
value={amount}
onChange={setAmount}
step={100}
min={Number(minBuyAmount) || 0}
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
/>
</div>
{(Number(minBuyAmount) || 0) > 0 && (
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
最小加仓金额¥{Number(minBuyAmount)}
</div>
)}
</div>
<div className="row" style={{ gap: 12, marginBottom: 16 }}> <div className="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 +658,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>

View File

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

View File

@@ -16,6 +16,7 @@
--border: #1f2937; --border: #1f2937;
--table-pinned-header-bg: #2a394b; --table-pinned-header-bg: #2a394b;
--table-row-hover-bg: #2a394b; --table-row-hover-bg: #2a394b;
--table-row-alt-bg: #1a2535;
--radius: 0.625rem; --radius: 0.625rem;
--background: #0f172a; --background: #0f172a;
--foreground: #e5e7eb; --foreground: #e5e7eb;
@@ -65,6 +66,7 @@
--border: #e2e8f0; --border: #e2e8f0;
--table-pinned-header-bg: #e2e8f0; --table-pinned-header-bg: #e2e8f0;
--table-row-hover-bg: #e2e8f0; --table-row-hover-bg: #e2e8f0;
--table-row-alt-bg: #f8fafc;
--background: #ffffff; --background: #ffffff;
--foreground: #0f172a; --foreground: #0f172a;
--card-foreground: #0f172a; --card-foreground: #0f172a;
@@ -106,8 +108,10 @@
html, html,
body { body {
overscroll-behavior-y: none;
height: 100%; height: 100%;
overflow-x: clip; overflow-x: clip;
will-change: auto; /* 或者移除任何 will-change: transform */
} }
body { body {
@@ -164,6 +168,13 @@ body::before {
width: 1200px; width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
/* 隐藏 y 轴滚动条,保留滚动能力 */
scrollbar-width: none;
-ms-overflow-style: none;
}
.container::-webkit-scrollbar {
width: 0;
display: none;
} }
.page-width-slider { .page-width-slider {
@@ -447,11 +458,20 @@ body::before {
background: #e2e8f0; background: #e2e8f0;
} }
[data-theme="light"] .table-row:nth-child(even) {
background: var(--table-row-alt-bg);
}
[data-theme="light"] .table-row-scroll:hover, [data-theme="light"] .table-row-scroll:hover,
[data-theme="light"] .table-row-scroll.row-hovered { [data-theme="light"] .table-row-scroll.row-hovered {
background: #e2e8f0; background: #e2e8f0;
} }
[data-theme="light"] .table-row-scroll:nth-child(even),
[data-theme="light"] .table-row-scroll.row-even {
background: var(--table-row-alt-bg) !important;
}
[data-theme="light"] .table-fixed-row.row-hovered { [data-theme="light"] .table-fixed-row.row-hovered {
background: #e2e8f0; background: #e2e8f0;
} }
@@ -965,6 +985,13 @@ input[type="number"] {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: clip; overflow-x: clip;
/* 移动端同样隐藏 y 轴滚动条 */
scrollbar-width: none;
-ms-overflow-style: none;
}
.container::-webkit-scrollbar {
width: 0;
display: none;
} }
.grid { .grid {
@@ -1023,6 +1050,12 @@ input[type="number"] {
color: var(--success); color: var(--success);
} }
.mask-text,
.up .mask-text,
.down .mask-text {
color: var(--text) !important;
}
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -1396,6 +1429,11 @@ input[type="number"] {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.table-row-scroll:nth-child(even),
.table-row-scroll.row-even {
background: var(--table-row-alt-bg) !important;
}
.table-fixed-row.row-hovered { .table-fixed-row.row-hovered {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
@@ -1450,6 +1488,10 @@ input[type="number"] {
background: #2a394b; background: #2a394b;
} }
.table-row:nth-child(even) {
background: var(--table-row-alt-bg);
}
.table-row:last-child { .table-row:last-child {
border-bottom: none; border-bottom: none;
} }
@@ -1862,7 +1904,6 @@ input[type="number"] {
@media (max-width: 640px) { @media (max-width: 640px) {
.filter-bar { .filter-bar {
position: sticky; position: sticky;
top: 60px; /* Navbar height */
z-index: 40; z-index: 40;
width: calc(100% + 32px); width: calc(100% + 32px);
background: rgba(15, 23, 42, 0.9); background: rgba(15, 23, 42, 0.9);
@@ -1991,6 +2032,11 @@ input[type="number"] {
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3); box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
} }
/* 指数个性化设置侧弹框:加宽以一行展示 5 个指数卡片 */
.pc-market-setting-drawer.pc-table-setting-drawer {
width: 560px;
}
.pc-table-setting-header { .pc-table-setting-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2047,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;
@@ -2076,6 +2124,21 @@ input[type="number"] {
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12); box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
} }
/* Drawer 内容玻璃拟态:与 Dialog 统一的毛玻璃效果(更通透) */
.drawer-content-theme {
background: rgba(15, 23, 42, 0);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-color: rgba(148, 163, 184, 0.45);
}
[data-theme="light"] .drawer-content-theme {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-color: rgba(148, 163, 184, 0.6);
}
/* shadcn Dialog符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */ /* shadcn Dialog符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
[data-slot="dialog-content"] { [data-slot="dialog-content"] {
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -2477,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;
@@ -3278,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);

View File

@@ -0,0 +1,65 @@
import { useEffect, useRef } from "react";
// 全局状态:支持多个弹框“引用计数”式地共用一个滚动锁
let scrollLockCount = 0;
let lockedScrollY = 0;
let originalBodyPosition = "";
let originalBodyTop = "";
function lockBodyScroll() {
scrollLockCount += 1;
// 只有第一个锁才真正修改 body避免多弹框互相干扰
if (scrollLockCount === 1) {
lockedScrollY = window.scrollY || window.pageYOffset || 0;
originalBodyPosition = document.body.style.position || "";
originalBodyTop = document.body.style.top || "";
requestAnimationFrame(() => {
document.body.style.top = `-${lockedScrollY}px`;
document.body.style.width = "100%";
document.body.style.position = "fixed";
});
}
}
function unlockBodyScroll() {
if (scrollLockCount === 0) return;
scrollLockCount -= 1;
// 只有全部弹框都关闭时才恢复滚动位置
if (scrollLockCount === 0) {
const scrollY = lockedScrollY;
document.body.style.position = originalBodyPosition;
document.body.style.top = originalBodyTop;
document.body.style.width = "";
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}
}
export function useBodyScrollLock(open) {
const isLockedRef = useRef(false);
useEffect(() => {
if (open && !isLockedRef.current) {
lockBodyScroll();
isLockedRef.current = true;
} else if (!open && isLockedRef.current) {
unlockBodyScroll();
isLockedRef.current = false;
}
// 组件卸载或依赖变化时兜底释放锁
return () => {
if (isLockedRef.current) {
unlockBodyScroll();
isLockedRef.current = false;
}
};
}, [open]);
}

28
app/lib/AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# app/lib/ — Core Utilities
## OVERVIEW
4 utility modules: Supabase client, request cache, trading calendar, valuation time-series.
## WHERE TO LOOK
| File | Exports | Purpose |
|------|---------|---------|
| `supabase.js` | `supabase`, `isSupabaseConfigured` | Supabase client (or noop fallback). Auth + DB + realtime |
| `cacheRequest.js` | `cachedRequest()`, `clearCachedRequest()` | In-memory request dedup + TTL cache |
| `tradingCalendar.js` | `loadHolidaysForYear()`, `loadHolidaysForYears()`, `isTradingDay()` | Chinese stock market holiday detection via CDN |
| `valuationTimeseries.js` | `recordValuation()`, `getValuationSeries()`, `clearFund()`, `getAllValuationSeries()` | Fund valuation time-series (localStorage) |
## CONVENTIONS
- **supabase.js**: creates `createNoopSupabase()` when env vars missing — all auth/DB methods return safe defaults
- **cacheRequest.js**: deduplicates concurrent requests for same key; default 10s TTL
- **tradingCalendar.js**: downloads `chinese-days` JSON from cdn.jsdelivr.net; caches per-year in Map
- **valuationTimeseries.js**: localStorage key `fundValuationTimeseries`; auto-clears old dates on new data
## ANTI-PATTERNS (THIS DIRECTORY)
- **No error reporting** — all modules silently fail (console.warn at most)
- **localStorage quota not handled** — valuationTimeseries writes without checking available space
- **Cache only in-memory** — cacheRequest lost on page reload; no persistent cache
- **No request cancellation** — JSONP scripts can't be aborted once injected

View File

@@ -33,6 +33,7 @@ const createNoopSupabase = () => ({
data: { subscription: { unsubscribe: () => { } } } 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 })
}, },

View File

@@ -59,6 +59,8 @@ import UpdatePromptModal from "./components/UpdatePromptModal";
import RefreshButton from "./components/RefreshButton"; import RefreshButton from "./components/RefreshButton";
import WeChatModal from "./components/WeChatModal"; import WeChatModal from "./components/WeChatModal";
import DcaModal from "./components/DcaModal"; import DcaModal from "./components/DcaModal";
import MarketIndexAccordion from "./components/MarketIndexAccordion";
import SortSettingModal from "./components/SortSettingModal";
import githubImg from "./assets/github.svg"; import githubImg from "./assets/github.svg";
import { supabase, isSupabaseConfigured } from './lib/supabase'; import { supabase, isSupabaseConfigured } from './lib/supabase';
import { toast as sonnerToast } from 'sonner'; import { toast as sonnerToast } from 'sonner';
@@ -69,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);
@@ -127,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;
@@ -139,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 { }
}, []); }, []);
@@ -161,10 +175,26 @@ export default function HomePage() {
const [groupManageOpen, setGroupManageOpen] = useState(false); const [groupManageOpen, setGroupManageOpen] = useState(false);
const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false); const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false);
const DEFAULT_SORT_RULES = [
{ id: 'default', label: '默认', enabled: true },
// 估值涨幅为原始名称,“涨跌幅”为别名
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
// 昨日涨幅排序:默认隐藏
{ id: 'yesterdayIncrease', label: '昨日涨幅', enabled: false },
// 持仓金额排序:默认隐藏
{ id: 'holdingAmount', label: '持仓金额', enabled: false },
{ id: 'holding', label: '持有收益', enabled: true },
{ id: 'name', label: '基金名称', alias: '名称', enabled: true },
];
const SORT_DISPLAY_MODES = new Set(['buttons', 'dropdown']);
// 排序状态 // 排序状态
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding 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 [sortSettingOpen, setSortSettingOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -172,6 +202,78 @@ export default function HomePage() {
const savedSortOrder = window.localStorage.getItem('localSortOrder'); const savedSortOrder = window.localStorage.getItem('localSortOrder');
if (savedSortBy) setSortBy(savedSortBy); if (savedSortBy) setSortBy(savedSortBy);
if (savedSortOrder) setSortOrder(savedSortOrder); if (savedSortOrder) setSortOrder(savedSortOrder);
// 1优先从 customSettings.localSortRules 读取
// 2兼容旧版独立 localSortRules 字段
let rulesFromSettings = null;
try {
const rawSettings = window.localStorage.getItem('customSettings');
if (rawSettings) {
const parsed = JSON.parse(rawSettings);
if (parsed && Array.isArray(parsed.localSortRules)) {
rulesFromSettings = parsed.localSortRules;
}
if (
parsed &&
typeof parsed.localSortDisplayMode === 'string' &&
SORT_DISPLAY_MODES.has(parsed.localSortDisplayMode)
) {
setSortDisplayMode(parsed.localSortDisplayMode);
}
}
} catch {
// ignore
}
if (!rulesFromSettings) {
const legacy = window.localStorage.getItem('localSortRules');
if (legacy) {
try {
const parsed = JSON.parse(legacy);
if (Array.isArray(parsed)) {
rulesFromSettings = parsed;
}
} catch {
// ignore
}
}
}
if (rulesFromSettings && rulesFromSettings.length) {
// 1先按本地存储的顺序还原包含 alias、enabled 等字段)
const defaultMap = new Map(
DEFAULT_SORT_RULES.map((rule) => [rule.id, 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);
}
setIsSortLoaded(true); setIsSortLoaded(true);
} }
}, []); }, []);
@@ -180,8 +282,37 @@ export default function HomePage() {
if (typeof window !== 'undefined' && isSortLoaded) { if (typeof window !== 'undefined' && isSortLoaded) {
window.localStorage.setItem('localSortBy', sortBy); window.localStorage.setItem('localSortBy', sortBy);
window.localStorage.setItem('localSortOrder', sortOrder); window.localStorage.setItem('localSortOrder', sortOrder);
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const next = {
...(parsed && typeof parsed === 'object' ? parsed : {}),
localSortRules: sortRules,
localSortDisplayMode: sortDisplayMode,
};
window.localStorage.setItem('customSettings', JSON.stringify(next));
// 更新后标记 customSettings 脏并触发云端同步
triggerCustomSettingsSync();
} catch {
// ignore
}
} }
}, [sortBy, sortOrder, isSortLoaded]); }, [sortBy, sortOrder, sortRules, sortDisplayMode, isSortLoaded]);
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
useEffect(() => {
const enabledRules = (sortRules || []).filter((r) => r.enabled);
const enabledIds = enabledRules.map((r) => r.id);
if (!enabledIds.length) {
// 至少保证默认存在
setSortRules(DEFAULT_SORT_RULES);
setSortBy('default');
return;
}
if (!enabledIds.includes(sortBy)) {
setSortBy(enabledIds[0]);
}
}, [sortRules, sortBy]);
// 视图模式 // 视图模式
const [viewMode, setViewMode] = useState('card'); // card, list const [viewMode, setViewMode] = useState('card'); // card, list
@@ -243,6 +374,7 @@ export default function HomePage() {
const containerRef = useRef(null); const containerRef = useRef(null);
const [navbarHeight, setNavbarHeight] = useState(0); const [navbarHeight, setNavbarHeight] = useState(0);
const [filterBarHeight, setFilterBarHeight] = useState(0); const [filterBarHeight, setFilterBarHeight] = useState(0);
const [marketIndexAccordionHeight, setMarketIndexAccordionHeight] = useState(0);
// 主题初始固定为 dark避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复 // 主题初始固定为 dark避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
const [theme, setTheme] = useState('dark'); const [theme, setTheme] = useState('dark');
const [showThemeTransition, setShowThemeTransition] = useState(false); const [showThemeTransition, setShowThemeTransition] = useState(false);
@@ -286,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();
@@ -338,6 +471,13 @@ export default function HomePage() {
} }
}, []); }, []);
const shouldShowMarketIndex = isMobile ? showMarketIndexMobile : showMarketIndexPc;
// 当关闭大盘指数时,重置它的高度,避免 top/stickyTop 仍沿用旧值
useEffect(() => {
if (!shouldShowMarketIndex) setMarketIndexAccordionHeight(0);
}, [shouldShowMarketIndex]);
// 检查更新 // 检查更新
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [latestVersion, setLatestVersion] = useState(''); const [latestVersion, setLatestVersion] = useState('');
@@ -460,26 +600,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;
} }
@@ -541,8 +685,54 @@ export default function HomePage() {
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
if (sortBy === 'yield') { if (sortBy === 'yield') {
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); const getYieldValue = (fund) => {
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); // 与 estimateChangePercent 展示逻辑对齐:
// - noValuation 为 true 一律视为无“估值涨幅”
// - 有估值覆盖时用 estGszzl
// - 否则仅在 gszzl 为数字时使用 gszzl
if (fund.noValuation) {
return { value: 0, hasValue: false };
}
if (fund.estPricedCoverage > 0.05) {
if (isNumber(fund.estGszzl)) {
return { value: fund.estGszzl, hasValue: true };
}
return { value: 0, hasValue: false };
}
if (isNumber(fund.gszzl)) {
return { value: Number(fund.gszzl), hasValue: true };
}
return { value: 0, hasValue: false };
};
const { value: valA, hasValue: hasA } = getYieldValue(a);
const { value: valB, hasValue: hasB } = getYieldValue(b);
// 无“估值涨幅”展示值(界面为 `—`)的基金统一排在最后
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
return sortOrder === 'asc' ? valA - valB : valB - valA;
}
if (sortBy === 'holdingAmount') {
const pa = getHoldingProfit(a, holdings[a.code]);
const pb = getHoldingProfit(b, holdings[b.code]);
const amountA = pa?.amount ?? Number.NEGATIVE_INFINITY;
const amountB = pb?.amount ?? Number.NEGATIVE_INFINITY;
return sortOrder === 'asc' ? amountA - amountB : amountB - amountA;
}
if (sortBy === 'yesterdayIncrease') {
const valA = Number(a.zzl);
const valB = Number(b.zzl);
const hasA = Number.isFinite(valA);
const hasB = Number.isFinite(valB);
// 无昨日涨幅数据(界面展示为 `—`)的基金统一排在最后
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
return sortOrder === 'asc' ? valA - valB : valB - valA; return sortOrder === 'asc' ? valA - valB : valB - valA;
} }
if (sortBy === 'holding') { if (sortBy === 'holding') {
@@ -604,6 +794,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 =
@@ -679,6 +872,7 @@ export default function HomePage() {
estimateProfitPercent, estimateProfitPercent,
holdingAmount, holdingAmount,
holdingAmountValue, holdingAmountValue,
holdingDaysValue,
todayProfit, todayProfit,
todayProfitPercent, todayProfitPercent,
todayProfitValue, todayProfitValue,
@@ -742,7 +936,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);
}; };
@@ -899,6 +1107,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;
@@ -1281,7 +1494,7 @@ export default function HomePage() {
}); });
}; };
const confirmScanImport = async (targetGroupId = 'all') => { const confirmScanImport = async (targetGroupId = 'all', expandAfterAdd = true) => {
const codes = Array.from(selectedScannedCodes); const codes = Array.from(selectedScannedCodes);
if (codes.length === 0) { if (codes.length === 0) {
showToast('请至少选择一个基金代码', 'error'); showToast('请至少选择一个基金代码', 'error');
@@ -1337,6 +1550,8 @@ export default function HomePage() {
} }
if (newFunds.length > 0) { if (newFunds.length > 0) {
const newCodesSet = new Set(newFunds.map((f) => f.code));
setFunds(prev => { setFunds(prev => {
const updated = dedupeByCode([...newFunds, ...prev]); const updated = dedupeByCode([...newFunds, ...prev]);
storageHelper.setItem('funds', JSON.stringify(updated)); storageHelper.setItem('funds', JSON.stringify(updated));
@@ -1359,6 +1574,22 @@ export default function HomePage() {
}); });
if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries })); if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
if (!expandAfterAdd) {
// 用户关闭“添加后展开详情”:将新添加基金的卡片和业绩走势都标记为收起
setCollapsedCodes(prev => {
const next = new Set(prev);
newCodesSet.forEach((code) => next.add(code));
storageHelper.setItem('collapsedCodes', JSON.stringify(Array.from(next)));
return next;
});
setCollapsedTrends(prev => {
const next = new Set(prev);
newCodesSet.forEach((code) => next.add(code));
storageHelper.setItem('collapsedTrends', JSON.stringify(Array.from(next)));
return next;
});
}
if (targetGroupId === 'fav') { if (targetGroupId === 'fav') {
setFavorites(prev => { setFavorites(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -1490,7 +1721,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();
@@ -2182,6 +2415,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;
@@ -2590,19 +2846,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);
@@ -3004,9 +3282,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();
@@ -3020,44 +3299,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);
@@ -3329,7 +3583,6 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
const isAnyModalOpen = const isAnyModalOpen =
settingsOpen ||
feedbackOpen || feedbackOpen ||
addResultOpen || addResultOpen ||
addFundToGroupOpen || addFundToGroupOpen ||
@@ -3356,16 +3609,15 @@ export default function HomePage() {
isScanImporting; isScanImporting;
if (isAnyModalOpen) { if (isAnyModalOpen) {
document.body.style.overflow = 'hidden'; containerRef.current.style.overflow = 'hidden';
} else { } else {
document.body.style.overflow = ''; containerRef.current.style.overflow = '';
} }
return () => { return () => {
document.body.style.overflow = ''; containerRef.current.style.overflow = '';
}; };
}, [ }, [
settingsOpen,
feedbackOpen, feedbackOpen,
addResultOpen, addResultOpen,
addFundToGroupOpen, addFundToGroupOpen,
@@ -3558,7 +3810,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">
@@ -3784,10 +4036,18 @@ export default function HomePage() {
</div> </div>
</div> </div>
</div> </div>
{shouldShowMarketIndex && (
<MarketIndexAccordion
navbarHeight={navbarHeight}
onHeightChange={setMarketIndexAccordionHeight}
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={{ ...(isMobile ? {} : { top: navbarHeight }), marginTop: navbarHeight, 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 }}>
<div className="tabs-container"> <div className="tabs-container">
<div <div
className="tabs-scroll-area" className="tabs-scroll-area"
@@ -3887,49 +4147,102 @@ export default function HomePage() {
<div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} /> <div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} />
<div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 4 }}> <button
<SortIcon width="14" height="14" /> type="button"
排序 className="icon-button"
</span> onClick={() => setSortSettingOpen(true)}
<div className="chips"> style={{
{[ border: 'none',
{ id: 'default', label: '默认' }, background: 'transparent',
{ id: 'yield', label: '涨跌幅' }, padding: 0,
{ id: 'holding', label: '持有收益' }, display: 'flex',
{ id: 'name', label: '名称' }, alignItems: 'center',
].map((s) => ( gap: 4,
<button fontSize: '12px',
key={s.id} color: 'var(--muted-foreground)',
className={`chip ${sortBy === s.id ? 'active' : ''}`} cursor: 'pointer',
onClick={() => { width: '50px',
if (sortBy === s.id) { }}
// 同一按钮重复点击,切换升序/降序 title="排序个性化设置"
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); >
} else { <span className="muted">排序</span>
// 切换到新的排序字段,默认用降序 <SettingsIcon width="14" height="14" />
setSortBy(s.id); </button>
setSortOrder('desc'); {sortDisplayMode === 'dropdown' ? (
} <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
value={sortBy}
onValueChange={(nextSortBy) => {
setSortBy(nextSortBy);
if (nextSortBy !== sortBy) setSortOrder('desc');
}} }}
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
> >
<span>{s.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>
@@ -3947,50 +4260,15 @@ export default function HomePage() {
holdings={holdings} holdings={holdings}
groupName={getGroupName()} groupName={getGroupName()}
getProfit={getHoldingProfit} getProfit={getHoldingProfit}
stickyTop={navbarHeight + 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}
@@ -3999,6 +4277,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 + 右侧冻结操作列 */}
@@ -4007,7 +4286,7 @@ export default function HomePage() {
<div className="table-scroll-area"> <div className="table-scroll-area">
<div className="table-scroll-area-inner"> <div className="table-scroll-area-inner">
<PcFundTable <PcFundTable
stickyTop={navbarHeight + filterBarHeight} stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight}
data={pcFundTableData} data={pcFundTableData}
refreshing={refreshing} refreshing={refreshing}
currentTab={currentTab} currentTab={currentTab}
@@ -4089,7 +4368,7 @@ export default function HomePage() {
currentTab={currentTab} currentTab={currentTab}
favorites={favorites} favorites={favorites}
sortBy={sortBy} sortBy={sortBy}
stickyTop={navbarHeight + filterBarHeight - 14} stickyTop={navbarHeight + filterBarHeight + marketIndexAccordionHeight}
blockDrawerClose={!!fundDeleteConfirm} blockDrawerClose={!!fundDeleteConfirm}
closeDrawerRef={fundDetailDrawerCloseRef} closeDrawerRef={fundDetailDrawerCloseRef}
onReorder={handleReorder} onReorder={handleReorder}
@@ -4202,6 +4481,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>
@@ -4334,6 +4652,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>
@@ -4442,6 +4761,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>
@@ -4544,19 +4869,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 && (
@@ -4579,6 +4903,7 @@ export default function HomePage() {
setLoginSuccess(''); setLoginSuccess('');
setLoginEmail(''); setLoginEmail('');
setLoginOtp(''); setLoginOtp('');
setLoginLoading(false);
}} }}
loginEmail={loginEmail} loginEmail={loginEmail}
setLoginEmail={setLoginEmail} setLoginEmail={setLoginEmail}
@@ -4589,9 +4914,22 @@ export default function HomePage() {
loginSuccess={loginSuccess} loginSuccess={loginSuccess}
handleSendOtp={handleSendOtp} handleSendOtp={handleSendOtp}
handleVerifyEmailOtp={handleVerifyEmailOtp} handleVerifyEmailOtp={handleVerifyEmailOtp}
handleGithubLogin={isSupabaseConfigured ? handleGithubLogin : undefined}
/> />
)} )}
{/* 排序个性化设置弹框 */}
<SortSettingModal
open={sortSettingOpen}
onClose={() => setSortSettingOpen(false)}
isMobile={isMobile}
rules={sortRules}
onChangeRules={setSortRules}
sortDisplayMode={sortDisplayMode}
onChangeSortDisplayMode={setSortDisplayMode}
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
/>
{/* 全局轻提示 Toast */} {/* 全局轻提示 Toast */}
<AnimatePresence> <AnimatePresence>
{toast.show && ( {toast.show && (

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

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

View File

@@ -0,0 +1,64 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props} />
);
}
function AccordionTrigger({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}>
{children}
<ChevronDownIcon
className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}>
<div className={cn("pt-0 pb-4 w-full", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,15 +1,42 @@
"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"
import {CloseIcon} from "@/app/components/Icons";
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock";
function Dialog({ function Dialog({
open: openProp,
defaultOpen,
onOpenChange,
...props ...props
}) { }) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isControlled = openProp !== undefined;
const currentOpen = isControlled ? openProp : uncontrolledOpen;
// 使用全局 hook 统一处理 body 滚动锁定 & 恢复,避免弹窗打开时页面跳到顶部
useBodyScrollLock(currentOpen);
const handleOpenChange = React.useCallback(
(next) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return (
<DialogPrimitive.Root
data-slot="dialog"
open={isControlled ? openProp : undefined}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
{...props}
/>
);
} }
function DialogTrigger({ function DialogTrigger({
@@ -58,8 +85,11 @@ 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",
className className
)} )}
{...props}> {...props}>
@@ -68,7 +98,7 @@ function DialogContent({
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon /> <CloseIcon width="20" height="20" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}

View File

@@ -4,6 +4,27 @@ import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock"
const DrawerScrollLockContext = React.createContext(null)
/**
* 移动端滚动锁定:仅将 body 设为 position:fixed用负值 top 把页面“拉”回当前视口位置,
* 既锁定滚动又保留视觉位置overlay 上 ontouchmove preventDefault 防止背景触摸滚动。
*/
function useScrollLock(open) {
const onOverlayTouchMove = React.useCallback((e) => {
e.preventDefault()
}, [])
// 统一使用 app 级 hook 处理 body 滚动锁定 & 恢复,避免多处实现导致位移/跳顶问题
useBodyScrollLock(open)
return React.useMemo(
() => (open ? { onTouchMove: onOverlayTouchMove } : null),
[open, onOverlayTouchMove]
)
}
function parseVhToPx(vhStr) { function parseVhToPx(vhStr) {
if (typeof vhStr === "number") return vhStr if (typeof vhStr === "number") return vhStr
@@ -12,10 +33,17 @@ function parseVhToPx(vhStr) {
return (window.innerHeight * Number(match[1])) / 100 return (window.innerHeight * Number(match[1])) / 100
} }
function Drawer({ function Drawer({ open, ...props }) {
...props const scrollLock = useScrollLock(open)
}) { const contextValue = React.useMemo(
return <DrawerPrimitive.Root data-slot="drawer" {...props} />; () => ({ ...scrollLock, open: !!open }),
[scrollLock, open]
)
return (
<DrawerScrollLockContext.Provider value={contextValue}>
<DrawerPrimitive.Root modal={false} data-slot="drawer" open={open} {...props} />
</DrawerScrollLockContext.Provider>
)
} }
function DrawerTrigger({ function DrawerTrigger({
@@ -40,14 +68,26 @@ function DrawerOverlay({
className, className,
...props ...props
}) { }) {
const ctx = React.useContext(DrawerScrollLockContext)
const { open = false, ...scrollLockProps } = ctx || {}
// modal={false} 时 vaul 不渲染/隐藏 Overlay用自定义遮罩 div 保证始终有遮罩;点击遮罩关闭
return ( return (
<DrawerPrimitive.Overlay <DrawerPrimitive.Close asChild>
data-slot="drawer-overlay" <div
className={cn( data-slot="drawer-overlay"
"fixed inset-0 z-50 bg-[var(--drawer-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", data-state={open ? "open" : "closed"}
className role="button"
)} tabIndex={-1}
{...props} /> aria-label="关闭"
className={cn(
"fixed inset-0 z-50 cursor-default bg-[var(--drawer-overlay,rgba(0,0,0,0.45))] backdrop-blur-[6px]",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...scrollLockProps}
{...props}
/>
</DrawerPrimitive.Close>
); );
} }

View File

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

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

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

View File

@@ -15,6 +15,7 @@
**类型**: `Array<Object>` **类型**: `Array<Object>`
**默认值**: `[]` **默认值**: `[]`
**说明**: 存储用户添加的所有基金信息 **说明**: 存储用户添加的所有基金信息
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -46,6 +47,7 @@
**类型**: `Array<string>` **类型**: `Array<string>`
**默认值**: `[]` **默认值**: `[]`
**说明**: 存储用户标记为自选的基金代码列表 **说明**: 存储用户标记为自选的基金代码列表
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -68,6 +70,7 @@
**类型**: `Array<Object>` **类型**: `Array<Object>`
**默认值**: `[]` **默认值**: `[]`
**说明**: 存储用户创建的基金分组信息 **说明**: 存储用户创建的基金分组信息
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -92,6 +95,7 @@
**类型**: `Array<string>` **类型**: `Array<string>`
**默认值**: `[]` **默认值**: `[]`
**说明**: 存储用户收起的基金代码列表(用于折叠基金详情) **说明**: 存储用户收起的基金代码列表(用于折叠基金详情)
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -113,6 +117,7 @@
**类型**: `Array<string>` **类型**: `Array<string>`
**默认值**: `[]` **默认值**: `[]`
**说明**: 存储用户收起的业绩走势图表的基金代码列表 **说明**: 存储用户收起的业绩走势图表的基金代码列表
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -135,6 +140,7 @@
**默认值**: `'card'` **默认值**: `'card'`
**可选值**: `'card'` | `'list'` **可选值**: `'card'` | `'list'`
**说明**: 存储用户选择的视图模式 **说明**: 存储用户选择的视图模式
**云端同步**: 否(仅通过 customSettings 同步)
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -154,6 +160,7 @@
**默认值**: `30000` (30秒) **默认值**: `30000` (30秒)
**最小值**: `5000` (5秒) **最小值**: `5000` (5秒)
**说明**: 存储数据刷新间隔时间(毫秒) **说明**: 存储数据刷新间隔时间(毫秒)
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -172,6 +179,7 @@
**类型**: `Object` **类型**: `Object`
**默认值**: `{}` **默认值**: `{}`
**说明**: 存储用户的持仓信息 **说明**: 存储用户的持仓信息
**云端同步**: 是
**数据结构**: **数据结构**:
```javascript ```javascript
@@ -199,6 +207,7 @@
**类型**: `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 // 时间戳
} }
] ]
@@ -233,6 +241,7 @@
**类型**: `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
View 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
View 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";
}
}

84
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.4", "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.4", "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",
@@ -16,6 +16,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ahooks": "^3.9.6",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -504,6 +505,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -4591,6 +4601,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -5227,6 +5243,28 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ahooks": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz",
"integrity": "sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0",
"@types/js-cookie": "^3.0.6",
"dayjs": "^1.9.1",
"intersection-observer": "^0.12.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"react-fast-compare": "^3.2.2",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.0.0",
"tslib": "^2.4.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -5885,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": {
@@ -8288,6 +8326,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/intersection-observer": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz",
"integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==",
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
"license": "Apache-2.0"
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -8953,6 +8998,15 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -10868,6 +10922,12 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -11031,6 +11091,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -11250,6 +11316,18 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/screenfull": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.4", "version": "0.2.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -19,6 +19,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ahooks": "^3.9.6",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",