Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82bdecca0b | ||
|
|
cc605fb45b | ||
|
|
e8bd65e499 | ||
|
|
12229e8eeb | ||
|
|
fb0dc25341 | ||
|
|
b489677d3e | ||
|
|
104a847d2a | ||
|
|
0a97b80499 | ||
|
|
7c48e94a5d | ||
|
|
02669020bc | ||
|
|
ba1687bf97 | ||
|
|
ac591c54c4 | ||
|
|
26bb966f90 | ||
|
|
a7eb537e67 | ||
|
|
5d97f8f83e | ||
|
|
e80ee0cad1 | ||
|
|
139116a0d3 | ||
|
|
ab9e8a5072 | ||
|
|
ce559664f1 | ||
|
|
d05002fd86 | ||
|
|
1a59087cd9 | ||
|
|
d8a4db34fe | ||
|
|
37611ddff1 | ||
|
|
3dd11f961d | ||
|
|
510bee53e3 | ||
|
|
cb87906aa2 | ||
|
|
2ea3a26353 | ||
|
|
885a8fc782 | ||
|
|
89f745741b | ||
|
|
7296706bb2 | ||
|
|
c24b6fb069 | ||
|
|
bc5ed496aa | ||
|
|
c85e0021cd | ||
|
|
9ac773f0c2 | ||
|
|
b8f3af4486 | ||
|
|
e46ced6360 | ||
|
|
26821c2bd1 | ||
|
|
7c332cb89d | ||
|
|
631336097f | ||
|
|
5981440881 | ||
|
|
2816a6c0dd | ||
|
|
e1eb3ea8ca | ||
|
|
15df89a9dd | ||
|
|
8849b547ce | ||
|
|
7953b906a5 | ||
|
|
d00c8cf3eb | ||
|
|
966c853eb5 | ||
|
|
063be7d08e | ||
|
|
613b5f02e8 | ||
|
|
643b23b97c | ||
|
|
32df6fc196 | ||
|
|
8c55e97d9c | ||
|
|
efe61a825a | ||
|
|
5293a32748 | ||
|
|
c28dd2d278 | ||
|
|
6a719fad1e | ||
|
|
c10c4a5d0e | ||
|
|
bcfbc2bcde | ||
|
|
5200b9292b | ||
|
|
1e081167b3 | ||
|
|
f11fc46bce | ||
|
|
fb7c852705 | ||
|
|
391c631ccb | ||
|
|
79b0100d98 | ||
|
|
be91fad303 | ||
|
|
3530a8eeb2 | ||
|
|
4dc0988197 | ||
|
|
f3be5e759e | ||
|
|
11bb886209 | ||
|
|
8fee023dfd | ||
|
|
c71759153f | ||
|
|
a4a881860b | ||
|
|
95514eb52f | ||
|
|
9516a4f874 | ||
|
|
750e72823b | ||
|
|
c3515c7011 | ||
|
|
f39f152efa | ||
|
|
d4255fc1c8 | ||
|
|
480abbcf47 | ||
|
|
3ed129afb2 | ||
|
|
5f909cc669 | ||
|
|
f379c9fef5 | ||
|
|
412b22ec1c |
240
.cursor/skills/shadcn/SKILL.md
Normal file
240
.cursor/skills/shadcn/SKILL.md
Normal 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 (2–7 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 2–5 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
|
||||
5
.cursor/skills/shadcn/agents/openai.yml
Normal file
5
.cursor/skills/shadcn/agents/openai.yml
Normal 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"
|
||||
BIN
.cursor/skills/shadcn/assets/shadcn-small.png
Normal file
BIN
.cursor/skills/shadcn/assets/shadcn-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
.cursor/skills/shadcn/assets/shadcn.png
Normal file
BIN
.cursor/skills/shadcn/assets/shadcn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
255
.cursor/skills/shadcn/cli.md
Normal file
255
.cursor/skills/shadcn/cli.md
Normal 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.
|
||||
202
.cursor/skills/shadcn/customization.md
Normal file
202
.cursor/skills/shadcn/customization.md
Normal 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 (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
47
.cursor/skills/shadcn/evals/evals.json
Normal file
47
.cursor/skills/shadcn/evals/evals.json
Normal 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-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
94
.cursor/skills/shadcn/mcp.md
Normal file
94
.cursor/skills/shadcn/mcp.md
Normal 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`
|
||||
306
.cursor/skills/shadcn/rules/base-vs-radix.md
Normal file
306
.cursor/skills/shadcn/rules/base-vs-radix.md
Normal 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>
|
||||
```
|
||||
195
.cursor/skills/shadcn/rules/composition.md
Normal file
195
.cursor/skills/shadcn/rules/composition.md
Normal 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">` |
|
||||
192
.cursor/skills/shadcn/rules/forms.md
Normal file
192
.cursor/skills/shadcn/rules/forms.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 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 2–5 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 (2–7 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`.
|
||||
101
.cursor/skills/shadcn/rules/icons.md
Normal file
101
.cursor/skills/shadcn/rules/icons.md
Normal 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} />
|
||||
```
|
||||
162
.cursor/skills/shadcn/rules/styling.md
Normal file
162
.cursor/skills/shadcn/rules/styling.md
Normal 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
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
*.log
|
||||
|
||||
.cursor
|
||||
.trae
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
|
||||
*.env
|
||||
.env.*
|
||||
|
||||
*.md
|
||||
*.txt
|
||||
47
Dockerfile
47
Dockerfile
@@ -1,33 +1,36 @@
|
||||
# ===== 构建阶段 =====
|
||||
FROM node:22-bullseye AS builder
|
||||
# ===== 构建阶段(Alpine 减小体积)=====
|
||||
# 未传入的 build-arg 使用占位符,便于运行阶段用环境变量替换
|
||||
# Supabase 构建时会校验 URL,故使用合法占位 URL,运行时再替换
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ARG NEXT_PUBLIC_GA_ID
|
||||
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL=https://runtime-replace.supabase.co
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__
|
||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__
|
||||
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_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npx next build
|
||||
# ===== 运行阶段 =====
|
||||
FROM node:22-bullseye AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
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_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
|
||||
# ===== 运行阶段(仅静态资源 + nginx,启动时替换占位符)=====
|
||||
FROM nginx:alpine AS runner
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=builder /app/out .
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000 || exit 1
|
||||
CMD ["npm", "start"]
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
60
README.md
60
README.md
@@ -5,6 +5,16 @@
|
||||
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
|
||||
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=hzm0321%2Freal-time-fund&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
||||
@@ -79,7 +89,11 @@
|
||||
官方验证码位数默认为8位,可自行修改。常见一般为6位。
|
||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email → Minimum password length 和 Email OTP Length 都改为6位。
|
||||
|
||||
5. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
||||
5. 关闭确认邮件
|
||||
|
||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
||||
|
||||
6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
|
||||
|
||||
更多 Supabase 相关内容查阅官方文档。
|
||||
|
||||
@@ -97,18 +111,27 @@ npm run build
|
||||
|
||||
### Docker运行
|
||||
|
||||
需先配置环境变量(与本地开发一致),否则构建出的镜像中 Supabase 等配置为空。可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
||||
镜像支持两种配置方式:
|
||||
|
||||
1. 构建镜像(构建时会读取当前环境或同目录 `.env` 中的变量)
|
||||
- **构建时写入**:构建时通过 `--build-arg` 或 `.env` 传入 `NEXT_PUBLIC_*`,值会打进镜像,运行时无需再传。
|
||||
- **运行时替换**:构建时不传(或使用默认占位符),启动容器时通过 `-e` 或 `--env-file` 传入,入口脚本会在启动 Nginx 前替换静态资源中的占位符。
|
||||
|
||||
可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
||||
|
||||
1. 构建镜像
|
||||
```bash
|
||||
# 方式 A:运行时再注入配置(镜像内为占位符)
|
||||
docker build -t real-time-fund .
|
||||
# 或通过 --build-arg 传入,例如:
|
||||
# docker build -t real-time-fund --build-arg NEXT_PUBLIC_Supabase_URL=xxx --build-arg NEXT_PUBLIC_Supabase_ANON_KEY=xxx --build-arg NEXT_PUBLIC_GA_ID=G-xxxx .
|
||||
|
||||
# 方式 B:构建时写入配置
|
||||
docker build -t real-time-fund --build-arg NEXT_PUBLIC_SUPABASE_URL=xxx --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx .
|
||||
# 或依赖同目录 .env:docker compose build
|
||||
```
|
||||
|
||||
2. 启动容器
|
||||
```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 与运行环境)
|
||||
@@ -117,6 +140,29 @@ docker run -d -p 3000:3000 --name fund real-time-fund
|
||||
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`),点击“添加”。
|
||||
@@ -128,7 +174,7 @@ docker compose up -d
|
||||
|
||||
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
||||
|
||||
微信开发群人数已满200,如需加入请加微信号 `hzm1998hzm` 。加v备注:`基估宝开发`,邀请入群。
|
||||
<img src="./doc/weChatGroupDevelop.jpg" width="300">
|
||||
|
||||
## 📝 免责声明
|
||||
|
||||
|
||||
279
app/api/fund.js
279
app/api/fund.js
@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
|
||||
const nowInTz = () => dayjs().tz(TZ);
|
||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 获取基金「关联板块/跟踪标的」信息(走本地 API,并做 1 天缓存)
|
||||
* 接口:/api/related-sectors?code=xxxxxx
|
||||
* 返回:{ code: string, relatedSectors: string }
|
||||
*/
|
||||
export const fetchRelatedSectors = async (code, { cacheTime = ONE_DAY_MS } = {}) => {
|
||||
if (!code) return '';
|
||||
const normalized = String(code).trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
const url = `/api/related-sectors?code=${encodeURIComponent(normalized)}`;
|
||||
const cacheKey = `relatedSectors:${normalized}`;
|
||||
|
||||
try {
|
||||
const data = await cachedRequest(async () => {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}, cacheKey, { cacheTime });
|
||||
|
||||
const relatedSectors = data?.relatedSectors;
|
||||
return relatedSectors ? String(relatedSectors).trim() : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScript = (url) => {
|
||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||
|
||||
@@ -341,8 +370,12 @@ export const fetchFundData = async (c) => {
|
||||
let name = '';
|
||||
let weight = '';
|
||||
if (idxCode >= 0 && tds[idxCode]) {
|
||||
const m = tds[idxCode].match(/(\d{6})/);
|
||||
code = m ? m[1] : tds[idxCode];
|
||||
const raw = String(tds[idxCode] || '').trim();
|
||||
const mA = raw.match(/(\d{6})/);
|
||||
const mHK = raw.match(/(\d{5})/);
|
||||
// 海外股票常见为英文代码(如 AAPL / usAAPL / TSLA.US / 0700.HK)
|
||||
const mAlpha = raw.match(/\b([A-Za-z]{1,10})\b/);
|
||||
code = mA ? mA[1] : (mHK ? mHK[1] : (mAlpha ? mAlpha[1].toUpperCase() : raw));
|
||||
} else {
|
||||
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
||||
if (codeIdx >= 0) code = tds[codeIdx];
|
||||
@@ -365,20 +398,67 @@ export const fetchFundData = async (c) => {
|
||||
}
|
||||
}
|
||||
holdings = holdings.slice(0, 10);
|
||||
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
|
||||
const normalizeTencentCode = (input) => {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
// already normalized tencent styles (normalize prefix casing)
|
||||
const mPref = raw.match(/^(us|hk|sh|sz|bj)(.+)$/i);
|
||||
if (mPref) {
|
||||
const p = mPref[1].toLowerCase();
|
||||
const rest = String(mPref[2] || '').trim();
|
||||
// usAAPL / usIXIC: rest use upper; hk00700 keep digits
|
||||
return `${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||
}
|
||||
const mSPref = raw.match(/^s_(sh|sz|bj|hk)(.+)$/i);
|
||||
if (mSPref) {
|
||||
const p = mSPref[1].toLowerCase();
|
||||
const rest = String(mSPref[2] || '').trim();
|
||||
return `s_${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// A股/北证
|
||||
if (/^\d{6}$/.test(raw)) {
|
||||
const pfx =
|
||||
raw.startsWith('6') || raw.startsWith('9')
|
||||
? 'sh'
|
||||
: raw.startsWith('4') || raw.startsWith('8')
|
||||
? 'bj'
|
||||
: 'sz';
|
||||
return `s_${pfx}${raw}`;
|
||||
}
|
||||
// 港股(数字)
|
||||
if (/^\d{5}$/.test(raw)) return `s_hk${raw}`;
|
||||
|
||||
// 形如 0700.HK / 00001.HK
|
||||
const mHkDot = raw.match(/^(\d{4,5})\.(?:HK)$/i);
|
||||
if (mHkDot) return `s_hk${mHkDot[1].padStart(5, '0')}`;
|
||||
|
||||
// 形如 AAPL / TSLA.US / AAPL.O / BRK.B(腾讯接口对“.”支持不稳定,优先取主代码)
|
||||
const mUsDot = raw.match(/^([A-Za-z]{1,10})(?:\.[A-Za-z]{1,6})$/);
|
||||
if (mUsDot) return `us${mUsDot[1].toUpperCase()}`;
|
||||
if (/^[A-Za-z]{1,10}$/.test(raw)) return `us${raw.toUpperCase()}`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTencentVarName = (tencentCode) => {
|
||||
const cd = String(tencentCode || '').trim();
|
||||
if (!cd) return '';
|
||||
// s_* uses v_s_*
|
||||
if (/^s_/i.test(cd)) return `v_${cd}`;
|
||||
// us/hk/sh/sz/bj uses v_{code}
|
||||
return `v_${cd}`;
|
||||
};
|
||||
|
||||
const needQuotes = holdings
|
||||
.map((h) => ({
|
||||
h,
|
||||
tencentCode: normalizeTencentCode(h.code),
|
||||
}))
|
||||
.filter((x) => Boolean(x.tencentCode));
|
||||
if (needQuotes.length) {
|
||||
try {
|
||||
const tencentCodes = needQuotes.map(h => {
|
||||
const cd = String(h.code || '');
|
||||
if (/^\d{6}$/.test(cd)) {
|
||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||
return `s_${pfx}${cd}`;
|
||||
}
|
||||
if (/^\d{5}$/.test(cd)) {
|
||||
return `s_hk${cd}`;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean).join(',');
|
||||
const tencentCodes = needQuotes.map((x) => x.tencentCode).join(',');
|
||||
if (!tencentCodes) {
|
||||
resolveH(holdings);
|
||||
return;
|
||||
@@ -388,22 +468,15 @@ export const fetchFundData = async (c) => {
|
||||
const scriptQuote = document.createElement('script');
|
||||
scriptQuote.src = quoteUrl;
|
||||
scriptQuote.onload = () => {
|
||||
needQuotes.forEach(h => {
|
||||
const cd = String(h.code || '');
|
||||
let varName = '';
|
||||
if (/^\d{6}$/.test(cd)) {
|
||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||
varName = `v_s_${pfx}${cd}`;
|
||||
} else if (/^\d{5}$/.test(cd)) {
|
||||
varName = `v_s_hk${cd}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const dataStr = window[varName];
|
||||
needQuotes.forEach(({ h, tencentCode }) => {
|
||||
const varName = getTencentVarName(tencentCode);
|
||||
const dataStr = varName ? window[varName] : null;
|
||||
if (dataStr) {
|
||||
const parts = dataStr.split('~');
|
||||
if (parts.length > 5) {
|
||||
h.change = parseFloat(parts[5]);
|
||||
const isUS = /^us/i.test(String(tencentCode || ''));
|
||||
const idx = isUS ? 32 : 5;
|
||||
if (parts.length > idx) {
|
||||
h.change = parseFloat(parts[idx]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -513,6 +586,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 () => {
|
||||
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
|
||||
if (!url) return null;
|
||||
@@ -591,7 +749,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||
};
|
||||
};
|
||||
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('无浏览器环境'));
|
||||
@@ -685,23 +843,62 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
default: start = start.subtract(1, 'month');
|
||||
}
|
||||
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend,
|
||||
// 同时附带 Data_grandTotal(若存在,格式为 [{ name, data: [[ts, val], ...] }, ...])
|
||||
try {
|
||||
const pz = await fetchFundPingzhongdata(code);
|
||||
const trend = pz?.Data_netWorthTrend;
|
||||
const grandTotal = pz?.Data_grandTotal;
|
||||
|
||||
if (Array.isArray(trend) && trend.length) {
|
||||
const startMs = start.startOf('day').valueOf();
|
||||
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
||||
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) => {
|
||||
const value = Number(d.y);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
|
||||
return { date, value };
|
||||
});
|
||||
|
||||
// 解析 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;
|
||||
}
|
||||
@@ -711,8 +908,20 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const API_KEYS = [
|
||||
'sk-5b03d4e02ec22dd2ba233fb6d2dd549b',
|
||||
'sk-5f14ce9c6e94af922bf592942426285c'
|
||||
// 添加更多 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) => {
|
||||
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
|
||||
const apiKey = getRandomApiKey();
|
||||
if (!apiKey || !text) return null;
|
||||
|
||||
try {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,14 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, PlusIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClose, onAdd }) {
|
||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdings = {}, onClose, onAdd }) {
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
|
||||
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
||||
|
||||
const getHoldingAmount = (fund) => {
|
||||
const holding = holdings[fund?.code];
|
||||
if (!holding || !holding.share || holding.share <= 0) return null;
|
||||
const nav = Number(fund?.dwjz) || Number(fund?.gsz) || Number(fund?.estGsz) || 0;
|
||||
if (!nav) return null;
|
||||
return holding.share * nav;
|
||||
};
|
||||
|
||||
const toggleSelect = (code) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -18,24 +30,21 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '500px', width: '90vw' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加基金到分组</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<PlusIcon width="20" height="20" />
|
||||
@@ -63,9 +72,14 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
<div className="checkbox" style={{ marginRight: 12 }}>
|
||||
{selected.has(fund.code) && <div className="checked-mark" />}
|
||||
</div>
|
||||
<div className="fund-info" style={{ flex: 1 }}>
|
||||
<div className="fund-info" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{fund.name}</div>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund.code}</div>
|
||||
{getHoldingAmount(fund) != null && (
|
||||
<div className="muted" style={{ fontSize: '12px', marginTop: 2 }}>
|
||||
持仓金额:<span style={{ color: 'var(--foreground)', fontWeight: 500 }}>¥{getHoldingAmount(fund).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -84,7 +98,7 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
确定 ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker } from './Common';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
const [type, setType] = useState('');
|
||||
@@ -77,30 +82,36 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="添加历史记录"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 1200 }}
|
||||
>
|
||||
<motion.div
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
style={{ maxWidth: '420px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 9998 }}
|
||||
style={{ maxWidth: '420px', zIndex: 9999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加历史记录</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<span>添加历史记录</span>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon />
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={handleCloseClick}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -200,15 +211,18 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
*此处补录的买入/卖出仅作记录展示,不会改变当前持仓金额与份额;实际持仓请在持仓设置中维护。
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary full-width"
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={!type || !date || !netValue || !amount || !share || loading}
|
||||
>
|
||||
确认添加
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v13';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v19';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -75,12 +75,16 @@ export default function Announcement() {
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.2 版本更新内容如下:</p>
|
||||
<p>1. 新增 ios pwa 应用支持。</p>
|
||||
<p>2. 引入 shadcn ui 组件库,逐步调整项目样式。</p>
|
||||
<p>3. 列表模式表头固定。</p>
|
||||
<p>4. 列表模式点击名称,展示基金详情弹框。</p>
|
||||
<p>注:强烈建议苹果用户通过 Safari 浏览器→分享→添加应用到主屏幕,实现保存网页成APP效果。(安卓同理)</p>
|
||||
<p>v0.2.8 更新内容:</p>
|
||||
<p>1. 增加关联板块列。</p>
|
||||
<p>2. 设置持仓支持今日首次买入。</p>
|
||||
<p>3. 加仓自动获取费率。</p>
|
||||
<br/>
|
||||
<p>下一版本更新内容:</p>
|
||||
<p>1. 关联板块实时估值。</p>
|
||||
<p>2. 收益曲线。</p>
|
||||
<p>3. 估值差异列。</p>
|
||||
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, CloudIcon } from './Icons';
|
||||
|
||||
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
|
||||
const [pendingAction, setPendingAction] = useState(null); // 'local' | 'cloud' | null
|
||||
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 (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
@@ -41,14 +84,25 @@ export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }
|
||||
: '是否将本地配置同步到云端?'}
|
||||
</p>
|
||||
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
|
||||
<button className="button" onClick={onConfirm}>
|
||||
<button className="button secondary" onClick={handlePrimaryClick}>
|
||||
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
|
||||
</button>
|
||||
<button className="button secondary" onClick={onCancel}>
|
||||
<button className="button" onClick={handleSecondaryClick}>
|
||||
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
{pendingAction && (
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
message={confirmMessage}
|
||||
onConfirm={handleConfirmModalConfirm}
|
||||
onCancel={handleConfirmModalCancel}
|
||||
confirmText="确认覆盖"
|
||||
icon={<CloudIcon width="20" height="20" />}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,19 +15,31 @@ export default function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确定删除',
|
||||
icon,
|
||||
confirmVariant = 'danger', // 'danger' | 'primary' | 'secondary'
|
||||
}) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) onCancel();
|
||||
};
|
||||
|
||||
const confirmButtonToneClass =
|
||||
confirmVariant === 'primary'
|
||||
? 'button'
|
||||
: confirmVariant === 'secondary'
|
||||
? 'button secondary'
|
||||
: 'button danger';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="!z-[12000]"
|
||||
showCloseButton={false}
|
||||
className="max-w-[400px] flex flex-col gap-5 p-6"
|
||||
className="!z-[12010] max-w-[400px] flex flex-col gap-5 p-6"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center gap-3 text-left">
|
||||
{icon || (
|
||||
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||
)}
|
||||
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||
@@ -43,7 +55,7 @@ export default function ConfirmModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button danger min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
className={`${confirmButtonToneClass} min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import { isNumber } from 'lodash';
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -170,24 +174,35 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="定投设置"
|
||||
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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal dca-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{
|
||||
maxWidth: '420px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 999,
|
||||
width: '90vw',
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">定投设置</DialogTitle>
|
||||
<div
|
||||
className="scrollbar-y-styled"
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -335,8 +350,15 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
* 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: 12,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary dca-cancel-btn"
|
||||
@@ -346,17 +368,18 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={!isValid()}
|
||||
onClick={handleSubmit}
|
||||
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
||||
>
|
||||
保存定投
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ const getBrowserTimeZone = () => {
|
||||
const TZ = getBrowserTimeZone();
|
||||
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({
|
||||
fund: f,
|
||||
todayStr,
|
||||
@@ -59,6 +70,7 @@ export default function FundCard({
|
||||
onToggleCollapse,
|
||||
onToggleTrendCollapse,
|
||||
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||
masked = false,
|
||||
}) {
|
||||
const holding = holdings[f?.code];
|
||||
const profit = getHoldingProfit?.(f, holding) ?? null;
|
||||
@@ -69,7 +81,7 @@ export default function FundCard({
|
||||
boxShadow: 'none',
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
background: 'transparent',
|
||||
background: theme === 'light' ? 'rgb(250,250,250)' : 'none',
|
||||
} : {};
|
||||
|
||||
return (
|
||||
@@ -90,6 +102,7 @@ export default function FundCard({
|
||||
e.stopPropagation();
|
||||
onRemoveFromGroup?.(f.code);
|
||||
}}
|
||||
style={{backgroundColor: 'transparent'}}
|
||||
title="从当前分组移除"
|
||||
>
|
||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||
@@ -124,7 +137,11 @@ export default function FundCard({
|
||||
<div className="actions">
|
||||
<div className="badge-v">
|
||||
<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 className="row" style={{ gap: 4 }}>
|
||||
<button
|
||||
@@ -226,27 +243,29 @@ export default function FundCard({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
|
||||
onClick={() => onHoldingClick?.(f)}
|
||||
>
|
||||
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
|
||||
未设置 <SettingsIcon width="12" height="12" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="stat"
|
||||
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
|
||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => onActionClick?.(f)}
|
||||
>
|
||||
<span
|
||||
className="label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
|
||||
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
|
||||
</span>
|
||||
<span className="value">
|
||||
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
||||
</span>
|
||||
<span className="value">¥{profit.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">当日收益</span>
|
||||
@@ -262,7 +281,9 @@ export default function FundCard({
|
||||
}`}
|
||||
>
|
||||
{profit.profitToday != null
|
||||
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
? masked
|
||||
? '******'
|
||||
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -288,6 +309,9 @@ export default function FundCard({
|
||||
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
|
||||
}`}
|
||||
>
|
||||
{masked
|
||||
? '******'
|
||||
: <>
|
||||
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
|
||||
{percentModes?.[f.code]
|
||||
? `${Math.abs(
|
||||
@@ -296,6 +320,7 @@ export default function FundCard({
|
||||
: 0,
|
||||
).toFixed(2)}%`
|
||||
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
|
||||
</>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -357,6 +382,15 @@ export default function FundCard({
|
||||
</TabsList>
|
||||
{hasHoldings && (
|
||||
<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">
|
||||
{f.holdings.map((h, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
@@ -384,7 +418,8 @@ export default function FundCard({
|
||||
code={f.code}
|
||||
isExpanded
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||
theme={theme}
|
||||
hideHeader
|
||||
/>
|
||||
@@ -455,7 +490,8 @@ export default function FundCard({
|
||||
code={f.code}
|
||||
isExpanded={!collapsedTrends?.has(f.code)}
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
|
||||
221
app/components/FundHistoryNetValue.jsx
Normal file
221
app/components/FundHistoryNetValue.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
app/components/FundHistoryNetValueModal.jsx
Normal file
321
app/components/FundHistoryNetValueModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {cachedRequest} from "../lib/cacheRequest";
|
||||
import { cachedRequest } from '../lib/cacheRequest';
|
||||
import FundHistoryNetValue from './FundHistoryNetValue';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@@ -55,12 +56,19 @@ function getChartThemeColors(theme) {
|
||||
}
|
||||
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const chartRef = 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]);
|
||||
|
||||
@@ -118,10 +126,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const lineColor = change >= 0 ? upColor : downColor;
|
||||
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(() => {
|
||||
// Calculate percentage change based on the first data point
|
||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
||||
const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
||||
// Data_grandTotal:在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
|
||||
const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
|
||||
|
||||
// Map transaction dates to chart indices
|
||||
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 {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
type: 'line',
|
||||
label: '涨跌幅',
|
||||
label: '本基金',
|
||||
data: percentageData,
|
||||
borderColor: lineColor,
|
||||
backgroundColor: (context) => {
|
||||
@@ -164,9 +230,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
tension: 0.2,
|
||||
order: 2
|
||||
},
|
||||
...grandDatasets,
|
||||
{
|
||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||
label: '买入',
|
||||
isTradePoint: true,
|
||||
data: buyPoints,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
@@ -180,6 +248,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
{
|
||||
type: 'line',
|
||||
label: '卖出',
|
||||
isTradePoint: true,
|
||||
data: sellPoints,
|
||||
borderColor: '#ffffff',
|
||||
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]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
@@ -264,9 +333,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
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 事件不稳定
|
||||
},
|
||||
onClick: () => {}
|
||||
onClick: (_event, elements) => {
|
||||
if (Array.isArray(elements) && elements.length > 0) {
|
||||
const idx = elements[0].index;
|
||||
setActiveIndex(typeof idx === 'number' ? idx : null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
@@ -300,6 +382,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||
}
|
||||
chart.update();
|
||||
clearActiveIndexRef.current?.();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
@@ -373,27 +456,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
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
|
||||
// Index 1 is Buy, Index 2 is Sell
|
||||
if (!activeElements?.length && datasets[1] && datasets[1].data) {
|
||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
||||
// datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
|
||||
const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
|
||||
const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
|
||||
|
||||
if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
|
||||
const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstBuyIndex !== -1) {
|
||||
let sellIndex = -1;
|
||||
if (datasets[2] && datasets[2].data) {
|
||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||
sellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
}
|
||||
const isCollision = (firstBuyIndex === sellIndex);
|
||||
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
||||
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) {
|
||||
const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstSellIndex !== -1) {
|
||||
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
||||
drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle active elements (hover crosshair)
|
||||
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
|
||||
if (activeElements && activeElements.length) {
|
||||
const activePoint = activeElements[0];
|
||||
const x = activePoint.element.x;
|
||||
@@ -424,15 +515,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Draw Axis Labels based on the first point (main line)
|
||||
const datasetIndex = activePoint.datasetIndex;
|
||||
const index = activePoint.index;
|
||||
|
||||
// Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0)作为数值来源,
|
||||
// 避免对比线在悬停时显示自己的数值标签
|
||||
const baseIndex = activePoint.index;
|
||||
const labels = chart.data.labels;
|
||||
const mainDataset = datasets[0];
|
||||
|
||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
||||
const dateStr = labels[index];
|
||||
const value = datasets[datasetIndex].data[index];
|
||||
if (labels && mainDataset && Array.isArray(mainDataset.data)) {
|
||||
const dateStr = labels[baseIndex];
|
||||
const value = mainDataset.data[baseIndex];
|
||||
|
||||
if (dateStr !== undefined && value !== undefined) {
|
||||
// X axis label (date) with boundary clamping
|
||||
@@ -448,7 +539,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||
|
||||
// Y axis label (value)
|
||||
// Y axis label (value) — 始终基于主线百分比
|
||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = primaryColor;
|
||||
@@ -459,29 +550,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
}
|
||||
|
||||
// Check for collision between Buy (1) and Sell (2) in active elements
|
||||
const activeBuy = activeElements.find(e => e.datasetIndex === 1);
|
||||
const activeSell = activeElements.find(e => e.datasetIndex === 2);
|
||||
// Check for collision between Buy and Sell in active elements
|
||||
const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
|
||||
const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
|
||||
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 => {
|
||||
const dsIndex = element.datasetIndex;
|
||||
// Only for transaction datasets (index > 0)
|
||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
||||
const label = datasets[dsIndex].label;
|
||||
// Determine background color based on dataset index
|
||||
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||
const ds = datasets?.[dsIndex];
|
||||
if (!isBuyOrSellDataset(ds)) return;
|
||||
|
||||
// If collision, offset Buy label upwards
|
||||
const label = ds.label;
|
||||
const bgColor = label === '买入' ? primaryColor : colors.danger;
|
||||
|
||||
// 如果买入/卖出在同一天,买入标签上移避免遮挡
|
||||
let yOffset = 0;
|
||||
if (isCollision && dsIndex === 1) {
|
||||
if (isCollision && label === '买入') {
|
||||
yOffset = -20;
|
||||
}
|
||||
|
||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
@@ -490,8 +579,166 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}];
|
||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||
|
||||
const lastIndex = data.length > 0 ? data.length - 1 : null;
|
||||
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
|
||||
|
||||
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) &&
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const rawPoint = pointsByDate.get(targetDate);
|
||||
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' }}>
|
||||
{loading && (
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
@@ -522,6 +769,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FundHistoryNetValue code={code} range={range} theme={theme} />
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
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 { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
|
||||
|
||||
@@ -56,33 +57,27 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
||||
const isAllValid = items.every(it => it.name.trim() !== '');
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="管理分组"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<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 }}
|
||||
<DialogContent
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '500px', width: '90vw' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogTitle asChild>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>管理分组</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -178,7 +173,8 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AnimatePresence>
|
||||
{deleteConfirm && (
|
||||
@@ -190,6 +186,6 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, PlusIcon } from './Icons';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldLabel, FieldContent } from '@/components/ui/field';
|
||||
import { PlusIcon, CloseIcon } from './Icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function GroupModal({ onClose, onConfirm }) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="新增分组"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose?.();
|
||||
}}
|
||||
>
|
||||
<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' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<DialogContent
|
||||
overlayClassName="modal-overlay z-[9999]"
|
||||
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<PlusIcon width="20" height="20" />
|
||||
<span>新增分组</span>
|
||||
<div className="glass card modal !max-w-[280px] !w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PlusIcon className="w-5 h-5 shrink-0 text-[var(--foreground)]" aria-hidden />
|
||||
<DialogTitle asChild>
|
||||
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 20 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称(最多 8 个字)</label>
|
||||
|
||||
<Field className="mb-5">
|
||||
<FieldLabel htmlFor="group-modal-name" className="text-sm text-[var(--muted-foreground)] mb-2 block">
|
||||
分组名称(最多 8 个字)
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<input
|
||||
className="input"
|
||||
id="group-modal-name"
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-xl border border-[var(--border)] bg-[var(--input)] px-3.5 py-2 text-sm text-[var(--foreground)] outline-none',
|
||||
'placeholder:text-[var(--muted-foreground)]',
|
||||
'transition-colors duration-200 focus:border-[var(--ring)] focus:ring-2 focus:ring-[var(--ring)]/20 focus:ring-offset-2 focus:ring-offset-[var(--card)]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
autoFocus
|
||||
placeholder="请输入分组名称..."
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value || '';
|
||||
// 限制最多 8 个字符(兼容中英文),超出部分自动截断
|
||||
setName(v.slice(0, 8));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) onConfirm(name.trim());
|
||||
}}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1 h-11 rounded-xl cursor-pointer bg-[var(--secondary)] text-[var(--foreground)] hover:bg-[var(--secondary)]/80 border border-[var(--border)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-11 rounded-xl cursor-pointer"
|
||||
onClick={() => name.trim() && onConfirm(name.trim())}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button className="button" onClick={() => name.trim() && onConfirm(name.trim())} disabled={!name.trim()} style={{ flex: 1 }}>确定</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,11 @@ export default function GroupSummary({
|
||||
groupName,
|
||||
getProfit,
|
||||
stickyTop,
|
||||
masked,
|
||||
onToggleMasked,
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(false);
|
||||
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
@@ -74,6 +76,31 @@ 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(() => {
|
||||
if (typeof masked === 'boolean') {
|
||||
setIsMasked(masked);
|
||||
}
|
||||
}, [masked]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let totalAsset = 0;
|
||||
let totalProfitToday = 0;
|
||||
@@ -90,7 +117,8 @@ export default function GroupSummary({
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
if (profit.profitToday != null) {
|
||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
|
||||
totalProfitToday += profit.profitToday;
|
||||
hasAnyTodayData = true;
|
||||
}
|
||||
if (profit.profitTotal !== null) {
|
||||
@@ -102,11 +130,14 @@ export default function GroupSummary({
|
||||
}
|
||||
});
|
||||
|
||||
// 将当日收益总和四舍五入到两位小数,和卡片展示保持一致
|
||||
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAsset,
|
||||
totalProfitToday,
|
||||
totalProfitToday: roundedTotalProfitToday,
|
||||
totalHoldingReturn,
|
||||
hasHolding,
|
||||
returnRate,
|
||||
@@ -185,7 +216,13 @@ export default function GroupSummary({
|
||||
</div>
|
||||
<button
|
||||
className="fav-button"
|
||||
onClick={() => setIsMasked((value) => !value)}
|
||||
onClick={() => {
|
||||
if (onToggleMasked) {
|
||||
onToggleMasked();
|
||||
} else {
|
||||
setIsMasked((value) => !value);
|
||||
}
|
||||
}}
|
||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||
style={{
|
||||
margin: 0,
|
||||
@@ -211,6 +248,7 @@ export default function GroupSummary({
|
||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||
{isMasked ? (
|
||||
<span
|
||||
className="mask-text"
|
||||
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||
>
|
||||
******
|
||||
@@ -245,7 +283,9 @@ export default function GroupSummary({
|
||||
}}
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||
******
|
||||
</span>
|
||||
) : summary.hasAnyTodayData ? (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
@@ -298,7 +338,9 @@ export default function GroupSummary({
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||
******
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
|
||||
@@ -1,52 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, SettingsIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory, pendingCount }) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="持仓操作"
|
||||
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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '320px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '320px', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">持仓操作</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>持仓操作</span>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={() => onAction('history')}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: '4px 8px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text)',
|
||||
cursor: 'pointer',
|
||||
height: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
gap: 4,
|
||||
position: 'relative',
|
||||
}}
|
||||
title="查看交易记录"
|
||||
>
|
||||
<span>📜</span>
|
||||
<span>交易记录</span>
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ef4444',
|
||||
border: '2px solid var(--background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
@@ -92,13 +104,13 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
background: 'linear-gradient(180deg, #ef4444, #f87171)',
|
||||
border: 'none',
|
||||
color: '#2b0b0b',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
清空持仓
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CloseIcon, SettingsIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) {
|
||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||
|
||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||
const dwjzRef = useRef(dwjz);
|
||||
useEffect(() => {
|
||||
dwjzRef.current = dwjz;
|
||||
}, [dwjz]);
|
||||
|
||||
const [share, setShare] = useState('');
|
||||
const [cost, setCost] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [profit, setProfit] = useState('');
|
||||
|
||||
const holdingSig = useMemo(() => {
|
||||
if (!holding) return '';
|
||||
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
|
||||
}, [holding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (holding) {
|
||||
const s = holding.share || 0;
|
||||
@@ -21,14 +34,17 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
setShare(String(s));
|
||||
setCost(String(c));
|
||||
|
||||
if (dwjz > 0) {
|
||||
const a = s * dwjz;
|
||||
const p = (dwjz - c) * s;
|
||||
const price = dwjzRef.current;
|
||||
if (price > 0) {
|
||||
const a = s * price;
|
||||
const p = (price - c) * s;
|
||||
setAmount(a.toFixed(2));
|
||||
setProfit(p.toFixed(2));
|
||||
}
|
||||
}
|
||||
}, [holding, fund, dwjz]);
|
||||
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [holdingSig]);
|
||||
|
||||
const handleModeChange = (newMode) => {
|
||||
if (newMode === mode) return;
|
||||
@@ -89,29 +105,42 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
? (share && cost && !isNaN(share) && !isNaN(cost))
|
||||
: (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑持仓"
|
||||
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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '400px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '400px', zIndex: 999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">编辑持仓</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>设置持仓</span>
|
||||
{typeof onOpenTrade === 'function' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTrade}
|
||||
className="button secondary"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--primary)',
|
||||
}}
|
||||
>
|
||||
今日买入?去加仓。
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
@@ -238,7 +267,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
505
app/components/MarketIndexAccordion.jsx
Normal file
505
app/components/MarketIndexAccordion.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
474
app/components/MarketSettingModal.jsx
Normal file
474
app/components/MarketSettingModal.jsx
Normal 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);
|
||||
}
|
||||
|
||||
83
app/components/MobileFundCardDrawer.jsx
Normal file
83
app/components/MobileFundCardDrawer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -24,19 +24,14 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { throttle } from 'lodash';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer';
|
||||
import FitText from './FitText';
|
||||
import FundCard from './FundCard';
|
||||
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { CloseIcon, ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'relatedSector',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
@@ -46,6 +41,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'estimateNav',
|
||||
];
|
||||
const MOBILE_COLUMN_HEADERS = {
|
||||
relatedSector: '关联板块',
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨幅',
|
||||
@@ -55,6 +51,8 @@ const MOBILE_COLUMN_HEADERS = {
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
|
||||
const RowSortableContext = createContext(null);
|
||||
|
||||
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,7 +82,9 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
style={{ ...style, position: 'relative' }}
|
||||
{...attributes}
|
||||
>
|
||||
<RowSortableContext.Provider value={{ setActivatorNodeRef, listeners }}>
|
||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||
</RowSortableContext.Provider>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用底部弹框展示卡片视图
|
||||
* @param {boolean} [props.masked] - 是否隐藏持仓相关金额
|
||||
*/
|
||||
export default function MobileFundTable({
|
||||
data = [],
|
||||
@@ -122,10 +123,14 @@ export default function MobileFundTable({
|
||||
getFundCardProps,
|
||||
blockDrawerClose = false,
|
||||
closeDrawerRef,
|
||||
masked = false,
|
||||
}) {
|
||||
const [isNameSortMode, setIsNameSortMode] = useState(false);
|
||||
|
||||
// 排序模式下拖拽手柄无需长按,直接拖动即可;非排序模式长按整行触发拖拽
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { delay: 400, tolerance: 5 },
|
||||
activationConstraint: isNameSortMode ? { delay: 0, tolerance: 5 } : { delay: 400, tolerance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
@@ -231,6 +236,8 @@ export default function MobileFundTable({
|
||||
const defaultVisibility = (() => {
|
||||
const o = {};
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
o.relatedSector = false;
|
||||
return o;
|
||||
})();
|
||||
|
||||
@@ -243,7 +250,11 @@ export default function MobileFundTable({
|
||||
})();
|
||||
const mobileColumnVisibility = (() => {
|
||||
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
return next;
|
||||
}
|
||||
return defaultVisibility;
|
||||
})();
|
||||
|
||||
@@ -297,6 +308,19 @@ export default function MobileFundTable({
|
||||
};
|
||||
|
||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sortBy !== 'default') setIsNameSortMode(false);
|
||||
}, [sortBy]);
|
||||
|
||||
// 排序模式下,点击页面任意区域(含表格外)退出排序;使用冒泡阶段,避免先于排序按钮处理
|
||||
useEffect(() => {
|
||||
if (!isNameSortMode) return;
|
||||
const onDocClick = () => setIsNameSortMode(false);
|
||||
document.addEventListener('click', onDocClick);
|
||||
return () => document.removeEventListener('click', onDocClick);
|
||||
}, [isNameSortMode]);
|
||||
|
||||
const [cardSheetRow, setCardSheetRow] = useState(null);
|
||||
const tableContainerRef = useRef(null);
|
||||
const portalHeaderRef = useRef(null);
|
||||
@@ -334,13 +358,19 @@ export default function MobileFundTable({
|
||||
const nextStickyTop = getEffectiveStickyTop();
|
||||
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
|
||||
|
||||
const tableRect = tableContainerRef.current?.getBoundingClientRect();
|
||||
const tableEl = tableContainerRef.current;
|
||||
const tableRect = tableEl?.getBoundingClientRect();
|
||||
if (!tableRect) {
|
||||
setShowPortalHeader(window.scrollY >= nextStickyTop);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPortalHeader(tableRect.top <= nextStickyTop);
|
||||
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||
const hasPassedHeader = (tableRect.top + headerHeight) <= nextStickyTop;
|
||||
const hasTableInView = tableRect.bottom > nextStickyTop;
|
||||
|
||||
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||
};
|
||||
|
||||
const throttledVerticalUpdate = throttle(updateVerticalState, 1000/60, { leading: true, trailing: true });
|
||||
@@ -401,6 +431,7 @@ export default function MobileFundTable({
|
||||
const LAST_COLUMN_EXTRA = 12;
|
||||
const FALLBACK_WIDTHS = {
|
||||
fundName: 140,
|
||||
relatedSector: 120,
|
||||
latestNav: 64,
|
||||
estimateNav: 64,
|
||||
yesterdayChangePercent: 72,
|
||||
@@ -410,6 +441,49 @@ export default function MobileFundTable({
|
||||
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;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!relatedSectorEnabled) return;
|
||||
if (!Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await runWithConcurrency(missing, 4, async (code) => {
|
||||
const value = await fetchRelatedSector(code);
|
||||
relatedSectorCacheRef.current.set(code, value);
|
||||
if (cancelled) return;
|
||||
setRelatedSectorByCode((prev) => {
|
||||
if (prev[code] === value) return prev;
|
||||
return { ...prev, [code]: value };
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [relatedSectorEnabled, data]);
|
||||
|
||||
const columnWidthMap = useMemo(() => {
|
||||
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||
const nonNameCount = visibleNonNameIds.length;
|
||||
@@ -435,6 +509,7 @@ export default function MobileFundTable({
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
setMobileColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||
@@ -442,7 +517,8 @@ export default function MobileFundTable({
|
||||
};
|
||||
|
||||
// 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps)
|
||||
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet }) => {
|
||||
// 当 isNameSortMode 且 sortBy==='default' 时,左侧显示排序/拖拽图标,可拖动行排序
|
||||
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet, isNameSortMode: nameSortMode, sortBy: currentSortBy }) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
@@ -451,10 +527,23 @@ export default function MobileFundTable({
|
||||
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
const rowSortable = useContext(RowSortableContext);
|
||||
const showDragHandle = nameSortMode && currentSortBy === 'default' && rowSortable;
|
||||
|
||||
return (
|
||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isGroupTab ? (
|
||||
{showDragHandle ? (
|
||||
<span
|
||||
ref={rowSortable.setActivatorNodeRef}
|
||||
className="icon-button fav-button"
|
||||
title="拖动排序"
|
||||
style={{ backgroundColor: 'transparent', touchAction: 'none', cursor: 'grab', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...rowSortable.listeners}
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</span>
|
||||
) : isGroupTab ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
@@ -519,7 +608,7 @@ export default function MobileFundTable({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{holdingAmountDisplay}
|
||||
{masked ? <span className="mask-text">******</span> : holdingAmountDisplay}
|
||||
{hasDca && <span className="dca-indicator">定</span>}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
@@ -581,6 +670,31 @@ export default function MobileFundTable({
|
||||
>
|
||||
<SettingsIcon width="18" height="18" />
|
||||
</button>
|
||||
{sortBy === 'default' && (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-button ${isNameSortMode ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setIsNameSortMode((prev) => !prev);
|
||||
}}
|
||||
title={isNameSortMode ? '退出排序' : '拖动排序'}
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
minWidth: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isNameSortMode ? 'var(--primary)' : 'var(--text)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SortIcon width="18" height="18" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (info) => (
|
||||
@@ -588,32 +702,71 @@ export default function MobileFundTable({
|
||||
info={info}
|
||||
showFullFundName={showFullFundName}
|
||||
onOpenCardSheet={getFundCardProps ? (row) => setCardSheetRow(row) : undefined}
|
||||
isNameSortMode={isNameSortMode}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
),
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||
},
|
||||
{
|
||||
id: 'relatedSector',
|
||||
header: '关联板块',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||
const display = value || '—';
|
||||
return (
|
||||
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '12px' }}>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'left', cellClassName: 'related-sector-cell', width: columnWidthMap.relatedSector ?? 120 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
cell: (info) => (
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.latestNavDate ?? '-';
|
||||
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateNav',
|
||||
header: '估算净值',
|
||||
cell: (info) => (
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.estimateNavDate ?? '-';
|
||||
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||
const estimateNav = info.getValue();
|
||||
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
{estimateNav ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
{hasEstimateNav && displayDate && displayDate !== '-' ? (
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
||||
},
|
||||
{
|
||||
@@ -623,13 +776,14 @@ export default function MobileFundTable({
|
||||
const original = info.row.original || {};
|
||||
const value = original.yesterdayChangeValue;
|
||||
const date = original.yesterdayDate ?? '-';
|
||||
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -645,12 +799,16 @@ export default function MobileFundTable({
|
||||
const time = original.estimateTime ?? '-';
|
||||
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
|
||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
const text = info.getValue();
|
||||
const hasText = text != null && text !== '—';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
{text ?? '—'}
|
||||
</span>
|
||||
{hasText && displayTime && displayTime !== '-' ? (
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -671,10 +829,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr ? (
|
||||
{hasProfit && percentStr && !masked ? (
|
||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -701,10 +859,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr && !isUpdated ? (
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -730,10 +888,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -746,7 +904,7 @@ export default function MobileFundTable({
|
||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps]
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy, relatedSectorByCode]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -925,7 +1083,7 @@ export default function MobileFundTable({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
{table.getRowModel().rows.map((row, index) => (
|
||||
<SortableRow
|
||||
key={row.original.code || row.id}
|
||||
row={row}
|
||||
@@ -934,15 +1092,16 @@ export default function MobileFundTable({
|
||||
>
|
||||
{(setActivatorNodeRef, listeners) => (
|
||||
<div
|
||||
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
||||
ref={sortBy === 'default' && !isNameSortMode ? setActivatorNodeRef : undefined}
|
||||
className="table-row"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
background: index % 2 === 0 ? 'var(--bg)' : 'var(--table-row-alt-bg)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||
}}
|
||||
{...(sortBy === 'default' ? listeners : {})}
|
||||
onClick={isNameSortMode ? () => setIsNameSortMode(false) : undefined}
|
||||
{...(sortBy === 'default' && !isNameSortMode ? listeners : {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const columnId = cell.column.id;
|
||||
@@ -950,11 +1109,19 @@ export default function MobileFundTable({
|
||||
const alignClass = getAlignClass(columnId);
|
||||
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||
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 (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||
style={style}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
@@ -996,51 +1163,14 @@ export default function MobileFundTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
<MobileFundCardDrawer
|
||||
open={!!(cardSheetRow && getFundCardProps)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (ignoreNextDrawerCloseRef.current) {
|
||||
ignoreNextDrawerCloseRef.current = false;
|
||||
return;
|
||||
}
|
||||
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>
|
||||
onOpenChange={(open) => { if (!open) setCardSheetRow(null); }}
|
||||
blockDrawerClose={blockDrawerClose}
|
||||
ignoreNextDrawerCloseRef={ignoreNextDrawerCloseRef}
|
||||
cardSheetRow={cardSheetRow}
|
||||
getFundCardProps={getFundCardProps}
|
||||
/>
|
||||
|
||||
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function MobileSettingModal({
|
||||
onToggleShowFullFundName,
|
||||
}) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetConfirmOpen(false);
|
||||
@@ -58,6 +59,7 @@ export default function MobileSettingModal({
|
||||
if (!v) onClose();
|
||||
}}
|
||||
direction="bottom"
|
||||
handleOnly={isReordering}
|
||||
>
|
||||
<DrawerContent
|
||||
className="glass"
|
||||
@@ -142,6 +144,8 @@ export default function MobileSettingModal({
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="mobile-setting-list"
|
||||
layoutScroll
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
@@ -153,6 +157,8 @@ export default function MobileSettingModal({
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
onDragStart={() => setIsReordering(true)}
|
||||
onDragEnd={() => setIsReordering(false)}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
@@ -160,6 +166,7 @@ export default function MobileSettingModal({
|
||||
mass: 1,
|
||||
layout: { duration: 0.2 },
|
||||
}}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
@@ -173,7 +180,19 @@ export default function MobileSettingModal({
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</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 && (
|
||||
<Switch
|
||||
checked={columnVisibility?.[item.id] !== false}
|
||||
@@ -198,6 +217,8 @@ export default function MobileSettingModal({
|
||||
key="mobile-reset-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||
|
||||
const NON_FROZEN_COLUMN_IDS = [
|
||||
'relatedSector',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
@@ -47,6 +49,7 @@ const NON_FROZEN_COLUMN_IDS = [
|
||||
'estimateNav',
|
||||
];
|
||||
const COLUMN_HEADERS = {
|
||||
relatedSector: '关联板块',
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨幅',
|
||||
@@ -131,6 +134,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
* @param {React.MutableRefObject<(() => void) | null>} [props.closeDialogRef] - 注入关闭弹框的方法,用于确认删除时关闭
|
||||
* @param {boolean} [props.blockDialogClose] - 为 true 时阻止点击遮罩关闭弹框(如删除确认弹框打开时)
|
||||
* @param {number} [props.stickyTop] - 表头固定时的 top 偏移(与 MobileFundTable 一致,用于适配导航栏、筛选栏等)
|
||||
* @param {boolean} [props.masked] - 是否隐藏持仓相关金额
|
||||
*/
|
||||
export default function PcFundTable({
|
||||
data = [],
|
||||
@@ -149,6 +153,7 @@ export default function PcFundTable({
|
||||
closeDialogRef,
|
||||
blockDialogClose = false,
|
||||
stickyTop = 0,
|
||||
masked = false,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -280,9 +285,15 @@ export default function PcFundTable({
|
||||
})();
|
||||
const columnVisibility = (() => {
|
||||
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
return next;
|
||||
}
|
||||
const allVisible = {};
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
allVisible.relatedSector = false;
|
||||
return allVisible;
|
||||
})();
|
||||
const columnSizing = (() => {
|
||||
@@ -354,6 +365,7 @@ export default function PcFundTable({
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
setColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||
@@ -412,7 +424,12 @@ export default function PcFundTable({
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPortalHeader(rect.top <= nextStickyTop);
|
||||
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||
const hasPassedHeader = (rect.top + headerHeight) <= nextStickyTop;
|
||||
const hasTableInView = rect.bottom > nextStickyTop;
|
||||
|
||||
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||
|
||||
setPortalHorizontal((prev) => {
|
||||
const next = {
|
||||
@@ -436,6 +453,51 @@ export default function PcFundTable({
|
||||
};
|
||||
}, [stickyTop]);
|
||||
|
||||
const relatedSectorEnabled = columnVisibility?.relatedSector !== false;
|
||||
const relatedSectorCacheRef = useRef(new Map());
|
||||
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
|
||||
|
||||
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
|
||||
|
||||
const runWithConcurrency = async (items, limit, worker) => {
|
||||
const queue = [...items];
|
||||
const results = [];
|
||||
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item == null) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
results.push(await worker(item));
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!relatedSectorEnabled) return;
|
||||
if (!Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await runWithConcurrency(missing, 4, async (code) => {
|
||||
const value = await fetchRelatedSector(code);
|
||||
relatedSectorCacheRef.current.set(code, value);
|
||||
if (cancelled) return;
|
||||
setRelatedSectorByCode((prev) => {
|
||||
if (prev[code] === value) return prev;
|
||||
return { ...prev, [code]: value };
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [relatedSectorEnabled, data]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableEl = tableContainerRef.current;
|
||||
const portalEl = portalHeaderRef.current;
|
||||
@@ -556,16 +618,47 @@ export default function PcFundTable({
|
||||
cellClassName: 'name-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'relatedSector',
|
||||
header: '关联板块',
|
||||
size: 180,
|
||||
minSize: 120,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||
const display = value || '—';
|
||||
return (
|
||||
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '14px' }}>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'related-sector-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const rawDate = original.latestNavDate ?? '-';
|
||||
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
@@ -576,11 +669,25 @@ export default function PcFundTable({
|
||||
header: '估算净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{estimateNav ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
{hasEstimateNav && date && date !== '-' ? (
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{date}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
@@ -594,7 +701,8 @@ export default function PcFundTable({
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
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' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
@@ -621,16 +729,21 @@ export default function PcFundTable({
|
||||
const original = info.row.original || {};
|
||||
const value = original.estimateChangeValue;
|
||||
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 text = info.getValue();
|
||||
const hasText = text != null && text !== '—';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
{text ?? '—'}
|
||||
</FitText>
|
||||
{hasText && time && time !== '-' ? (
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{time}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -655,9 +768,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
{hasProfit && percentStr && !masked ? (
|
||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -713,7 +826,7 @@ export default function PcFundTable({
|
||||
>
|
||||
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
{masked ? <span className="mask-text">******</span> : (info.getValue() ?? '—')}
|
||||
</FitText>
|
||||
</div>
|
||||
<button
|
||||
@@ -751,9 +864,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
{percentStr && !isUpdated ? (
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -783,9 +896,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -858,7 +971,7 @@ export default function PcFundTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps],
|
||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked, relatedSectorByCode],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -917,7 +1030,7 @@ export default function PcFundTable({
|
||||
left: isLeft ? `${column.getStart('left')}px` : undefined,
|
||||
right: isRight ? `${column.getAfter('right')}px` : undefined,
|
||||
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',
|
||||
textAlign: isNameColumn ? 'left' : 'center',
|
||||
justifyContent: isNameColumn ? 'flex-start' : 'center',
|
||||
@@ -968,10 +1081,34 @@ export default function PcFundTable({
|
||||
<style>{`
|
||||
.table-row-scroll {
|
||||
--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);
|
||||
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 以支持动态列宽 */
|
||||
@@ -1055,10 +1192,10 @@ export default function PcFundTable({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
{table.getRowModel().rows.map((row, index) => (
|
||||
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
|
||||
<div
|
||||
className="table-row table-row-scroll"
|
||||
className={`table-row table-row-scroll ${index % 2 === 1 ? 'row-even' : ''}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||
@@ -1081,10 +1218,11 @@ export default function PcFundTable({
|
||||
const cellClassName =
|
||||
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||
const style = getCommonPinningStyles(cell.column, false);
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${align} ${cellClassName}`}
|
||||
className={`table-cell ${align} ${cellClassName} ${isPinned ? 'pinned-cell' : ''}`}
|
||||
style={style}
|
||||
>
|
||||
{flexRender(
|
||||
@@ -1112,6 +1250,8 @@ export default function PcFundTable({
|
||||
<ConfirmModal
|
||||
title="重置列宽"
|
||||
message="是否重置表格列宽为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={handleResetSizing}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
@@ -1140,25 +1280,15 @@ export default function PcFundTable({
|
||||
>
|
||||
<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"
|
||||
className="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-y-styled"
|
||||
>
|
||||
{cardDialogRow && getFundCardProps ? (
|
||||
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
|
||||
|
||||
@@ -206,7 +206,19 @@ export default function PcTableSettingModal({
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -267,6 +279,8 @@ export default function PcTableSettingModal({
|
||||
key="reset-order-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
|
||||
94
app/components/PendingTradesModal.jsx
Normal file
94
app/components/PendingTradesModal.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PendingTradesModal({
|
||||
open,
|
||||
trades = [],
|
||||
onClose,
|
||||
onRevoke,
|
||||
}) {
|
||||
const handleOpenChange = (nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal trade-modal"
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 998 }}
|
||||
style={{ maxWidth: '420px', zIndex: 999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">待交易队列</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>📥</span>
|
||||
<span>待交易队列</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{trades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)',
|
||||
}}
|
||||
>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
{trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
<span className="muted">份额/金额</span>
|
||||
<span>{trade.share ? `${trade.share} 份` : `¥${trade.amount}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => onRevoke?.(trade)}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
38
app/components/PwaRegister.jsx
Normal file
38
app/components/PwaRegister.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 在客户端注册 Service Worker,满足 Android Chrome PWA 安装条件(需 HTTPS + manifest + SW)。
|
||||
* 仅在生产环境且浏览器支持时注册。
|
||||
*/
|
||||
export default function PwaRegister() {
|
||||
useEffect(() => {// 检测核心能力
|
||||
const isPwaSupported =
|
||||
'serviceWorker' in navigator &&
|
||||
'BeforeInstallPromptEvent' in window;
|
||||
console.log('PWA 支持:', isPwaSupported);
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
process.env.NODE_ENV !== 'production'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/', updateViaCache: 'none' })
|
||||
.then((reg) => {
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const newWorker = reg.installing;
|
||||
newWorker?.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// 可选:提示用户刷新以获取新版本
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export default function ScanImportConfirmModal({
|
||||
scannedFunds,
|
||||
@@ -22,9 +23,10 @@ export default function ScanImportConfirmModal({
|
||||
isOcrScan = false
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('all');
|
||||
const [expandAfterAdd, setExpandAfterAdd] = useState(true);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedGroupId);
|
||||
onConfirm(selectedGroupId, expandAfterAdd);
|
||||
};
|
||||
|
||||
const formatAmount = (val) => {
|
||||
@@ -126,6 +128,13 @@ export default function ScanImportConfirmModal({
|
||||
);
|
||||
})}
|
||||
</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 }}>
|
||||
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
||||
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
@@ -20,6 +22,22 @@ export default function SettingsModal({
|
||||
}) {
|
||||
const [sliderDragging, setSliderDragging] = useState(false);
|
||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
||||
const pageWidthTrackRef = useRef(null);
|
||||
|
||||
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
const pageWidthPercent = ((clampedWidth - 600) / (2000 - 600)) * 100;
|
||||
|
||||
const updateWidthByClientX = (clientX) => {
|
||||
if (!pageWidthTrackRef.current || !setContainerWidth) return;
|
||||
const rect = pageWidthTrackRef.current.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const ratio = (clientX - rect.left) / rect.width;
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
const rawWidth = 600 + clampedRatio * (2000 - 600);
|
||||
const snapped = Math.round(rawWidth / 10) * 10;
|
||||
setContainerWidth(snapped);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sliderDragging) return;
|
||||
@@ -32,18 +50,29 @@ export default function SettingsModal({
|
||||
};
|
||||
}, [sliderDragging]);
|
||||
|
||||
// 外部的 tempSeconds 变更时,同步到本地显示,但不立即生效
|
||||
useEffect(() => {
|
||||
setLocalSeconds(tempSeconds);
|
||||
}, [tempSeconds]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置"
|
||||
onClick={onClose}
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose?.();
|
||||
}}
|
||||
>
|
||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogContent
|
||||
overlayClassName={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''} z-[9999]`}
|
||||
className="!p-0 z-[10000]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="glass card modal">
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<DialogTitle asChild>
|
||||
<span>设置</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
@@ -53,9 +82,9 @@ export default function SettingsModal({
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`chip ${tempSeconds === s ? 'active' : ''}`}
|
||||
onClick={() => setTempSeconds(s)}
|
||||
aria-pressed={tempSeconds === s}
|
||||
className={`chip ${localSeconds === s ? 'active' : ''}`}
|
||||
onClick={() => setLocalSeconds(s)}
|
||||
aria-pressed={localSeconds === s}
|
||||
>
|
||||
{s} 秒
|
||||
</button>
|
||||
@@ -67,11 +96,11 @@ export default function SettingsModal({
|
||||
inputMode="numeric"
|
||||
min="30"
|
||||
step="5"
|
||||
value={tempSeconds}
|
||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
||||
value={localSeconds}
|
||||
onChange={(e) => setLocalSeconds(Number(e.target.value))}
|
||||
placeholder="自定义秒数"
|
||||
/>
|
||||
{tempSeconds < 30 && (
|
||||
{localSeconds < 30 && (
|
||||
<div className="error-text" style={{ marginTop: 8 }}>
|
||||
最小 30 秒
|
||||
</div>
|
||||
@@ -102,23 +131,32 @@ export default function SettingsModal({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={600}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
|
||||
onChange={(e) => setContainerWidth(Number(e.target.value))}
|
||||
onPointerDown={() => setSliderDragging(true)}
|
||||
className="page-width-slider"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
accentColor: 'var(--primary)',
|
||||
<div
|
||||
ref={pageWidthTrackRef}
|
||||
className="group relative"
|
||||
style={{ flex: 1, height: 14, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
onPointerDown={(e) => {
|
||||
setSliderDragging(true);
|
||||
updateWidthByClientX(e.clientX);
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!sliderDragging) return;
|
||||
updateWidthByClientX(e.clientX);
|
||||
}}
|
||||
>
|
||||
<Progress value={pageWidthPercent} />
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2"
|
||||
style={{ left: `${pageWidthPercent}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full bg-primary shadow-md shadow-primary/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
||||
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
|
||||
{clampedWidth}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,13 +186,22 @@ export default function SettingsModal({
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
|
||||
<button
|
||||
className="button"
|
||||
onClick={(e) => saveSettings(e, localSeconds)}
|
||||
disabled={localSeconds < 30}
|
||||
>
|
||||
保存并关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||
<ConfirmModal
|
||||
title="重置页面宽度"
|
||||
message="是否重置页面宽度为默认值 1200px?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetContainerWidth();
|
||||
setResetWidthConfirmOpen(false);
|
||||
@@ -163,6 +210,6 @@ export default function SettingsModal({
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
519
app/components/SortSettingModal.jsx
Normal file
519
app/components/SortSettingModal.jsx
Normal file
@@ -0,0 +1,519 @@
|
||||
"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 { 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,
|
||||
}) {
|
||||
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",
|
||||
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
|
||||
);
|
||||
}
|
||||
37
app/components/ThemeColorSync.jsx
Normal file
37
app/components/ThemeColorSync.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const THEME_COLORS = {
|
||||
dark: '#0f172a',
|
||||
light: '#ffffff',
|
||||
};
|
||||
|
||||
function getThemeColor() {
|
||||
if (typeof document === 'undefined') return THEME_COLORS.dark;
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
return THEME_COLORS[theme] ?? THEME_COLORS.dark;
|
||||
}
|
||||
|
||||
function applyThemeColor() {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', getThemeColor());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前亮/暗主题同步 PWA theme-color meta,使 Android 状态栏与页面主题一致。
|
||||
* 监听 document.documentElement 的 data-theme 变化并更新 meta。
|
||||
*/
|
||||
export default function ThemeColorSync() {
|
||||
useEffect(() => {
|
||||
applyThemeColor();
|
||||
const observer = new MutationObserver(() => applyThemeColor());
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { isNumber } from 'lodash';
|
||||
import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import PendingTradesModal from './PendingTradesModal';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -33,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
const [share, setShare] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [feeRate, setFeeRate] = useState('0');
|
||||
const [minBuyAmount, setMinBuyAmount] = useState(0);
|
||||
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
|
||||
const [buyMetaError, setBuyMetaError] = useState(null);
|
||||
const [date, setDate] = useState(() => {
|
||||
return formatDate();
|
||||
});
|
||||
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
||||
const [calcShare, setCalcShare] = useState(null);
|
||||
|
||||
const parseNumberish = (input) => {
|
||||
if (input === null || typeof input === 'undefined') return null;
|
||||
if (typeof input === 'number') return Number.isFinite(input) ? input : null;
|
||||
const cleaned = String(input).replace(/[^\d.]/g, '');
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBuy || !fund?.code) return;
|
||||
let cancelled = false;
|
||||
|
||||
setLoadingBuyMeta(true);
|
||||
setBuyMetaError(null);
|
||||
|
||||
fetchFundPingzhongdata(fund.code)
|
||||
.then((pz) => {
|
||||
if (cancelled) return;
|
||||
const rate = parseNumberish(pz?.fund_Rate);
|
||||
const minsg = parseNumberish(pz?.fund_minsg);
|
||||
|
||||
if (Number.isFinite(minsg)) {
|
||||
setMinBuyAmount(minsg);
|
||||
} else {
|
||||
setMinBuyAmount(0);
|
||||
}
|
||||
|
||||
if (Number.isFinite(rate)) {
|
||||
setFeeRate((prev) => {
|
||||
const prevNum = parseNumberish(prev);
|
||||
const shouldOverride = prev === '' || prev === '0' || prevNum === 0 || prevNum === null;
|
||||
return shouldOverride ? rate.toFixed(2) : prev;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setBuyMetaError(e?.message || '买入信息加载失败');
|
||||
setMinBuyAmount(0);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoadingBuyMeta(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isBuy, fund?.code]);
|
||||
|
||||
const currentPendingTrades = useMemo(() => {
|
||||
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
||||
}, [pendingTrades, fund]);
|
||||
@@ -142,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
};
|
||||
|
||||
const isValid = isBuy
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null)
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
|
||||
: (!!share && !!date);
|
||||
|
||||
const handleSetShareFraction = (fraction) => {
|
||||
@@ -153,36 +213,33 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
const [revokeTrade, setRevokeTrade] = useState(null);
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isBuy ? "加仓" : "减仓"}
|
||||
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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal trade-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 99 }}
|
||||
style={{ maxWidth: '420px', width: '90vw', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">{isBuy ? '加仓' : '减仓'}</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>{isBuy ? '📥' : '📤'}</span>
|
||||
<span>{showPendingList ? '待交易队列' : (showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓'))}</span>
|
||||
<span>{showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓')}</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
||||
{!showConfirm && currentPendingTrades.length > 0 && (
|
||||
<div
|
||||
className="trade-pending-alert"
|
||||
onClick={() => setShowPendingList(true)}
|
||||
@@ -192,49 +249,6 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPendingList ? (
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-header trade-pending-header">
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setShowPendingList(false)}
|
||||
style={{ padding: '4px 8px', fontSize: '12px' }}
|
||||
>
|
||||
< 返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{currentPendingTrades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>{trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
<span className="muted">份额/金额</span>
|
||||
<span>{trade.share ? `${trade.share} 份` : `¥${trade.amount}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary trade-revoke-btn"
|
||||
onClick={() => setRevokeTrade(trade)}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!showConfirm && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||
@@ -316,10 +330,10 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
className="button queue-button"
|
||||
onClick={handleFinalConfirm}
|
||||
disabled={loadingPrice}
|
||||
style={{ flex: 1, background: 'var(--primary)', opacity: loadingPrice ? 0.6 : 1, color: '#05263b' }}
|
||||
style={{ flex: 1, background: 'var(--primary)', opacity: loadingPrice ? 0.6 : 1 }}
|
||||
>
|
||||
{loadingPrice ? '请稍候' : (price ? '确认买入' : '加入待处理队列')}
|
||||
</button>
|
||||
@@ -398,7 +412,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
className="button queue-button"
|
||||
onClick={handleFinalConfirm}
|
||||
disabled={loadingPrice}
|
||||
style={{ flex: 1, background: 'var(--danger)', opacity: loadingPrice ? 0.6 : 1 }}
|
||||
@@ -412,19 +426,33 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<form onSubmit={handleSubmit}>
|
||||
{isBuy ? (
|
||||
<>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
|
||||
? '1px solid var(--danger)'
|
||||
: '1px solid var(--border)',
|
||||
borderRadius: 12
|
||||
}}
|
||||
>
|
||||
<NumericInput
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={0}
|
||||
placeholder="请输入加仓金额"
|
||||
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 }}>
|
||||
@@ -473,6 +501,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</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 : (
|
||||
@@ -481,6 +512,29 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingBuyMeta && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
WebkitBackdropFilter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
<span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -604,17 +658,15 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
disabled={!isValid || loadingPrice}
|
||||
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
|
||||
disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
|
||||
style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
<AnimatePresence>
|
||||
{revokeTrade && (
|
||||
<ConfirmModal
|
||||
@@ -630,6 +682,12 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<PendingTradesModal
|
||||
open={showPendingList}
|
||||
trades={currentPendingTrades}
|
||||
onClose={() => setShowPendingList(false)}
|
||||
onRevoke={(trade) => setRevokeTrade(trade)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function TransactionHistoryModal({
|
||||
fund,
|
||||
@@ -12,7 +18,7 @@ export default function TransactionHistoryModal({
|
||||
onClose,
|
||||
onDeleteTransaction,
|
||||
onDeletePending,
|
||||
onAddHistory
|
||||
onAddHistory,
|
||||
}) {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
|
||||
|
||||
@@ -39,31 +45,46 @@ export default function TransactionHistoryModal({
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
// 只关闭交易记录弹框,避免事件冒泡影响到其他弹框(例如 HoldingActionModal)
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 1100 }} // Higher than TradeModal if stacked, but usually TradeModal closes or this opens on top
|
||||
>
|
||||
<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 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal tx-history-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 998 }}
|
||||
style={{
|
||||
maxWidth: '480px',
|
||||
width: '90vw',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 999, // 保持原有层级,确保在其他弹框之上
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">交易记录</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>📜</span>
|
||||
<span>交易记录</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={handleCloseClick}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -76,7 +97,7 @@ export default function TransactionHistoryModal({
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={onAddHistory}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto', width: '80px' }}
|
||||
>
|
||||
添加记录
|
||||
</button>
|
||||
@@ -108,13 +129,16 @@ export default function TransactionHistoryModal({
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary tx-history-action-btn"
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => handleDeleteClick(item, 'pending')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -158,13 +182,16 @@ export default function TransactionHistoryModal({
|
||||
)}
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="muted"></span>
|
||||
<button
|
||||
className="button secondary tx-history-action-btn"
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => handleDeleteClick(item, 'history')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -172,22 +199,21 @@ export default function TransactionHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{deleteConfirm && (
|
||||
<ConfirmModal
|
||||
key="delete-confirm"
|
||||
title={deleteConfirm.type === 'pending' ? "撤销交易" : "删除记录"}
|
||||
title={deleteConfirm.type === 'pending' ? '撤销交易' : '删除记录'}
|
||||
message={deleteConfirm.type === 'pending'
|
||||
? "确定要撤销这笔待处理交易吗?"
|
||||
: "确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。"}
|
||||
? '确定要撤销这笔待处理交易吗?'
|
||||
: '确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。'}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { UpdateIcon } from './Icons';
|
||||
|
||||
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
|
||||
export default function UpdatePromptModal({ updateContent, open, onClose, onRefresh }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose?.()}>
|
||||
<DialogContent
|
||||
className="glass card"
|
||||
style={{ maxWidth: '400px' }}
|
||||
showCloseButton={false}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="更新提示"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 10002 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '400px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
||||
<span>更新提示</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
||||
检测到新版本,是否刷新浏览器以更新?
|
||||
@@ -36,7 +29,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
</p>
|
||||
{updateContent && (
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
background: 'var(--card)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
@@ -44,13 +37,14 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
{updateContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
|
||||
<div className="flex-row" style={{ gap: 12, display: 'flex' }}>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={onClose}
|
||||
@@ -66,7 +60,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
刷新浏览器
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export default function WeChatModal({ onClose }) {
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="trade-pending-alert"
|
||||
>
|
||||
<span>⚠️ 入群须知:禁止讨论和基金买卖以及投资的有关内容,可反馈软件相关需求和问题。</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={weChatGroupImg}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
--border: #1f2937;
|
||||
--table-pinned-header-bg: #2a394b;
|
||||
--table-row-hover-bg: #2a394b;
|
||||
--table-row-alt-bg: #1a2535;
|
||||
--radius: 0.625rem;
|
||||
--background: #0f172a;
|
||||
--foreground: #e5e7eb;
|
||||
@@ -23,12 +24,12 @@
|
||||
--popover: #111827;
|
||||
--popover-foreground: #e5e7eb;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #1f2937;
|
||||
--secondary: #0b1220;
|
||||
--secondary-foreground: #e5e7eb;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent-foreground: #e5e7eb;
|
||||
--destructive: #f87171;
|
||||
--input: #1f2937;
|
||||
--input: #0b1220;
|
||||
--ring: #22d3ee;
|
||||
--chart-1: #22d3ee;
|
||||
--chart-2: #60a5fa;
|
||||
@@ -65,6 +66,7 @@
|
||||
--border: #e2e8f0;
|
||||
--table-pinned-header-bg: #e2e8f0;
|
||||
--table-row-hover-bg: #e2e8f0;
|
||||
--table-row-alt-bg: #f8fafc;
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
--card-foreground: #0f172a;
|
||||
@@ -76,7 +78,7 @@
|
||||
--muted-foreground: #475569;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #dc2626;
|
||||
--input: #e2e8f0;
|
||||
--input: #f1f5f9;
|
||||
--ring: #0891b2;
|
||||
--chart-1: #0891b2;
|
||||
--chart-2: #2563eb;
|
||||
@@ -106,8 +108,10 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
height: 100%;
|
||||
overflow-x: clip;
|
||||
will-change: auto; /* 或者移除任何 will-change: transform */
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -164,6 +168,13 @@ body::before {
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
/* 隐藏 y 轴滚动条,保留滚动能力 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.container::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-width-slider {
|
||||
@@ -447,11 +458,20 @@ body::before {
|
||||
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.row-hovered {
|
||||
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 {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
@@ -965,6 +985,13 @@ input[type="number"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
/* 移动端同样隐藏 y 轴滚动条 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.container::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -1023,6 +1050,12 @@ input[type="number"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.mask-text,
|
||||
.up .mask-text,
|
||||
.down .mask-text {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -1396,6 +1429,11 @@ input[type="number"] {
|
||||
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 {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@@ -1450,6 +1488,10 @@ input[type="number"] {
|
||||
background: #2a394b;
|
||||
}
|
||||
|
||||
.table-row:nth-child(even) {
|
||||
background: var(--table-row-alt-bg);
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -1695,6 +1737,11 @@ input[type="number"] {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* 基金名称表头排序按钮在排序模式下的高亮 */
|
||||
.mobile-fund-table .mobile-fund-table-header .icon-button.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row .name-cell .name-cell-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -1857,7 +1904,6 @@ input[type="number"] {
|
||||
@media (max-width: 640px) {
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 60px; /* Navbar height */
|
||||
z-index: 40;
|
||||
width: calc(100% + 32px);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
@@ -1986,6 +2032,11 @@ input[type="number"] {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2042,10 +2093,12 @@ input[type="number"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 亮色主题下,PC 右侧抽屉里的 Switch 拇指使用浅色,以保证对比度 */
|
||||
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* 移动端表格设置底部抽屉 */
|
||||
.mobile-setting-overlay {
|
||||
position: fixed;
|
||||
@@ -2071,9 +2124,23 @@ input[type="number"] {
|
||||
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),适配亮/暗主题,略微玻璃拟态 */
|
||||
[data-slot="dialog-content"] {
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
@@ -2266,6 +2333,10 @@ input[type="number"] {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
[data-theme="light"] .trade-modal .queue-button {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.trade-time-slot {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -2306,6 +2377,13 @@ input[type="number"] {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* 亮色主题:TradeModal */
|
||||
[data-theme="light"] .trade-pending-alert {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
[data-theme="light"] .trade-modal .trade-pending-header {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-bottom-color: var(--border);
|
||||
@@ -2462,6 +2540,13 @@ input[type="number"] {
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
/* 亮色主题下:所有使用 dca-toggle 的拇指在浅底上统一用白色,保证对比度
|
||||
- PC 右侧排序设置抽屉
|
||||
- 移动端排序个性化设置 Drawer(以及其它区域) */
|
||||
[data-theme="light"] .dca-toggle-thumb {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dca-option-group {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -3445,7 +3530,7 @@ input[type="number"] {
|
||||
--accent-foreground: #f8fafc;
|
||||
--destructive: #f87171;
|
||||
--border: #1f2937;
|
||||
--input: #1e293b;
|
||||
--input: #0b1220;
|
||||
--ring: #22d3ee;
|
||||
--chart-1: #22d3ee;
|
||||
--chart-2: #60a5fa;
|
||||
|
||||
60
app/hooks/useBodyScrollLock.js
Normal file
60
app/hooks/useBodyScrollLock.js
Normal file
@@ -0,0 +1,60 @@
|
||||
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 || "";
|
||||
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.top = `-${lockedScrollY}px`;
|
||||
document.body.style.width = "100%";
|
||||
}
|
||||
}
|
||||
|
||||
function unlockBodyScroll() {
|
||||
if (scrollLockCount === 0) return;
|
||||
|
||||
scrollLockCount -= 1;
|
||||
|
||||
// 只有全部弹框都关闭时才恢复滚动位置
|
||||
if (scrollLockCount === 0) {
|
||||
document.body.style.position = originalBodyPosition;
|
||||
document.body.style.top = originalBodyTop;
|
||||
document.body.style.width = "";
|
||||
|
||||
// 恢复到锁定前的滚动位置,而不是跳到顶部
|
||||
window.scrollTo(0, lockedScrollY);
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import './globals.css';
|
||||
import AnalyticsGate from './components/AnalyticsGate';
|
||||
import PwaRegister from './components/PwaRegister';
|
||||
import ThemeColorSync from './components/ThemeColorSync';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
export const metadata = {
|
||||
@@ -18,6 +21,9 @@ export default function RootLayout({ children }) {
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
{/* 初始为暗色;ThemeColorSync 会按 data-theme 同步为亮/暗 */}
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
{/* 尽早设置 data-theme,减少首屏主题闪烁;与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
|
||||
<script
|
||||
@@ -27,8 +33,11 @@ export default function RootLayout({ children }) {
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeColorSync />
|
||||
<PwaRegister />
|
||||
<AnalyticsGate GA_ID={GA_ID} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
431
app/page.jsx
431
app/page.jsx
@@ -59,8 +59,11 @@ import UpdatePromptModal from "./components/UpdatePromptModal";
|
||||
import RefreshButton from "./components/RefreshButton";
|
||||
import WeChatModal from "./components/WeChatModal";
|
||||
import DcaModal from "./components/DcaModal";
|
||||
import MarketIndexAccordion from "./components/MarketIndexAccordion";
|
||||
import SortSettingModal from "./components/SortSettingModal";
|
||||
import githubImg from "./assets/github.svg";
|
||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||
import { toast as sonnerToast } from 'sonner';
|
||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
||||
import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
|
||||
@@ -160,10 +163,22 @@ export default function HomePage() {
|
||||
const [groupManageOpen, setGroupManageOpen] = useState(false);
|
||||
const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false);
|
||||
|
||||
const DEFAULT_SORT_RULES = [
|
||||
{ id: 'default', label: '默认', enabled: true },
|
||||
// 估值涨幅为原始名称,“涨跌幅”为别名
|
||||
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
|
||||
// 持仓金额排序:默认隐藏
|
||||
{ id: 'holdingAmount', label: '持仓金额', enabled: false },
|
||||
{ id: 'holding', label: '持有收益', enabled: true },
|
||||
{ id: 'name', label: '基金名称', alias: '名称', enabled: true },
|
||||
];
|
||||
|
||||
// 排序状态
|
||||
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding
|
||||
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding, holdingAmount
|
||||
const [sortOrder, setSortOrder] = useState('desc'); // asc | desc
|
||||
const [isSortLoaded, setIsSortLoaded] = useState(false);
|
||||
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
|
||||
const [sortSettingOpen, setSortSettingOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -171,6 +186,71 @@ export default function HomePage() {
|
||||
const savedSortOrder = window.localStorage.getItem('localSortOrder');
|
||||
if (savedSortBy) setSortBy(savedSortBy);
|
||||
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;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}, []);
|
||||
@@ -179,11 +259,41 @@ export default function HomePage() {
|
||||
if (typeof window !== 'undefined' && isSortLoaded) {
|
||||
window.localStorage.setItem('localSortBy', sortBy);
|
||||
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,
|
||||
};
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(next));
|
||||
// 更新后标记 customSettings 脏并触发云端同步
|
||||
triggerCustomSettingsSync();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [sortBy, sortOrder, isSortLoaded]);
|
||||
}
|
||||
}, [sortBy, sortOrder, sortRules, 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 [maskAmounts, setMaskAmounts] = useState(false);
|
||||
|
||||
// 用户认证状态
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -240,6 +350,7 @@ export default function HomePage() {
|
||||
const containerRef = useRef(null);
|
||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
||||
const [marketIndexAccordionHeight, setMarketIndexAccordionHeight] = useState(0);
|
||||
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
||||
const [theme, setTheme] = useState('dark');
|
||||
const [showThemeTransition, setShowThemeTransition] = useState(false);
|
||||
@@ -538,10 +649,43 @@ export default function HomePage() {
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortBy === 'yield') {
|
||||
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
|
||||
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
|
||||
const getYieldValue = (fund) => {
|
||||
// 与 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 === 'holding') {
|
||||
const pa = getHoldingProfit(a, holdings[a.code]);
|
||||
const pb = getHoldingProfit(b, holdings[b.code]);
|
||||
@@ -659,7 +803,9 @@ export default function HomePage() {
|
||||
isUpdated: f.jzrq === todayStr,
|
||||
hasDca: dcaPlans[f.code]?.enabled === true,
|
||||
latestNav,
|
||||
latestNavDate: yesterdayDate,
|
||||
estimateNav,
|
||||
estimateNavDate: estimateTime,
|
||||
yesterdayChangePercent,
|
||||
yesterdayChangeValue,
|
||||
yesterdayDate,
|
||||
@@ -737,7 +883,21 @@ export default function HomePage() {
|
||||
|
||||
const handleClearConfirm = () => {
|
||||
if (clearConfirm?.fund) {
|
||||
handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null });
|
||||
const code = clearConfirm.fund.code;
|
||||
handleSaveHolding(code, { share: null, cost: null });
|
||||
|
||||
setTransactions(prev => {
|
||||
const next = { ...(prev || {}) };
|
||||
delete next[code];
|
||||
storageHelper.setItem('transactions', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
|
||||
setPendingTrades(prev => {
|
||||
const next = prev.filter(trade => trade.fundCode !== code);
|
||||
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setClearConfirm(null);
|
||||
};
|
||||
@@ -894,6 +1054,11 @@ export default function HomePage() {
|
||||
setPendingTrades(next);
|
||||
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||
|
||||
// 如果该基金没有持仓数据,初始化持仓金额为 0
|
||||
if (!holdings[fund.code]) {
|
||||
handleSaveHolding(fund.code, { share: 0, cost: 0 });
|
||||
}
|
||||
|
||||
setTradeModal({ open: false, fund: null, type: 'buy' });
|
||||
showToast('净值暂未更新,已加入待处理队列', 'info');
|
||||
return;
|
||||
@@ -1276,7 +1441,7 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const confirmScanImport = async (targetGroupId = 'all') => {
|
||||
const confirmScanImport = async (targetGroupId = 'all', expandAfterAdd = true) => {
|
||||
const codes = Array.from(selectedScannedCodes);
|
||||
if (codes.length === 0) {
|
||||
showToast('请至少选择一个基金代码', 'error');
|
||||
@@ -1332,6 +1497,8 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
if (newFunds.length > 0) {
|
||||
const newCodesSet = new Set(newFunds.map((f) => f.code));
|
||||
|
||||
setFunds(prev => {
|
||||
const updated = dedupeByCode([...newFunds, ...prev]);
|
||||
storageHelper.setItem('funds', JSON.stringify(updated));
|
||||
@@ -1354,6 +1521,22 @@ export default function HomePage() {
|
||||
});
|
||||
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') {
|
||||
setFavorites(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -1427,7 +1610,6 @@ export default function HomePage() {
|
||||
const fields = Array.from(new Set([
|
||||
'jzrq',
|
||||
'dwjz',
|
||||
'gsz',
|
||||
...(Array.isArray(extraFields) ? extraFields : [])
|
||||
]));
|
||||
const items = list.map((item) => {
|
||||
@@ -1486,7 +1668,9 @@ export default function HomePage() {
|
||||
if (key === 'funds') {
|
||||
const prevSig = getFundCodesSignature(prevValue);
|
||||
const nextSig = getFundCodesSignature(nextValue);
|
||||
if (prevSig === nextSig) return;
|
||||
if (prevSig === nextSig) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!skipSyncRef.current) {
|
||||
const now = nowInTz().toISOString();
|
||||
@@ -2078,29 +2262,30 @@ export default function HomePage() {
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupabaseConfigured || !user?.id) return;
|
||||
const channel = supabase
|
||||
.channel(`user-configs-${user.id}`)
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
const incoming = payload?.new?.data;
|
||||
if (!isPlainObject(incoming)) return;
|
||||
const incomingComparable = getComparablePayload(incoming);
|
||||
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
})
|
||||
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
const incoming = payload?.new?.data;
|
||||
if (!isPlainObject(incoming)) return;
|
||||
const incomingComparable = getComparablePayload(incoming);
|
||||
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
})
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user?.id]);
|
||||
// 实时同步
|
||||
// useEffect(() => {
|
||||
// if (!isSupabaseConfigured || !user?.id) return;
|
||||
// const channel = supabase
|
||||
// .channel(`user-configs-${user.id}`)
|
||||
// .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
// const incoming = payload?.new?.data;
|
||||
// if (!isPlainObject(incoming)) return;
|
||||
// const incomingComparable = getComparablePayload(incoming);
|
||||
// if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
// await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
// })
|
||||
// .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
// const incoming = payload?.new?.data;
|
||||
// if (!isPlainObject(incoming)) return;
|
||||
// const incomingComparable = getComparablePayload(incoming);
|
||||
// if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
// await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
// })
|
||||
// .subscribe();
|
||||
// return () => {
|
||||
// supabase.removeChannel(channel);
|
||||
// };
|
||||
// }, [user?.id]);
|
||||
|
||||
const handleSendOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2585,9 +2770,11 @@ export default function HomePage() {
|
||||
await refreshAll(codes);
|
||||
};
|
||||
|
||||
const saveSettings = (e) => {
|
||||
const saveSettings = (e, secondsOverride) => {
|
||||
e?.preventDefault?.();
|
||||
const ms = Math.max(30, Number(tempSeconds)) * 1000;
|
||||
const seconds = secondsOverride ?? tempSeconds;
|
||||
const ms = Math.max(30, Number(seconds)) * 1000;
|
||||
setTempSeconds(Math.round(ms / 1000));
|
||||
setRefreshMs(ms);
|
||||
storageHelper.setItem('refreshMs', String(ms));
|
||||
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
@@ -2997,9 +3184,10 @@ export default function HomePage() {
|
||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
// 一次查询同时拿到 meta 与 data,方便两种模式复用
|
||||
const { data: meta, error: metaError } = await supabase
|
||||
.from('user_configs')
|
||||
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
|
||||
.select('id, data, updated_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -3013,44 +3201,19 @@ export default function HomePage() {
|
||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 冲突检查模式:使用 meta.data 弹出冲突确认弹窗
|
||||
if (checkConflict) {
|
||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
||||
return;
|
||||
}
|
||||
|
||||
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
|
||||
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
|
||||
// 非冲突检查模式:直接复用上方查询到的 meta 数据,覆盖本地
|
||||
if (meta.data && isPlainObject(meta.data) && Object.keys(meta.data).length > 0) {
|
||||
await applyCloudConfig(meta.data, meta.updated_at);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_configs')
|
||||
.select('id, data, updated_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
||||
const localPayload = collectLocalPayload();
|
||||
const localComparable = getComparablePayload(localPayload);
|
||||
const cloudComparable = getComparablePayload(data.data);
|
||||
|
||||
if (localComparable !== cloudComparable) {
|
||||
// 如果数据不一致
|
||||
if (checkConflict) {
|
||||
// 只有明确要求检查冲突时才提示(例如刚登录时)
|
||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
|
||||
return;
|
||||
}
|
||||
// 否则直接覆盖本地(例如已登录状态下的刷新)
|
||||
await applyCloudConfig(data.data, data.updated_at);
|
||||
return;
|
||||
}
|
||||
|
||||
await applyCloudConfig(data.data, data.updated_at);
|
||||
return;
|
||||
}
|
||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||
} catch (e) {
|
||||
console.error('获取云端配置失败', e);
|
||||
@@ -3067,9 +3230,6 @@ export default function HomePage() {
|
||||
const dataToSync = payload || collectLocalPayload(); // Fallback to full sync if no payload
|
||||
const now = nowInTz().toISOString();
|
||||
|
||||
let upsertData = null;
|
||||
let updateError = null;
|
||||
|
||||
if (isPartial) {
|
||||
// 增量更新:使用 RPC 调用
|
||||
const { error: rpcError } = await supabase.rpc('update_user_config_partial', {
|
||||
@@ -3080,7 +3240,7 @@ export default function HomePage() {
|
||||
console.error('增量同步失败,尝试全量同步', rpcError);
|
||||
// RPC 失败回退到全量更新
|
||||
const fullPayload = collectLocalPayload();
|
||||
const { data, error } = await supabase
|
||||
const { error } = await supabase
|
||||
.from('user_configs')
|
||||
.upsert(
|
||||
{
|
||||
@@ -3089,17 +3249,12 @@ export default function HomePage() {
|
||||
updated_at: now
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
.select();
|
||||
upsertData = data;
|
||||
updateError = error;
|
||||
} else {
|
||||
// RPC 成功,模拟 upsertData 格式以便后续逻辑通过
|
||||
upsertData = [{ id: 'rpc_success' }];
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
} else {
|
||||
// 全量更新
|
||||
const { data, error } = await supabase
|
||||
const { error } = await supabase
|
||||
.from('user_configs')
|
||||
.upsert(
|
||||
{
|
||||
@@ -3108,15 +3263,8 @@ export default function HomePage() {
|
||||
updated_at: now
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
.select();
|
||||
upsertData = data;
|
||||
updateError = error;
|
||||
}
|
||||
|
||||
if (updateError) throw updateError;
|
||||
if (!upsertData || upsertData.length === 0) {
|
||||
throw new Error('同步失败:未写入任何数据,请检查账号状态或重新登录');
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
storageHelper.setItem('localUpdatedAt', now);
|
||||
@@ -3364,13 +3512,13 @@ export default function HomePage() {
|
||||
isScanImporting;
|
||||
|
||||
if (isAnyModalOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
containerRef.current.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
containerRef.current.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
containerRef.current.style.overflow = '';
|
||||
};
|
||||
}, [
|
||||
settingsOpen,
|
||||
@@ -3566,7 +3714,7 @@ export default function HomePage() {
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="search-dropdown glass"
|
||||
className="search-dropdown glass scrollbar-y-styled"
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="search-results">
|
||||
@@ -3724,6 +3872,26 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item"
|
||||
disabled={isSyncing}
|
||||
onClick={async () => {
|
||||
setUserMenuOpen(false);
|
||||
if (user?.id) await syncUserConfig(user.id);
|
||||
}}
|
||||
title="手动同步配置到云端"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<span className="loading-spinner" style={{ width: 16, height: 16, border: '2px solid var(--muted)', borderTopColor: 'var(--primary)', borderRadius: '50%', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" stroke="var(--primary)" />
|
||||
<path d="M12 12v9" stroke="var(--accent)" />
|
||||
<path d="m16 16-4-4-4 4" stroke="var(--accent)" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isSyncing ? '同步中...' : '同步'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
@@ -3772,10 +3940,16 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarketIndexAccordion
|
||||
navbarHeight={navbarHeight}
|
||||
onHeightChange={setMarketIndexAccordionHeight}
|
||||
isMobile={isMobile}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
<div className="grid">
|
||||
<div className="col-12">
|
||||
<div ref={filterBarRef} className="filter-bar" style={{ ...(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-scroll-area"
|
||||
@@ -3875,17 +4049,29 @@ export default function HomePage() {
|
||||
<div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} />
|
||||
|
||||
<div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted" style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<SortIcon width="14" height="14" />
|
||||
排序
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setSortSettingOpen(true)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: '12px',
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
width: '50px',
|
||||
}}
|
||||
title="排序个性化设置"
|
||||
>
|
||||
<span className="muted">排序</span>
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
<div className="chips">
|
||||
{[
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'yield', label: '涨跌幅' },
|
||||
{ id: 'holding', label: '持有收益' },
|
||||
{ id: 'name', label: '名称' },
|
||||
].map((s) => (
|
||||
{sortRules.filter((s) => s.enabled).map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`chip ${sortBy === s.id ? 'active' : ''}`}
|
||||
@@ -3901,7 +4087,7 @@ export default function HomePage() {
|
||||
}}
|
||||
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<span>{s.label}</span>
|
||||
<span>{s.alias || s.label}</span>
|
||||
{s.id !== 'default' && sortBy === s.id && (
|
||||
<span
|
||||
style={{
|
||||
@@ -3935,7 +4121,9 @@ export default function HomePage() {
|
||||
holdings={holdings}
|
||||
groupName={getGroupName()}
|
||||
getProfit={getHoldingProfit}
|
||||
stickyTop={navbarHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||
masked={maskAmounts}
|
||||
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
||||
/>
|
||||
|
||||
{currentTab !== 'all' && currentTab !== 'fav' && (
|
||||
@@ -3993,7 +4181,7 @@ export default function HomePage() {
|
||||
<div className="table-scroll-area">
|
||||
<div className="table-scroll-area-inner">
|
||||
<PcFundTable
|
||||
stickyTop={navbarHeight + filterBarHeight}
|
||||
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight}
|
||||
data={pcFundTableData}
|
||||
refreshing={refreshing}
|
||||
currentTab={currentTab}
|
||||
@@ -4030,6 +4218,7 @@ export default function HomePage() {
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
closeDialogRef={fundDetailDialogCloseRef}
|
||||
blockDialogClose={!!fundDeleteConfirm}
|
||||
masked={maskAmounts}
|
||||
getFundCardProps={(row) => {
|
||||
const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null);
|
||||
if (!fund) return {};
|
||||
@@ -4058,6 +4247,7 @@ export default function HomePage() {
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
layoutMode: 'drawer',
|
||||
};
|
||||
}}
|
||||
@@ -4073,7 +4263,7 @@ export default function HomePage() {
|
||||
currentTab={currentTab}
|
||||
favorites={favorites}
|
||||
sortBy={sortBy}
|
||||
stickyTop={navbarHeight + filterBarHeight - 14}
|
||||
stickyTop={navbarHeight + filterBarHeight + marketIndexAccordionHeight}
|
||||
blockDrawerClose={!!fundDeleteConfirm}
|
||||
closeDrawerRef={fundDetailDrawerCloseRef}
|
||||
onReorder={handleReorder}
|
||||
@@ -4133,9 +4323,11 @@ export default function HomePage() {
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
layoutMode: 'drawer',
|
||||
};
|
||||
}}
|
||||
masked={maskAmounts}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -4176,6 +4368,7 @@ export default function HomePage() {
|
||||
}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onToggleTrendCollapse={toggleTrendCollapse}
|
||||
masked={maskAmounts}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -4210,6 +4403,7 @@ export default function HomePage() {
|
||||
<ConfirmModal
|
||||
title="确认登出"
|
||||
message="确定要退出当前账号吗?"
|
||||
icon={<LogoutIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />}
|
||||
confirmText="确认登出"
|
||||
onConfirm={() => {
|
||||
setLogoutConfirmOpen(false);
|
||||
@@ -4229,6 +4423,10 @@ export default function HomePage() {
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => {
|
||||
if (!user?.id) {
|
||||
sonnerToast.error('请先登录后再提交反馈');
|
||||
return;
|
||||
}
|
||||
setFeedbackNonce((n) => n + 1);
|
||||
setFeedbackOpen(true);
|
||||
}}
|
||||
@@ -4296,6 +4494,7 @@ export default function HomePage() {
|
||||
<AddFundToGroupModal
|
||||
allFunds={funds}
|
||||
currentGroupCodes={groups.find(g => g.id === currentTab)?.codes || []}
|
||||
holdings={holdings}
|
||||
onClose={() => setAddFundToGroupOpen(false)}
|
||||
onAdd={handleAddFundsToGroup}
|
||||
/>
|
||||
@@ -4309,6 +4508,7 @@ export default function HomePage() {
|
||||
onClose={() => setActionModal({ open: false, fund: null })}
|
||||
onAction={(type) => handleAction(type, actionModal.fund)}
|
||||
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
||||
pendingCount={pendingTrades.filter(t => t.fundCode === actionModal.fund?.code).length}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -4417,6 +4617,12 @@ export default function HomePage() {
|
||||
holding={holdings[holdingModal.fund?.code]}
|
||||
onClose={() => setHoldingModal({ open: false, fund: null })}
|
||||
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
|
||||
onOpenTrade={() => {
|
||||
const f = holdingModal.fund;
|
||||
if (!f) return;
|
||||
setHoldingModal({ open: false, fund: null });
|
||||
setTradeModal({ open: true, fund: f, type: 'buy' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -4523,15 +4729,12 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{/* 更新提示弹窗 */}
|
||||
<AnimatePresence>
|
||||
{updateModalOpen && (
|
||||
<UpdatePromptModal
|
||||
open={updateModalOpen}
|
||||
updateContent={updateContent}
|
||||
onClose={() => setUpdateModalOpen(false)}
|
||||
onRefresh={() => window.location.reload()}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isScanning && (
|
||||
@@ -4567,6 +4770,16 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 排序个性化设置弹框 */}
|
||||
<SortSettingModal
|
||||
open={sortSettingOpen}
|
||||
onClose={() => setSortSettingOpen(false)}
|
||||
isMobile={isMobile}
|
||||
rules={sortRules}
|
||||
onChangeRules={setSortRules}
|
||||
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
|
||||
/>
|
||||
|
||||
{/* 全局轻提示 Toast */}
|
||||
<AnimatePresence>
|
||||
{toast.show && (
|
||||
|
||||
64
components/ui/accordion.jsx
Normal file
64
components/ui/accordion.jsx
Normal 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 }
|
||||
60
components/ui/button.jsx
Normal file
60
components/ui/button.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[linear-gradient(#0ea5e9,#0891b2)] text-white hover:bg-[linear-gradient(#0284c7,#0e7490)]",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -5,11 +5,39 @@ import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {CloseIcon} from "@/app/components/Icons";
|
||||
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock";
|
||||
|
||||
function Dialog({
|
||||
open: openProp,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...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({
|
||||
@@ -49,15 +77,18 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
overlayClassName,
|
||||
overlayStyle,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid 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
|
||||
)}
|
||||
{...props}>
|
||||
@@ -66,7 +97,7 @@ function DialogContent({
|
||||
<DialogPrimitive.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">
|
||||
<XIcon />
|
||||
<CloseIcon width="20" height="20" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,27 @@ import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
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) {
|
||||
if (typeof vhStr === "number") return vhStr
|
||||
@@ -12,10 +33,17 @@ function parseVhToPx(vhStr) {
|
||||
return (window.innerHeight * Number(match[1])) / 100
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
function Drawer({ open, ...props }) {
|
||||
const scrollLock = useScrollLock(open)
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ ...scrollLock, open: !!open }),
|
||||
[scrollLock, open]
|
||||
)
|
||||
return (
|
||||
<DrawerScrollLockContext.Provider value={contextValue}>
|
||||
<DrawerPrimitive.Root modal={false} data-slot="drawer" open={open} {...props} />
|
||||
</DrawerScrollLockContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
@@ -40,14 +68,26 @@ function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
const ctx = React.useContext(DrawerScrollLockContext)
|
||||
const { open = false, ...scrollLockProps } = ctx || {}
|
||||
// modal={false} 时 vaul 不渲染/隐藏 Overlay,用自定义遮罩 div 保证始终有遮罩;点击遮罩关闭
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
<DrawerPrimitive.Close asChild>
|
||||
<div
|
||||
data-slot="drawer-overlay"
|
||||
data-state={open ? "open" : "closed"}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="关闭"
|
||||
className={cn(
|
||||
"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",
|
||||
"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
|
||||
)}
|
||||
{...props} />
|
||||
{...scrollLockProps}
|
||||
{...props}
|
||||
/>
|
||||
</DrawerPrimitive.Close>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
245
components/ui/field.jsx
Normal file
245
components/ui/field.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
})
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
fieldVariants({ orientation }),
|
||||
// iOS 聚焦时若输入框字体 < 16px 会触发缩放,小屏下强制 16px 避免缩放
|
||||
"max-md:[&_input]:text-base max-md:[&_textarea]:text-base max-md:[&_select]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content">
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map((error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
23
components/ui/label.jsx
Normal file
23
components/ui/label.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
43
components/ui/progress.jsx
Normal file
43
components/ui/progress.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
// 细高条,轻玻璃质感,统一用 CSS 变量
|
||||
"relative w-full overflow-hidden rounded-full",
|
||||
"h-1.5 sm:h-1.5",
|
||||
"bg-[var(--input)]/70 dark:bg-[var(--input)]/40",
|
||||
"border border-[var(--border)]/80 dark:border-[var(--border)]/80",
|
||||
"shadow-[0_0_0_1px_rgba(15,23,42,0.02)] dark:shadow-[0_0_0_1px_rgba(15,23,42,0.6)]",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"h-full w-full flex-1",
|
||||
// 金融风轻渐变,兼容明暗主题
|
||||
"bg-gradient-to-r from-[var(--primary)] to-[var(--primary)]/80",
|
||||
"dark:from-[var(--primary)] dark:to-[var(--secondary)]/90",
|
||||
// 柔和发光,不喧宾夺主
|
||||
"shadow-[0_0_8px_rgba(245,158,11,0.35)] dark:shadow-[0_0_14px_rgba(245,158,11,0.45)]",
|
||||
// 平滑进度动画
|
||||
"transition-[transform,box-shadow] duration-250 ease-out"
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
27
components/ui/separator.jsx
Normal file
27
components/ui/separator.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
61
components/ui/sonner.jsx
Normal file
61
components/ui/sonner.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
// 外层容器:固定在页面顶部中间
|
||||
className="toaster pointer-events-none fixed inset-x-0 top-4 z-[70] flex items-start justify-center px-4 sm:top-6"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="h-4 w-4 text-emerald-500" />,
|
||||
info: <InfoIcon className="h-4 w-4 text-sky-500" />,
|
||||
warning: <TriangleAlertIcon className="h-4 w-4 text-amber-500" />,
|
||||
error: <OctagonXIcon className="h-4 w-4 text-destructive" />,
|
||||
loading: <Loader2Icon className="h-4 w-4 animate-spin text-primary" />,
|
||||
}}
|
||||
richColors
|
||||
// 统一 toast 样式,使用 ui-ux-pro-max 建议的明暗主题对比度
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
// 基础:浅色模式下使用高对比白色卡片,暗色模式使用深色卡片
|
||||
"pointer-events-auto relative flex w-full max-w-sm items-start gap-3 rounded-xl border border-slate-200 bg-white/90 text-slate-900 px-4 py-3 shadow-lg shadow-black/10 backdrop-blur-md transition-all duration-200 " +
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top sm:data-[state=open]:slide-in-from-bottom " +
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right " +
|
||||
"data-[swipe=move]:translate-x-[var(--sonner-swipe-move-x)] data-[swipe=move]:transition-none " +
|
||||
"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-x-[var(--sonner-swipe-end-x)] " +
|
||||
"dark:border-slate-800 dark:bg-slate-900/90 dark:text-slate-100",
|
||||
title: "text-sm font-medium",
|
||||
description: "mt-1 text-xs text-slate-600 dark:text-slate-400",
|
||||
closeButton:
|
||||
"cursor-pointer text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
actionButton:
|
||||
"inline-flex h-8 items-center justify-center rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||
cancelButton:
|
||||
"inline-flex h-8 items-center justify-center rounded-full border border-border bg-background px-3 text-xs font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
// 状态色:成功/信息/警告只强化边框,错误使用红色背景,满足你“提示为红色”的需求
|
||||
success: "border-emerald-500/70",
|
||||
info: "border-sky-500/70",
|
||||
warning: "border-amber-500/70",
|
||||
error: "bg-destructive text-destructive-foreground border-destructive/80",
|
||||
loading: "border-primary/60",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
21
components/ui/spinner.jsx
Normal file
21
components/ui/spinner.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"size-4 animate-spin text-muted-foreground motion-reduce:animate-none",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
BIN
doc/weChatGroupDevelop.jpg
Normal file
BIN
doc/weChatGroupDevelop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
26
entrypoint.sh
Normal file
26
entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# 在启动 Nginx 前,将静态资源中的占位符替换为运行时环境变量
|
||||
set -e
|
||||
|
||||
HTML_ROOT="/usr/share/nginx/html"
|
||||
|
||||
# 转义 sed 替换串中的特殊字符:\ & |
|
||||
escape_sed() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/&/\\&/g; s/|/\\|/g'
|
||||
}
|
||||
|
||||
# 占位符与环境变量对应(占位符名 = 变量名)
|
||||
replace_var() {
|
||||
placeholder="$1"
|
||||
value=$(escape_sed "${2:-}")
|
||||
find "$HTML_ROOT" -type f \( -name '*.js' -o -name '*.html' \) -exec sed -i "s|${placeholder}|${value}|g" {} \;
|
||||
}
|
||||
|
||||
# URL 构建时使用合法占位,此处替换为运行时环境变量
|
||||
replace_var "https://runtime-replace.supabase.co" "${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
replace_var "__NEXT_PUBLIC_SUPABASE_ANON_KEY__" "${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
replace_var "__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__" "${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}"
|
||||
replace_var "__NEXT_PUBLIC_GA_ID__" "${NEXT_PUBLIC_GA_ID}"
|
||||
replace_var "__NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL__" "${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}"
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -1,6 +1,6 @@
|
||||
# Supabase 配置
|
||||
# 从 Supabase 项目设置中获取这些值:https://app.supabase.com/project/_/settings/api
|
||||
# 复制此文件为 .env.local 并填入实际值
|
||||
# 复制此文件为 .env.local 并填入实际值(docker 部署复制成 .env)
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ /index.html =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
108
package-lock.json
generated
108
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ahooks": "^3.9.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -26,10 +27,12 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -502,6 +505,15 @@
|
||||
"@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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -4589,6 +4601,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -5225,6 +5243,28 @@
|
||||
"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": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -5883,7 +5923,7 @@
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -8286,6 +8326,13 @@
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -8951,6 +8998,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -9903,6 +9959,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -10720,7 +10786,7 @@
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10856,6 +10922,12 @@
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -11019,6 +11091,12 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -11238,6 +11316,18 @@
|
||||
"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": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -11669,6 +11759,16 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ahooks": "^3.9.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -29,10 +30,12 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
34
public/manifest.webmanifest
Normal file
34
public/manifest.webmanifest
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "基估宝",
|
||||
"short_name": "基估宝",
|
||||
"description": "基金管理管家",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"id": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["finance", "utilities"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
18
public/sw.js
Normal file
18
public/sw.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// 最小 Service Worker,满足 Android Chrome「添加到主屏幕」的安装条件
|
||||
const CACHE_NAME = 'jigubao-v1';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => {
|
||||
return new Response('', { status: 503, statusText: 'Service Unavailable' });
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user