Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a4e33d23cb | ||
|
|
63e7f000df | ||
|
|
152059b199 | ||
|
|
6c685c61e0 | ||
|
|
a176e7d013 | ||
|
|
d5df393723 | ||
|
|
7f3dfb31cf | ||
|
|
e97de8744a | ||
|
|
354936c9af | ||
|
|
a8a24605d4 | ||
|
|
b20fd42eec | ||
|
|
a3719c58fb | ||
|
|
6d2cf60d21 | ||
|
|
89d938a6c3 | ||
|
|
86e479c21a | ||
|
|
1f3c0bbbc9 | ||
|
|
24eb21fd29 | ||
|
|
56e20211e4 | ||
|
|
e5e2e472aa | ||
|
|
dab3ba3142 | ||
|
|
5b86a1c84a | ||
|
|
e5858df592 | ||
|
|
f20b852e98 | ||
|
|
792986dd79 | ||
|
|
1f9a4ff97a | ||
|
|
baea6f5107 | ||
|
|
f0b469fc93 | ||
|
|
d9364ce504 | ||
|
|
99ec356fbb | ||
|
|
44dfb944c7 | ||
|
|
aac5c5003a | ||
|
|
6580658f55 | ||
|
|
d9bc246088 | ||
|
|
fe3c2b64f6 | ||
|
|
c08c97d706 | ||
|
|
873728a6a2 | ||
|
|
9cfac48b59 | ||
|
|
0a828f33bf | ||
|
|
6d44803a27 | ||
|
|
b13ada16df | ||
|
|
a206076a56 | ||
|
|
df7abaecdc | ||
|
|
171ebac326 | ||
|
|
31553bb1a4 | ||
|
|
c65f2b8ab1 | ||
|
|
c9038757dd | ||
|
|
be4fc5eabe | ||
|
|
b8d239de40 | ||
|
|
e2d8858432 | ||
|
|
9f47ee3f08 | ||
|
|
eb7483a5dd | ||
|
|
0bdbb6d168 | ||
|
|
d6d64f1897 | ||
|
|
0504b9ae06 | ||
|
|
162f1c3b99 | ||
|
|
b0b4cfded1 | ||
|
|
39f8152e70 | ||
|
|
3958580571 | ||
|
|
6a53479bd7 | ||
|
|
20e101bb65 | ||
|
|
cbb9d2a105 | ||
|
|
848226cfbb | ||
|
|
5d46515e63 | ||
|
|
92d22b0bef | ||
|
|
8bcffffaa7 | ||
|
|
0ea310f9b3 | ||
|
|
4fcb076d99 | ||
|
|
e7661e7b38 | ||
|
|
2a406be0b1 | ||
|
|
dd9ec06c65 | ||
|
|
e0260f01ec | ||
|
|
9e743e29f4 | ||
|
|
ad746c0fcd | ||
|
|
5ab0ad45c2 | ||
|
|
1256b807a9 | ||
|
|
37243c5fc0 | ||
|
|
1c2195dd64 | ||
|
|
c3157439c3 | ||
|
|
7236684178 | ||
|
|
dae7576c7a | ||
|
|
67ca3ce81d | ||
|
|
c740999e90 | ||
|
|
e7192987f4 | ||
|
|
510664c4d3 | ||
|
|
bf791949d0 | ||
|
|
8084f96dce | ||
|
|
8dbe1c7cbb | ||
|
|
c2f4fec86d | ||
|
|
cbfa9a433a | ||
|
|
b27ab48d27 | ||
|
|
f33c6397c0 | ||
|
|
1146f88466 | ||
|
|
8f2ca3ab23 | ||
|
|
f3adc1c7aa | ||
|
|
d5131b87db | ||
|
|
d8d5e7b100 | ||
|
|
026dbfceeb | ||
|
|
21eb5d7fd7 | ||
|
|
c91903a077 | ||
|
|
f5edd7bbf8 | ||
|
|
5f12e9d900 | ||
|
|
1176a4ba18 | ||
|
|
d73a9ef9fa | ||
|
|
43206e816f | ||
|
|
048bd8db57 | ||
|
|
42327fc110 | ||
|
|
194f9246ef | ||
|
|
17edeccecc | ||
|
|
b9ee4546b7 | ||
|
|
5214f618ba | ||
|
|
3d2fc36f69 | ||
|
|
1db379c048 | ||
|
|
aaa91868a3 | ||
|
|
faecf13df8 | ||
|
|
b59f1c809f | ||
|
|
d1bf5db4c5 | ||
|
|
13992b6155 | ||
|
|
6e6ec6cb03 | ||
|
|
fe1f67407d | ||
|
|
62180be8ac |
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
@@ -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
|
After Width: | Height: | Size: 1.0 KiB |
BIN
.cursor/skills/shadcn/assets/shadcn.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]`.
|
||||||
2
.github/workflows/docker-ci.yml
vendored
@@ -21,6 +21,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build Dockerfile image
|
- name: Build Dockerfile image
|
||||||
@@ -55,6 +56,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Docker Compose up
|
- name: Docker Compose up
|
||||||
|
|||||||
1
.github/workflows/nextjs.yml
vendored
@@ -78,6 +78,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
|
||||||
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID }}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=${{ secrets.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL }}
|
||||||
EOF
|
EOF
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -83,5 +83,5 @@ fabric.properties
|
|||||||
|
|
||||||
.env.local
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea/*
|
||||||
.husky/_/
|
.husky/_/
|
||||||
|
|||||||
5
.idea/real-time-fund.iml
generated
@@ -1,7 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="WEB_MODULE" version="4">
|
<module type="WEB_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.cursor" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.trae" />
|
||||||
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ ARG NEXT_PUBLIC_SUPABASE_URL
|
|||||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ARG NEXT_PUBLIC_GA_ID
|
ARG NEXT_PUBLIC_GA_ID
|
||||||
|
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
|
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --legacy-peer-deps
|
RUN npm install --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -21,6 +23,7 @@ ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
|||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||||
|
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next ./.next
|
||||||
|
|||||||
27
README.md
@@ -5,6 +5,16 @@
|
|||||||
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
|
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
|
||||||
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
|
||||||
## ✨ 特性
|
## ✨ 特性
|
||||||
|
|
||||||
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
||||||
@@ -48,7 +58,8 @@
|
|||||||
- `NEXT_PUBLIC_Supabase_URL`:Supabase 项目 URL
|
- `NEXT_PUBLIC_Supabase_URL`:Supabase 项目 URL
|
||||||
- `NEXT_PUBLIC_Supabase_ANON_KEY`:Supabase 匿名公钥
|
- `NEXT_PUBLIC_Supabase_ANON_KEY`:Supabase 匿名公钥
|
||||||
- `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`:Web3Forms Access Key
|
- `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`:Web3Forms Access Key
|
||||||
- `NEXT_PUBLIC_GA_ID`:Google Analytics Measurement ID(形如 `G-xxxx`)
|
- `NEXT_PUBLIC_GA_ID`:Google Analytics Measurement ID(如 `G-xxxx`)
|
||||||
|
- `NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`:GitHub 最新 Release 接口地址,用于在页面中展示“发现新版本”提示(如:`https://api.github.com/repos/hzm0321/real-time-fund/releases/latest`)
|
||||||
|
|
||||||
注:如不使用登录、反馈或 GA 统计功能,可不设置对应变量
|
注:如不使用登录、反馈或 GA 统计功能,可不设置对应变量
|
||||||
|
|
||||||
@@ -71,14 +82,18 @@
|
|||||||
|
|
||||||
3. 修改接收到的邮件为验证码
|
3. 修改接收到的邮件为验证码
|
||||||
|
|
||||||
在 Supabase控制台 → Authentication → Email → Confirm sign up,选择 `{{.token}}`。
|
在 Supabase控制台 → Authentication → Email Templates 中,选择 **Magic Link** 模板进行编辑,在邮件正文中使用变量 `{{ .Token }}` 展示验证码。
|
||||||
|
|
||||||
4. 修改验证码位数
|
4. 修改验证码位数
|
||||||
|
|
||||||
官方验证码位数默认为8位,可自行修改。常见一般为6位。
|
官方验证码位数默认为8位,可自行修改。常见一般为6位。
|
||||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email → Minimum password length 和 Email OTP Length 都改为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 相关内容查阅官方文档。
|
更多 Supabase 相关内容查阅官方文档。
|
||||||
|
|
||||||
@@ -86,7 +101,7 @@
|
|||||||
|
|
||||||
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
|
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
|
||||||
如需使用 GitHub Actions 部署,请在 GitHub 项目 Settings → Secrets and variables → Actions 中创建对应的 Repository secrets(字段名称与 `.env.local` 保持一致)。
|
如需使用 GitHub Actions 部署,请在 GitHub 项目 Settings → Secrets and variables → Actions 中创建对应的 Repository secrets(字段名称与 `.env.local` 保持一致)。
|
||||||
包括:`NEXT_PUBLIC_Supabase_URL`、`NEXT_PUBLIC_Supabase_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`。
|
包括:`NEXT_PUBLIC_Supabase_URL`、`NEXT_PUBLIC_Supabase_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`、`NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`。
|
||||||
|
|
||||||
若要手动构建:
|
若要手动构建:
|
||||||
```bash
|
```bash
|
||||||
@@ -125,9 +140,9 @@ docker compose up -d
|
|||||||
|
|
||||||
## 💬 开发者交流群
|
## 💬 开发者交流群
|
||||||
|
|
||||||
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
||||||
|
|
||||||
微信开发群人数已满200,如需加入请加微信号 `hzm1998hzm` 。加v备注:`基估宝开发`,邀请入群。
|
<img src="./doc/weChatGroupDevelop.jpg" width="300">
|
||||||
|
|
||||||
## 📝 免责声明
|
## 📝 免责声明
|
||||||
|
|
||||||
|
|||||||
579
app/api/fund.js
@@ -1,6 +1,7 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { isString } from 'lodash';
|
||||||
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
@@ -20,7 +21,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
|||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
|
|
||||||
export const loadScript = (url) => {
|
export const loadScript = (url) => {
|
||||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve();
|
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||||
|
|
||||||
let cacheKey = url;
|
let cacheKey = url;
|
||||||
try {
|
try {
|
||||||
@@ -69,9 +70,7 @@ export const loadScript = (url) => {
|
|||||||
clearCachedRequest(cacheKey);
|
clearCachedRequest(cacheKey);
|
||||||
throw new Error(result?.error || '数据加载失败');
|
throw new Error(result?.error || '数据加载失败');
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && result.apidata !== undefined) {
|
return result.apidata;
|
||||||
window.apidata = result.apidata;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,9 +78,9 @@ export const fetchFundNetValue = async (code, date) => {
|
|||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
|
||||||
try {
|
try {
|
||||||
await loadScript(url);
|
const apidata = await loadScript(url);
|
||||||
if (window.apidata && window.apidata.content) {
|
if (apidata && apidata.content) {
|
||||||
const content = window.apidata.content;
|
const content = apidata.content;
|
||||||
if (content.includes('暂无数据')) return null;
|
if (content.includes('暂无数据')) return null;
|
||||||
const rows = content.split('<tr>');
|
const rows = content.split('<tr>');
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -101,6 +100,81 @@ export const fetchFundNetValue = async (code, date) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseLatestNetValueFromLsjzContent = (content) => {
|
||||||
|
if (!content || content.includes('暂无数据')) return null;
|
||||||
|
const rowMatches = content.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||||
|
for (const row of rowMatches) {
|
||||||
|
const cells = row.match(/<td[^>]*>(.*?)<\/td>/gi) || [];
|
||||||
|
if (!cells.length) continue;
|
||||||
|
const getText = (td) => td.replace(/<[^>]+>/g, '').trim();
|
||||||
|
const dateStr = getText(cells[0] || '');
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) continue;
|
||||||
|
const navStr = getText(cells[1] || '');
|
||||||
|
const nav = parseFloat(navStr);
|
||||||
|
if (!Number.isFinite(nav)) continue;
|
||||||
|
let growth = null;
|
||||||
|
for (const c of cells) {
|
||||||
|
const txt = getText(c);
|
||||||
|
const m = txt.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||||
|
if (m) {
|
||||||
|
growth = parseFloat(m[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { date: dateStr, nav, growth };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractHoldingsReportDate = (html) => {
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
// 优先匹配带有“报告期 / 截止日期”等关键字附近的日期
|
||||||
|
const m1 = html.match(/(报告期|截止日期)[^0-9]{0,20}(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (m1) return m1[2];
|
||||||
|
|
||||||
|
// 兜底:取文中出现的第一个 yyyy-MM-dd 格式日期
|
||||||
|
const m2 = html.match(/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
return m2 ? m2[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLastQuarterReport = (reportDateStr) => {
|
||||||
|
if (!reportDateStr) return false;
|
||||||
|
|
||||||
|
const report = dayjs(reportDateStr, 'YYYY-MM-DD');
|
||||||
|
if (!report.isValid()) return false;
|
||||||
|
|
||||||
|
const now = nowInTz();
|
||||||
|
const m = now.month(); // 0-11
|
||||||
|
const q = Math.floor(m / 3); // 当前季度 0-3 => Q1-Q4
|
||||||
|
|
||||||
|
let lastQ;
|
||||||
|
let year;
|
||||||
|
if (q === 0) {
|
||||||
|
// 当前为 Q1,则上一季度是上一年的 Q4
|
||||||
|
lastQ = 3;
|
||||||
|
year = now.year() - 1;
|
||||||
|
} else {
|
||||||
|
lastQ = q - 1;
|
||||||
|
year = now.year();
|
||||||
|
}
|
||||||
|
|
||||||
|
const quarterEnds = [
|
||||||
|
{ month: 2, day: 31 }, // Q1 -> 03-31
|
||||||
|
{ month: 5, day: 30 }, // Q2 -> 06-30
|
||||||
|
{ month: 8, day: 30 }, // Q3 -> 09-30
|
||||||
|
{ month: 11, day: 31 } // Q4 -> 12-31
|
||||||
|
];
|
||||||
|
|
||||||
|
const { month: endMonth, day: endDay } = quarterEnds[lastQ];
|
||||||
|
const lastQuarterEnd = dayjs(
|
||||||
|
`${year}-${String(endMonth + 1).padStart(2, '0')}-${endDay}`,
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
);
|
||||||
|
|
||||||
|
return report.isSame(lastQuarterEnd, 'day');
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchSmartFundNetValue = async (code, startDate) => {
|
export const fetchSmartFundNetValue = async (code, startDate) => {
|
||||||
const today = nowInTz().startOf('day');
|
const today = nowInTz().startOf('day');
|
||||||
let current = toTz(startDate).startOf('day');
|
let current = toTz(startDate).startOf('day');
|
||||||
@@ -157,43 +231,33 @@ export const fetchFundDataFallback = async (c) => {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
try {
|
||||||
const tScript = document.createElement('script');
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||||
tScript.src = tUrl;
|
const apidata = await loadScript(url);
|
||||||
tScript.onload = () => {
|
const content = apidata?.content || '';
|
||||||
const v = window[`v_jj${c}`];
|
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||||
if (v && v.length > 5) {
|
if (latest && latest.nav) {
|
||||||
const p = v.split('~');
|
const name = fundName || `未知基金(${c})`;
|
||||||
const name = fundName || p[1] || `未知基金(${c})`;
|
resolve({
|
||||||
const dwjz = p[5];
|
code: c,
|
||||||
const zzl = parseFloat(p[7]);
|
name,
|
||||||
const jzrq = p[8] ? p[8].slice(0, 10) : '';
|
dwjz: String(latest.nav),
|
||||||
if (dwjz) {
|
gsz: null,
|
||||||
resolve({
|
gztime: null,
|
||||||
code: c,
|
jzrq: latest.date,
|
||||||
name: name,
|
gszzl: null,
|
||||||
dwjz: dwjz,
|
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||||
gsz: null,
|
noValuation: true,
|
||||||
gztime: null,
|
holdings: [],
|
||||||
jzrq: jzrq,
|
holdingsReportDate: null,
|
||||||
gszzl: null,
|
holdingsIsLastQuarter: false
|
||||||
zzl: !isNaN(zzl) ? zzl : null,
|
});
|
||||||
noValuation: true,
|
|
||||||
holdings: []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('未能获取到基金数据'));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('未能获取到基金数据'));
|
reject(new Error('未能获取到基金数据'));
|
||||||
}
|
}
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
} catch (e) {
|
||||||
};
|
|
||||||
tScript.onerror = () => {
|
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
|
||||||
reject(new Error('基金数据加载失败'));
|
reject(new Error('基金数据加载失败'));
|
||||||
};
|
}
|
||||||
document.body.appendChild(tScript);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,35 +286,43 @@ export const fetchFundData = async (c) => {
|
|||||||
jzrq: json.jzrq,
|
jzrq: json.jzrq,
|
||||||
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
|
||||||
};
|
};
|
||||||
const tencentPromise = new Promise((resolveT) => {
|
const lsjzPromise = new Promise((resolveT) => {
|
||||||
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
|
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${c}&page=1&per=1&sdate=&edate=`;
|
||||||
const tScript = document.createElement('script');
|
loadScript(url)
|
||||||
tScript.src = tUrl;
|
.then((apidata) => {
|
||||||
tScript.onload = () => {
|
const content = apidata?.content || '';
|
||||||
const v = window[`v_jj${c}`];
|
const latest = parseLatestNetValueFromLsjzContent(content);
|
||||||
if (v) {
|
if (latest && latest.nav) {
|
||||||
const p = v.split('~');
|
resolveT({
|
||||||
resolveT({
|
dwjz: String(latest.nav),
|
||||||
dwjz: p[5],
|
zzl: Number.isFinite(latest.growth) ? latest.growth : null,
|
||||||
zzl: parseFloat(p[7]),
|
jzrq: latest.date
|
||||||
jzrq: p[8] ? p[8].slice(0, 10) : ''
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
resolveT(null);
|
||||||
resolveT(null);
|
}
|
||||||
}
|
})
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
.catch(() => resolveT(null));
|
||||||
};
|
|
||||||
tScript.onerror = () => {
|
|
||||||
if (document.body.contains(tScript)) document.body.removeChild(tScript);
|
|
||||||
resolveT(null);
|
|
||||||
};
|
|
||||||
document.body.appendChild(tScript);
|
|
||||||
});
|
});
|
||||||
const holdingsPromise = new Promise((resolveH) => {
|
const holdingsPromise = new Promise((resolveH) => {
|
||||||
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
|
||||||
loadScript(holdingsUrl).then(async () => {
|
const holdingsCacheKey = `fund_holdings_archives_${c}`;
|
||||||
|
cachedRequest(
|
||||||
|
() => loadScript(holdingsUrl),
|
||||||
|
holdingsCacheKey,
|
||||||
|
{ cacheTime: 60 * 60 * 1000 }
|
||||||
|
).then(async (apidata) => {
|
||||||
let holdings = [];
|
let holdings = [];
|
||||||
const html = window.apidata?.content || '';
|
const html = apidata?.content || '';
|
||||||
|
const holdingsReportDate = extractHoldingsReportDate(html);
|
||||||
|
const holdingsIsLastQuarter = isLastQuarterReport(holdingsReportDate);
|
||||||
|
|
||||||
|
// 如果不是上一季度末的披露数据,则不展示重仓(并避免继续解析/请求行情)
|
||||||
|
if (!holdingsIsLastQuarter) {
|
||||||
|
resolveH({ holdings: [], holdingsReportDate, holdingsIsLastQuarter: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
||||||
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
|
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
|
||||||
let idxCode = -1, idxName = -1, idxWeight = -1;
|
let idxCode = -1, idxName = -1, idxWeight = -1;
|
||||||
@@ -347,10 +419,15 @@ export const fetchFundData = async (c) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolveH(holdings);
|
resolveH({ holdings, holdingsReportDate, holdingsIsLastQuarter });
|
||||||
}).catch(() => resolveH([]));
|
}).catch(() => resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false }));
|
||||||
});
|
});
|
||||||
Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
|
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
|
||||||
|
const {
|
||||||
|
holdings,
|
||||||
|
holdingsReportDate,
|
||||||
|
holdingsIsLastQuarter
|
||||||
|
} = holdingsResult || {};
|
||||||
if (tData) {
|
if (tData) {
|
||||||
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
|
||||||
gzData.dwjz = tData.dwjz;
|
gzData.dwjz = tData.dwjz;
|
||||||
@@ -358,7 +435,12 @@ export const fetchFundData = async (c) => {
|
|||||||
gzData.zzl = tData.zzl;
|
gzData.zzl = tData.zzl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve({ ...gzData, holdings });
|
resolve({
|
||||||
|
...gzData,
|
||||||
|
holdings,
|
||||||
|
holdingsReportDate,
|
||||||
|
holdingsIsLastQuarter
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
scriptGz.onerror = () => {
|
scriptGz.onerror = () => {
|
||||||
@@ -431,8 +513,96 @@ export const fetchShanghaiIndexDate = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 大盘指数项:name, code, price, change, changePercent
|
||||||
|
* 同时用于:
|
||||||
|
* - qt.gtimg.cn 实时快照(code 用于 q= 参数,varKey 为全局变量名)
|
||||||
|
* - 分时 mini 图(code 传给 minute/query,当不支持分时时会自动回退占位折线)
|
||||||
|
*
|
||||||
|
* 参照产品图:覆盖主要 A 股宽基 + 创业/科创 + 部分海外与港股指数。
|
||||||
|
*/
|
||||||
|
const MARKET_INDEX_KEYS = [
|
||||||
|
// 行 1:上证 / 深证
|
||||||
|
{ code: 'sh000001', varKey: 'v_sh000001', name: '上证指数' },
|
||||||
|
{ code: 'sh000016', varKey: 'v_sh000016', name: '上证50' },
|
||||||
|
{ code: 'sz399001', varKey: 'v_sz399001', name: '深证成指' },
|
||||||
|
{ code: 'sz399330', varKey: 'v_sz399330', name: '深证100' },
|
||||||
|
|
||||||
|
// 行 2:北证 / 沪深300 / 创业板
|
||||||
|
{ code: 'bj899050', varKey: 'v_bj899050', name: '北证50' },
|
||||||
|
{ code: 'sh000300', varKey: 'v_sh000300', name: '沪深300' },
|
||||||
|
{ code: 'sz399006', varKey: 'v_sz399006', name: '创业板指' },
|
||||||
|
{ code: 'sz399102', varKey: 'v_sz399102', name: '创业板综' },
|
||||||
|
|
||||||
|
// 行 3:创业板 50 / 科创
|
||||||
|
{ code: 'sz399673', varKey: 'v_sz399673', name: '创业板50' },
|
||||||
|
{ code: 'sh000688', varKey: 'v_sh000688', name: '科创50' },
|
||||||
|
{ code: 'sz399005', varKey: 'v_sz399005', name: '中小100' },
|
||||||
|
|
||||||
|
// 行 4:中证系列
|
||||||
|
{ code: 'sh000905', varKey: 'v_sh000905', name: '中证500' },
|
||||||
|
{ code: 'sh000906', varKey: 'v_sh000906', name: '中证800' },
|
||||||
|
{ code: 'sh000852', varKey: 'v_sh000852', name: '中证1000' },
|
||||||
|
{ code: 'sh000903', varKey: 'v_sh000903', name: '中证A100' },
|
||||||
|
|
||||||
|
// 行 5:等权 / 国证 / 纳指
|
||||||
|
{ code: 'sh000932', varKey: 'v_sh000932', name: '500等权' },
|
||||||
|
{ code: 'sz399303', varKey: 'v_sz399303', name: '国证2000' },
|
||||||
|
{ code: 'usIXIC', varKey: 'v_usIXIC', name: '纳斯达克' },
|
||||||
|
{ code: 'usNDX', varKey: 'v_usNDX', name: '纳斯达克100' },
|
||||||
|
|
||||||
|
// 行 6:美股三大 + 恒生
|
||||||
|
{ code: 'usINX', varKey: 'v_usINX', name: '标普500' },
|
||||||
|
{ code: 'usDJI', varKey: 'v_usDJI', name: '道琼斯' },
|
||||||
|
{ code: 'hkHSI', varKey: 'v_hkHSI', name: '恒生指数' },
|
||||||
|
{ code: 'hkHSTECH', varKey: 'v_hkHSTECH', name: '恒生科技指数' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseIndexRaw(data) {
|
||||||
|
if (!data || typeof data !== 'string') return null;
|
||||||
|
const parts = data.split('~');
|
||||||
|
if (parts.length < 33) return null;
|
||||||
|
const name = parts[1] || '';
|
||||||
|
const price = parseFloat(parts[3], 10);
|
||||||
|
const change = parseFloat(parts[31], 10);
|
||||||
|
const changePercent = parseFloat(parts[32], 10);
|
||||||
|
if (Number.isNaN(price)) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price: Number.isFinite(price) ? price : 0,
|
||||||
|
change: Number.isFinite(change) ? change : 0,
|
||||||
|
changePercent: Number.isFinite(changePercent) ? changePercent : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMarketIndices = async () => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
const codes = MARKET_INDEX_KEYS.map((item) => item.code).join(',');
|
||||||
|
script.src = `https://qt.gtimg.cn/q=${codes}&_t=${Date.now()}`;
|
||||||
|
script.onload = () => {
|
||||||
|
const list = MARKET_INDEX_KEYS.map(({ name: defaultName, varKey }) => {
|
||||||
|
const raw = window[varKey];
|
||||||
|
const parsed = parseIndexRaw(raw);
|
||||||
|
if (!parsed) return { name: defaultName, code: '', price: 0, change: 0, changePercent: 0 };
|
||||||
|
return { ...parsed, code: varKey.replace('v_', '') };
|
||||||
|
});
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
resolve(list);
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
reject(new Error('指数数据加载失败'));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchLatestRelease = async () => {
|
export const fetchLatestRelease = async () => {
|
||||||
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
|
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return {
|
return {
|
||||||
@@ -449,7 +619,140 @@ export const submitFeedback = async (formData) => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
let historyQueue = Promise.resolve();
|
const PINGZHONGDATA_GLOBAL_KEYS = [
|
||||||
|
'ishb',
|
||||||
|
'fS_name',
|
||||||
|
'fS_code',
|
||||||
|
'fund_sourceRate',
|
||||||
|
'fund_Rate',
|
||||||
|
'fund_minsg',
|
||||||
|
'stockCodes',
|
||||||
|
'zqCodes',
|
||||||
|
'stockCodesNew',
|
||||||
|
'zqCodesNew',
|
||||||
|
'syl_1n',
|
||||||
|
'syl_6y',
|
||||||
|
'syl_3y',
|
||||||
|
'syl_1y',
|
||||||
|
'Data_fundSharesPositions',
|
||||||
|
'Data_netWorthTrend',
|
||||||
|
'Data_ACWorthTrend',
|
||||||
|
'Data_grandTotal',
|
||||||
|
'Data_rateInSimilarType',
|
||||||
|
'Data_rateInSimilarPersent',
|
||||||
|
'Data_fluctuationScale',
|
||||||
|
'Data_holderStructure',
|
||||||
|
'Data_assetAllocation',
|
||||||
|
'Data_performanceEvaluation',
|
||||||
|
'Data_currentFundManager',
|
||||||
|
'Data_buySedemption',
|
||||||
|
'swithSameType',
|
||||||
|
];
|
||||||
|
|
||||||
|
let pingzhongdataQueue = Promise.resolve();
|
||||||
|
|
||||||
|
const enqueuePingzhongdataLoad = (fn) => {
|
||||||
|
const p = pingzhongdataQueue.then(fn, fn);
|
||||||
|
// 避免队列被 reject 永久阻塞
|
||||||
|
pingzhongdataQueue = p.catch(() => undefined);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||||
|
const out = {};
|
||||||
|
for (const k of PINGZHONGDATA_GLOBAL_KEYS) {
|
||||||
|
if (typeof window?.[k] === 'undefined') continue;
|
||||||
|
try {
|
||||||
|
out[k] = JSON.parse(JSON.stringify(window[k]));
|
||||||
|
} catch (e) {
|
||||||
|
out[k] = window[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fundCode: out.fS_code || fundCode,
|
||||||
|
fundName: out.fS_name || '',
|
||||||
|
...out,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
|
reject(new Error('无浏览器环境'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://fund.eastmoney.com/pingzhongdata/${fundCode}.js?v=${Date.now()}`;
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
script.onload = null;
|
||||||
|
script.onerror = null;
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('pingzhongdata 请求超时'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
const data = snapshotPingzhongdataGlobals(fundCode);
|
||||||
|
cleanup();
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('pingzhongdata 加载失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAndParsePingzhongdata = async (fundCode) => {
|
||||||
|
// 使用 JSONP(script 注入) 方式获取并解析 pingzhongdata
|
||||||
|
return enqueuePingzhongdataLoad(() => jsonpLoadPingzhongdata(fundCode));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并解析「基金走势图/资产等」数据(pingzhongdata)
|
||||||
|
* 来源:https://fund.eastmoney.com/pingzhongdata/${fundCode}.js
|
||||||
|
*/
|
||||||
|
export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 60 * 60 * 1000 } = {}) => {
|
||||||
|
if (!fundCode) throw new Error('fundCode 不能为空');
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
throw new Error('无浏览器环境');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `pingzhongdata_${fundCode}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await cachedRequest(
|
||||||
|
() => fetchAndParsePingzhongdata(fundCode),
|
||||||
|
cacheKey,
|
||||||
|
{ cacheTime }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
clearCachedRequest(cacheKey);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchFundHistory = async (code, range = '1m') => {
|
export const fetchFundHistory = async (code, range = '1m') => {
|
||||||
if (typeof window === 'undefined') return [];
|
if (typeof window === 'undefined') return [];
|
||||||
@@ -463,73 +766,77 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
case '6m': start = start.subtract(6, 'month'); break;
|
case '6m': start = start.subtract(6, 'month'); break;
|
||||||
case '1y': start = start.subtract(1, 'year'); break;
|
case '1y': start = start.subtract(1, 'year'); break;
|
||||||
case '3y': start = start.subtract(3, 'year'); break;
|
case '3y': start = start.subtract(3, 'year'); break;
|
||||||
|
case 'all': start = dayjs(0).tz(TZ); break;
|
||||||
default: start = start.subtract(1, 'month');
|
default: start = start.subtract(1, 'month');
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdate = start.format('YYYY-MM-DD');
|
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||||
const edate = end.format('YYYY-MM-DD');
|
try {
|
||||||
const per = 49;
|
const pz = await fetchFundPingzhongdata(code);
|
||||||
|
const trend = pz?.Data_netWorthTrend;
|
||||||
|
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)
|
||||||
|
.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 };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
if (out.length) return out;
|
||||||
historyQueue = historyQueue.then(async () => {
|
}
|
||||||
let allData = [];
|
} catch (e) {
|
||||||
let page = 1;
|
return [];
|
||||||
let totalPages = 1;
|
}
|
||||||
|
return [];
|
||||||
try {
|
};
|
||||||
const parseContent = (content) => {
|
|
||||||
if (!content) return [];
|
const API_KEYS = [
|
||||||
const rows = content.split('<tr>');
|
'sk-5b03d4e02ec22dd2ba233fb6d2dd549b',
|
||||||
const data = [];
|
'sk-5f14ce9c6e94af922bf592942426285c'
|
||||||
for (const row of rows) {
|
// 添加更多 API Key 到这里
|
||||||
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
|
];
|
||||||
if (cells && cells.length >= 2) {
|
|
||||||
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim();
|
// 随机从数组中选择一个 API Key
|
||||||
const valStr = cells[1].replace(/<[^>]+>/g, '').trim();
|
const getRandomApiKey = () => {
|
||||||
const val = parseFloat(valStr);
|
if (!API_KEYS.length) return null;
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) {
|
return API_KEYS[Math.floor(Math.random() * API_KEYS.length)];
|
||||||
data.push({ date: dateStr, value: val });
|
};
|
||||||
}
|
|
||||||
}
|
export const parseFundTextWithLLM = async (text) => {
|
||||||
}
|
const apiKey = getRandomApiKey();
|
||||||
return data;
|
if (!apiKey || !text) return null;
|
||||||
};
|
|
||||||
|
try {
|
||||||
// Fetch first page to get metadata
|
const response = await fetch('https://apis.iflow.cn/v1/chat/completions', {
|
||||||
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
method: 'POST',
|
||||||
await loadScript(firstUrl);
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) {
|
'Content-Type': 'application/json'
|
||||||
resolve([]);
|
},
|
||||||
return;
|
body: JSON.stringify({
|
||||||
}
|
model: 'qwen3-max',
|
||||||
|
messages: [
|
||||||
// Parse total pages
|
{ role: 'system', content: "你是一个基金文本解析助手。请从提供的OCR文本中执行以下任务:\n抽取所有基金信息,包括:基金名称:中文字符串(可含英文或括号),名称后常跟随金额数字。基金代码:6位数字(如果存在)。持有金额:数字格式(可能含千分位逗号或小数,如果存在)。持有收益:数字格式(可能含千分位逗号或小数,如果存在)。忽略无关文本。输出格式:以JSON数组形式返回结果,每个基金信息为一个对象,包含以下字段:基金名称(必填,字符串)基金代码(可选,字符串,不存在时为空字符串)持有金额(可选,字符串,不存在时为空字符串)持有收益(可选,字符串,不存在时为空字符串)示例输出:[{'fundName':'华夏成长混合','fundCode':'000001','holdAmounts':'50,000.00','holdGains':'2,500.00'},{'fundName':'易方达消费行业','fundCode':'','holdAmounts':'10,000.00','holdGains':'}]。除了示例输出的内容外,不要输出任何多余内容"},
|
||||||
if (window.apidata.pages) {
|
{ role: 'user', content: text }
|
||||||
totalPages = parseInt(window.apidata.pages, 10) || 1;
|
],
|
||||||
}
|
temperature: 0.3,
|
||||||
|
max_tokens: 2000
|
||||||
allData = allData.concat(parseContent(window.apidata.content));
|
})
|
||||||
|
});
|
||||||
// Fetch remaining pages
|
|
||||||
for (page = 2; page <= totalPages; page++) {
|
if (!response.ok) {
|
||||||
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
|
return null;
|
||||||
await loadScript(nextUrl);
|
}
|
||||||
if (window.apidata && window.apidata.content) {
|
|
||||||
allData = allData.concat(parseContent(window.apidata.content));
|
const data = await response.json();
|
||||||
}
|
return data?.choices?.[0]?.message?.content || null;
|
||||||
}
|
} catch (e) {
|
||||||
|
return null;
|
||||||
// The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
|
}
|
||||||
resolve(allData.reverse());
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fetch history error:', e);
|
|
||||||
resolve([]);
|
|
||||||
}
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error('Queue error:', e);
|
|
||||||
resolve([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
<?xml version="1.0" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770335913293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1562" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770335913293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1562" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||||
<path d="M512 85.333333C276.266667 85.333333 85.333333 276.266667 85.333333 512a426.410667 426.410667 0 0 0 291.754667 404.821333c21.333333 3.712 29.312-9.088 29.312-20.309333 0-10.112-0.554667-43.690667-0.554667-79.445333-107.178667 19.754667-134.912-26.112-143.445333-50.133334-4.821333-12.288-25.6-50.133333-43.733333-60.288-14.933333-7.978667-36.266667-27.733333-0.554667-28.245333 33.621333-0.554667 57.6 30.933333 65.621333 43.733333 38.4 64.512 99.754667 46.378667 124.245334 35.2 3.754667-27.733333 14.933333-46.378667 27.221333-57.045333-94.933333-10.666667-194.133333-47.488-194.133333-210.688 0-46.421333 16.512-84.778667 43.733333-114.688-4.266667-10.666667-19.2-54.4 4.266667-113.066667 0 0 35.712-11.178667 117.333333 43.776a395.946667 395.946667 0 0 1 106.666667-14.421333c36.266667 0 72.533333 4.778667 106.666666 14.378667 81.578667-55.466667 117.333333-43.690667 117.333334-43.690667 23.466667 58.666667 8.533333 102.4 4.266666 113.066667 27.178667 29.866667 43.733333 67.712 43.733334 114.645333 0 163.754667-99.712 200.021333-194.645334 210.688 15.445333 13.312 28.8 38.912 28.8 78.933333 0 57.045333-0.554667 102.912-0.554666 117.333334 0 11.178667 8.021333 24.490667 29.354666 20.224A427.349333 427.349333 0 0 0 938.666667 512c0-235.733333-190.933333-426.666667-426.666667-426.666667z" fill="#000000" p-id="1563"></path>
|
<path d="M512 85.333333C276.266667 85.333333 85.333333 276.266667 85.333333 512a426.410667 426.410667 0 0 0 291.754667 404.821333c21.333333 3.712 29.312-9.088 29.312-20.309333 0-10.112-0.554667-43.690667-0.554667-79.445333-107.178667 19.754667-134.912-26.112-143.445333-50.133334-4.821333-12.288-25.6-50.133333-43.733333-60.288-14.933333-7.978667-36.266667-27.733333-0.554667-28.245333 33.621333-0.554667 57.6 30.933333 65.621333 43.733333 38.4 64.512 99.754667 46.378667 124.245334 35.2 3.754667-27.733333 14.933333-46.378667 27.221333-57.045333-94.933333-10.666667-194.133333-47.488-194.133333-210.688 0-46.421333 16.512-84.778667 43.733333-114.688-4.266667-10.666667-19.2-54.4 4.266667-113.066667 0 0 35.712-11.178667 117.333333 43.776a395.946667 395.946667 0 0 1 106.666667-14.421333c36.266667 0 72.533333 4.778667 106.666666 14.378667 81.578667-55.466667 117.333333-43.690667 117.333334-43.690667 23.466667 58.666667 8.533333 102.4 4.266666 113.066667 27.178667 29.866667 43.733333 67.712 43.733334 114.645333 0 163.754667-99.712 200.021333-194.645334 210.688 15.445333 13.312 28.8 38.912 28.8 78.933333 0 57.045333-0.554667 102.912-0.554666 117.333334 0 11.178667 8.021333 24.490667 29.354666 20.224A427.349333 427.349333 0 0 0 938.666667 512c0-235.733333-190.933333-426.666667-426.666667-426.666667z" fill="#d2d2d2" p-id="1563"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 60 KiB |
@@ -1,14 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { CloseIcon, PlusIcon } from './Icons';
|
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 [selected, setSelected] = useState(new Set());
|
||||||
|
|
||||||
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
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) => {
|
const toggleSelect = (code) => {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -18,24 +30,21 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
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 }}
|
|
||||||
className="glass card modal"
|
className="glass card modal"
|
||||||
style={{ maxWidth: '500px', width: '90vw' }}
|
overlayClassName="modal-overlay"
|
||||||
onClick={(e) => e.stopPropagation()}
|
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">添加基金到分组</DialogTitle>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<PlusIcon width="20" height="20" />
|
<PlusIcon width="20" height="20" />
|
||||||
@@ -63,9 +72,14 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
|||||||
<div className="checkbox" style={{ marginRight: 12 }}>
|
<div className="checkbox" style={{ marginRight: 12 }}>
|
||||||
{selected.has(fund.code) && <div className="checked-mark" />}
|
{selected.has(fund.code) && <div className="checked-mark" />}
|
||||||
</div>
|
</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 style={{ fontWeight: 600 }}>{fund.name}</div>
|
||||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund.code}</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -84,7 +98,7 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
|||||||
确定 ({selected.size})
|
确定 ({selected.size})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
import { fetchSmartFundNetValue } from '../api/fund';
|
import { fetchSmartFundNetValue } from '../api/fund';
|
||||||
import { DatePicker } from './Common';
|
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 }) {
|
export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||||
const [type, setType] = useState('');
|
const [type, setType] = useState('');
|
||||||
@@ -77,30 +82,36 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseClick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
aria-modal="true"
|
|
||||||
aria-label="添加历史记录"
|
|
||||||
onClick={onClose}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
style={{ zIndex: 1200 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="glass card modal"
|
className="glass card modal"
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
overlayClassName="modal-overlay"
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
overlayStyle={{ zIndex: 9998 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
style={{ maxWidth: '420px', zIndex: 9999, width: '90vw' }}
|
||||||
style={{ maxWidth: '420px' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">添加历史记录</DialogTitle>
|
||||||
|
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<span>添加历史记录</span>
|
<span>添加历史记录</span>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button
|
||||||
<CloseIcon />
|
className="icon-button"
|
||||||
|
onClick={handleCloseClick}
|
||||||
|
style={{ border: 'none', background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,6 +198,7 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
className="input"
|
className="input"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={handleAmountChange}
|
onChange={handleAmountChange}
|
||||||
@@ -196,14 +208,21 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
className="button primary full-width"
|
*此处补录的买入/卖出仅作记录展示,不会改变当前持仓金额与份额;实际持仓请在持仓设置中维护。
|
||||||
onClick={handleSubmit}
|
</div>
|
||||||
disabled={!type || !date || !netValue || !amount || !share || loading}
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
>
|
<Button
|
||||||
确认添加
|
type="button"
|
||||||
</button>
|
variant="default"
|
||||||
</motion.div>
|
size="lg"
|
||||||
</motion.div>
|
onClick={handleSubmit}
|
||||||
|
disabled={!type || !date || !netValue || !amount || !share || loading}
|
||||||
|
>
|
||||||
|
确认添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v8';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v17';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -16,6 +16,16 @@ export default function Announcement() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 清理历史 ANNOUNCEMENT_KEY
|
||||||
|
const keysToRemove = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith('hasClosedAnnouncement_v') && key !== ANNOUNCEMENT_KEY) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||||
|
|
||||||
localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
|
localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
};
|
};
|
||||||
@@ -52,6 +62,8 @@ export default function Announcement() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
|
maxHeight: 'calc(100dvh - 40px)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}>
|
<div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}>
|
||||||
@@ -62,23 +74,23 @@ export default function Announcement() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>公告</span>
|
<span>公告</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}>
|
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||||
为了增加更多用户方便访问, 新增国内加速地址:<a className="link-button"
|
<p>v0.2.6 更新内容:</p>
|
||||||
target="_blank"
|
<p>1. 新增大盘指数并支持个性化。</p>
|
||||||
rel="noopener noreferrer"
|
<p>2. 新增持仓金额排序以及排序个性化设置。</p>
|
||||||
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
<p>3. 新增历史净值。</p>
|
||||||
<p>节后第一次更新内容如下:</p>
|
<p>4. 表格视图斑马纹。</p>
|
||||||
<p>1. OCR 识别截图导入基金。</p>
|
<br/>
|
||||||
<p>2. 基金历史曲线图。</p>
|
<p>下一版本更新内容:</p>
|
||||||
<p>3. 买入、卖出历史记录。</p>
|
<p>1. 关联板块。</p>
|
||||||
以下内容会在近期更新:
|
<p>2. 收益曲线。</p>
|
||||||
<p>1. 定投。</p>
|
<p>3. 估值差异列。</p>
|
||||||
<p>2. 自定义布局。</p>
|
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
style={{ width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center' }}
|
style={{ width: '100%', justifyContent: 'center', display: 'flex', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon, CloudIcon } from './Icons';
|
import { CloseIcon, CloudIcon } from './Icons';
|
||||||
|
|
||||||
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
|
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
|
||||||
|
const [pendingAction, setPendingAction] = useState(null); // 'local' | 'cloud' | null
|
||||||
const isConflict = type === 'conflict';
|
const isConflict = type === 'conflict';
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (isConflict) {
|
||||||
|
setPendingAction('local');
|
||||||
|
} else {
|
||||||
|
onConfirm?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondaryClick = () => {
|
||||||
|
if (isConflict) {
|
||||||
|
setPendingAction('cloud');
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmModalCancel = () => {
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmModalConfirm = () => {
|
||||||
|
if (pendingAction === 'local') {
|
||||||
|
onConfirm?.();
|
||||||
|
} else if (pendingAction === 'cloud') {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmTitle =
|
||||||
|
pendingAction === 'local'
|
||||||
|
? '确认使用本地配置覆盖云端?'
|
||||||
|
: '确认使用云端配置覆盖本地?';
|
||||||
|
|
||||||
|
const confirmMessage =
|
||||||
|
pendingAction === 'local'
|
||||||
|
? '此操作会将当前本地配置同步到云端,覆盖云端原有配置,且可能无法恢复,请谨慎操作。'
|
||||||
|
: '此操作会使用云端配置覆盖当前本地配置,导致本地修改丢失,且可能无法恢复,请谨慎操作。';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -41,14 +84,25 @@ export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }
|
|||||||
: '是否将本地配置同步到云端?'}
|
: '是否将本地配置同步到云端?'}
|
||||||
</p>
|
</p>
|
||||||
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
|
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
|
||||||
<button className="button" onClick={onConfirm}>
|
<button className="button secondary" onClick={handlePrimaryClick}>
|
||||||
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
|
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={onCancel}>
|
<button className="button" onClick={handleSecondaryClick}>
|
||||||
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
|
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
{pendingAction && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={confirmTitle}
|
||||||
|
message={confirmMessage}
|
||||||
|
onConfirm={handleConfirmModalConfirm}
|
||||||
|
onCancel={handleConfirmModalCancel}
|
||||||
|
confirmText="确认覆盖"
|
||||||
|
icon={<CloudIcon width="20" height="20" />}
|
||||||
|
confirmVariant="danger"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,20 +73,8 @@ export function DatePicker({ value, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
||||||
<div
|
<div
|
||||||
className="input-trigger"
|
className="date-picker-trigger"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '0 12px',
|
|
||||||
height: '40px',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span>{value || '选择日期'}</span>
|
<span>{value || '选择日期'}</span>
|
||||||
<CalendarIcon width="16" height="16" className="muted" />
|
<CalendarIcon width="16" height="16" className="muted" />
|
||||||
@@ -98,7 +86,7 @@ export function DatePicker({ value, onChange }) {
|
|||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
className="glass card"
|
className="date-picker-dropdown glass card"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
@@ -106,10 +94,7 @@ export function DatePicker({ value, onChange }) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
zIndex: 10,
|
zIndex: 10
|
||||||
background: 'rgba(30, 41, 59, 0.95)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
@@ -141,26 +126,8 @@ export function DatePicker({ value, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
className={`date-picker-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isFuture ? 'future' : ''}`}
|
||||||
onClick={(e) => !isFuture && handleSelect(e, d)}
|
onClick={(e) => !isFuture && handleSelect(e, d)}
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '13px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: isFuture ? 'not-allowed' : 'pointer',
|
|
||||||
background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
|
|
||||||
color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
|
|
||||||
fontWeight: isSelected || isToday ? 600 : 400,
|
|
||||||
opacity: isFuture ? 0.3 : 1
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +238,7 @@ export function NumericInput({ value, onChange, step = 1, min = 0, placeholder }
|
|||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className="input no-zoom"
|
className="input no-zoom"
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,43 +1,67 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { TrashIcon } from './Icons';
|
import { TrashIcon } from './Icons';
|
||||||
|
|
||||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
export default function ConfirmModal({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
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 (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
overlayClassName="!z-[12000]"
|
||||||
aria-modal="true"
|
showCloseButton={false}
|
||||||
onClick={(e) => {
|
className="!z-[12010] max-w-[400px] flex flex-col gap-5 p-6"
|
||||||
e.stopPropagation();
|
|
||||||
onCancel();
|
|
||||||
}}
|
|
||||||
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 className="flex flex-row items-center gap-3 text-left">
|
||||||
<TrashIcon width="20" height="20" className="danger" />
|
{icon || (
|
||||||
<span>{title}</span>
|
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||||
</div>
|
)}
|
||||||
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
|
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</DialogDescription>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
<button
|
||||||
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>{confirmText}</button>
|
type="button"
|
||||||
|
className="button secondary 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={onCancel}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
385
app/components/DcaModal.jsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
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);
|
||||||
|
|
||||||
|
const DEFAULT_TZ = 'Asia/Shanghai';
|
||||||
|
const getBrowserTimeZone = () => {
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return tz || DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
return DEFAULT_TZ;
|
||||||
|
};
|
||||||
|
const TZ = getBrowserTimeZone();
|
||||||
|
dayjs.tz.setDefault(TZ);
|
||||||
|
const nowInTz = () => dayjs().tz(TZ);
|
||||||
|
const formatDate = (input) => dayjs.tz(input, TZ).format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const CYCLES = [
|
||||||
|
{ value: 'daily', label: '每日' },
|
||||||
|
{ value: 'weekly', label: '每周' },
|
||||||
|
{ value: 'biweekly', label: '每两周' },
|
||||||
|
{ value: 'monthly', label: '每月' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAY_OPTIONS = [
|
||||||
|
{ value: 1, label: '周一' },
|
||||||
|
{ value: 2, label: '周二' },
|
||||||
|
{ value: 3, label: '周三' },
|
||||||
|
{ value: 4, label: '周四' },
|
||||||
|
{ value: 5, label: '周五' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const computeFirstDate = (cycle, weeklyDay, monthlyDay) => {
|
||||||
|
const today = nowInTz().startOf('day');
|
||||||
|
|
||||||
|
if (cycle === 'weekly' || cycle === 'biweekly') {
|
||||||
|
const todayDay = today.day(); // 0-6, 1=周一
|
||||||
|
let target = isNumber(weeklyDay) ? weeklyDay : todayDay;
|
||||||
|
if (target < 1 || target > 5) {
|
||||||
|
// 如果当前是周末且未设定,默认周一
|
||||||
|
target = 1;
|
||||||
|
}
|
||||||
|
let candidate = today;
|
||||||
|
for (let i = 0; i < 14; i += 1) {
|
||||||
|
if (candidate.day() === target && !candidate.isBefore(today)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate = candidate.add(1, 'day');
|
||||||
|
}
|
||||||
|
return candidate.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle === 'monthly') {
|
||||||
|
const baseDay = today.date();
|
||||||
|
const day =
|
||||||
|
isNumber(monthlyDay) && monthlyDay >= 1 && monthlyDay <= 28
|
||||||
|
? monthlyDay
|
||||||
|
: Math.min(28, baseDay);
|
||||||
|
|
||||||
|
let candidate = today.date(day);
|
||||||
|
if (candidate.isBefore(today)) {
|
||||||
|
candidate = today.add(1, 'month').date(day);
|
||||||
|
}
|
||||||
|
return candidate.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(today);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [feeRate, setFeeRate] = useState('0');
|
||||||
|
const [cycle, setCycle] = useState('monthly');
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [weeklyDay, setWeeklyDay] = useState(() => {
|
||||||
|
const d = nowInTz().day();
|
||||||
|
return d >= 1 && d <= 5 ? d : 1;
|
||||||
|
});
|
||||||
|
const [monthlyDay, setMonthlyDay] = useState(() => {
|
||||||
|
const d = nowInTz().date();
|
||||||
|
return d >= 1 && d <= 28 ? d : 1;
|
||||||
|
});
|
||||||
|
const [firstDate, setFirstDate] = useState(() => computeFirstDate('monthly', null, null));
|
||||||
|
const monthlyDayRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plan) {
|
||||||
|
// 新建定投时,以当前默认 weeklyDay/monthlyDay 计算一次首扣日期
|
||||||
|
setFirstDate(computeFirstDate('monthly', weeklyDay, monthlyDay));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plan.amount != null) {
|
||||||
|
setAmount(String(plan.amount));
|
||||||
|
}
|
||||||
|
if (plan.feeRate != null) {
|
||||||
|
setFeeRate(String(plan.feeRate));
|
||||||
|
}
|
||||||
|
if (typeof plan.enabled === 'boolean') {
|
||||||
|
setEnabled(plan.enabled);
|
||||||
|
}
|
||||||
|
if (isNumber(plan.weeklyDay)) {
|
||||||
|
setWeeklyDay(plan.weeklyDay);
|
||||||
|
}
|
||||||
|
if (isNumber(plan.monthlyDay)) {
|
||||||
|
setMonthlyDay(plan.monthlyDay);
|
||||||
|
}
|
||||||
|
if (plan.cycle && CYCLES.some(c => c.value === plan.cycle)) {
|
||||||
|
setCycle(plan.cycle);
|
||||||
|
setFirstDate(plan.firstDate || computeFirstDate(plan.cycle, plan.weeklyDay, plan.monthlyDay));
|
||||||
|
} else {
|
||||||
|
setFirstDate(plan.firstDate || computeFirstDate('monthly', null, null));
|
||||||
|
}
|
||||||
|
}, [plan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFirstDate(computeFirstDate(cycle, weeklyDay, monthlyDay));
|
||||||
|
}, [cycle, weeklyDay, monthlyDay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cycle !== 'monthly') return;
|
||||||
|
if (monthlyDayRef.current) {
|
||||||
|
try {
|
||||||
|
monthlyDayRef.current.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [cycle, monthlyDay]);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
const rate = parseFloat(feeRate);
|
||||||
|
if (!fund?.code) return;
|
||||||
|
if (!amt || amt <= 0) return;
|
||||||
|
if (isNaN(rate) || rate < 0) return;
|
||||||
|
if (!cycle) return;
|
||||||
|
if ((cycle === 'weekly' || cycle === 'biweekly') && (weeklyDay < 1 || weeklyDay > 5)) return;
|
||||||
|
if (cycle === 'monthly' && (monthlyDay < 1 || monthlyDay > 28)) return;
|
||||||
|
|
||||||
|
onConfirm?.({
|
||||||
|
type: 'dca',
|
||||||
|
fundCode: fund.code,
|
||||||
|
fundName: fund.name,
|
||||||
|
amount: amt,
|
||||||
|
feeRate: rate,
|
||||||
|
cycle,
|
||||||
|
firstDate,
|
||||||
|
weeklyDay: cycle === 'weekly' || cycle === 'biweekly' ? weeklyDay : null,
|
||||||
|
monthlyDay: cycle === 'monthly' ? monthlyDay : null,
|
||||||
|
enabled
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = () => {
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
const rate = parseFloat(feeRate);
|
||||||
|
if (!fund?.code || !cycle || !firstDate) return false;
|
||||||
|
if (!(amt > 0) || isNaN(rate) || rate < 0) return false;
|
||||||
|
if ((cycle === 'weekly' || cycle === 'biweekly') && (weeklyDay < 1 || weeklyDay > 5)) return false;
|
||||||
|
if (cycle === 'monthly' && (monthlyDay < 1 || monthlyDay > 28)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="glass card modal dca-modal"
|
||||||
|
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 }}>
|
||||||
|
<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 style={{ marginBottom: 16 }}>
|
||||||
|
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||||
|
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 8 }}>
|
||||||
|
<label className="muted" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||||
|
<span>是否启用定投</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnabled(v => !v)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`dca-toggle-track ${enabled ? 'enabled' : ''}`}>
|
||||||
|
<span className="dca-toggle-thumb" style={{ left: enabled ? 16 : 2 }} />
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}>
|
||||||
|
{enabled ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 || parseFloat(amount) <= 0) ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
|
<NumericInput
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
step={100}
|
||||||
|
min={0}
|
||||||
|
placeholder="请输入每次定投金额"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ border: feeRate === '' ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
|
<NumericInput
|
||||||
|
value={feeRate}
|
||||||
|
onChange={setFeeRate}
|
||||||
|
step={0.01}
|
||||||
|
min={0}
|
||||||
|
placeholder="0.12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
定投周期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||||
|
{CYCLES.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setCycle(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(cycle === 'weekly' || cycle === 'biweekly') && (
|
||||||
|
<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 className="dca-option-group row" style={{ gap: 4 }}>
|
||||||
|
{WEEKDAY_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setWeeklyDay(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cycle === 'monthly' && (
|
||||||
|
<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 className="dca-monthly-day-group scrollbar-y-styled">
|
||||||
|
{Array.from({ length: 28 }).map((_, idx) => {
|
||||||
|
const day = idx + 1;
|
||||||
|
const active = monthlyDay === day;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
ref={active ? monthlyDayRef : null}
|
||||||
|
type="button"
|
||||||
|
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => setMonthlyDay(day)}
|
||||||
|
>
|
||||||
|
{day}日
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
|
||||||
|
首次扣款日期
|
||||||
|
</label>
|
||||||
|
<div className="dca-first-date-display">
|
||||||
|
{firstDate}
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||||
|
* 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button secondary dca-cancel-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={!isValid()}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
||||||
|
>
|
||||||
|
保存定投
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
33
app/components/EmptyStateCard.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function EmptyStateCard({
|
||||||
|
fundsLength = 0,
|
||||||
|
currentTab = 'all',
|
||||||
|
onAddToGroup,
|
||||||
|
}) {
|
||||||
|
const isEmpty = fundsLength === 0;
|
||||||
|
const isGroupTab = currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="glass card empty"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
|
||||||
|
<div className="muted" style={{ marginBottom: 20 }}>
|
||||||
|
{isEmpty ? '尚未添加基金' : '该分组下暂无数据'}
|
||||||
|
</div>
|
||||||
|
{isGroupTab && fundsLength > 0 && (
|
||||||
|
<button className="button" onClick={onAddToGroup}>
|
||||||
|
添加基金到此分组
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/components/FitText.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据容器宽度动态缩小字体,使内容不溢出。
|
||||||
|
* 使用 ResizeObserver 监听容器宽度,内容超出时按比例缩小 fontSize,不低于 minFontSize。
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {React.ReactNode} props.children - 要显示的文本(会单行、不换行)
|
||||||
|
* @param {number} [props.maxFontSize=14] - 最大字号(px)
|
||||||
|
* @param {number} [props.minFontSize=10] - 最小字号(px),再窄也不低于此值
|
||||||
|
* @param {string} [props.className] - 外层容器 className
|
||||||
|
* @param {Object} [props.style] - 外层容器 style(宽度由父级决定,建议父级有明确宽度)
|
||||||
|
* @param {string} [props.as='span'] - 外层容器标签 'span' | 'div'
|
||||||
|
*/
|
||||||
|
export default function FitText({
|
||||||
|
children,
|
||||||
|
maxFontSize = 14,
|
||||||
|
minFontSize = 10,
|
||||||
|
className,
|
||||||
|
style = {},
|
||||||
|
as: Tag = 'span',
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
|
const adjust = () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!container || !content) return;
|
||||||
|
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
if (containerWidth <= 0) return;
|
||||||
|
|
||||||
|
// 先恢复到最大字号再测量,确保在「未缩放」状态下取到真实内容宽度
|
||||||
|
content.style.fontSize = `${maxFontSize}px`;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
const contentWidth = content.scrollWidth;
|
||||||
|
if (contentWidth <= 0) return;
|
||||||
|
let size = maxFontSize;
|
||||||
|
if (contentWidth > containerWidth) {
|
||||||
|
size = (containerWidth / contentWidth) * maxFontSize;
|
||||||
|
size = Math.max(minFontSize, Math.min(maxFontSize, size));
|
||||||
|
}
|
||||||
|
content.style.fontSize = `${size}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(run);
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
adjust();
|
||||||
|
const ro = new ResizeObserver(adjust);
|
||||||
|
ro.observe(container);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [children, maxFontSize, minFontSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
ref={containerRef}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref={contentRef}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontWeight: 'inherit',
|
||||||
|
fontSize: `${maxFontSize}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
490
app/components/FundCard.jsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import { isNumber, isString } from 'lodash';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Stat } from './Common';
|
||||||
|
import FundTrendChart from './FundTrendChart';
|
||||||
|
import FundIntradayChart from './FundIntradayChart';
|
||||||
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
ExitIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
StarIcon,
|
||||||
|
SwitchIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from './Icons';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
|
||||||
|
const DEFAULT_TZ = 'Asia/Shanghai';
|
||||||
|
const getBrowserTimeZone = () => {
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return tz || DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
return DEFAULT_TZ;
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
currentTab,
|
||||||
|
favorites,
|
||||||
|
dcaPlans,
|
||||||
|
holdings,
|
||||||
|
percentModes,
|
||||||
|
valuationSeries,
|
||||||
|
collapsedCodes,
|
||||||
|
collapsedTrends,
|
||||||
|
transactions,
|
||||||
|
theme,
|
||||||
|
isTradingDay,
|
||||||
|
refreshing,
|
||||||
|
getHoldingProfit,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFund,
|
||||||
|
onHoldingClick,
|
||||||
|
onActionClick,
|
||||||
|
onPercentModeToggle,
|
||||||
|
onToggleCollapse,
|
||||||
|
onToggleTrendCollapse,
|
||||||
|
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||||
|
masked = false,
|
||||||
|
}) {
|
||||||
|
const holding = holdings[f?.code];
|
||||||
|
const profit = getHoldingProfit?.(f, holding) ?? null;
|
||||||
|
const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0;
|
||||||
|
|
||||||
|
const style = layoutMode === 'drawer' ? {
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
background: theme === 'light' ? 'rgb(250,250,250)' : 'none',
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="glass card"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ marginBottom: 10 }}>
|
||||||
|
<div className="title">
|
||||||
|
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||||
|
<button
|
||||||
|
className="icon-button fav-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveFromGroup?.(f.code);
|
||||||
|
}}
|
||||||
|
style={{backgroundColor: 'transparent'}}
|
||||||
|
title="从当前分组移除"
|
||||||
|
>
|
||||||
|
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`icon-button fav-button ${favorites?.has(f.code) ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite?.(f.code);
|
||||||
|
}}
|
||||||
|
title={favorites?.has(f.code) ? '取消自选' : '添加自选'}
|
||||||
|
>
|
||||||
|
<StarIcon width="18" height="18" filled={favorites?.has(f.code)} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="title-text">
|
||||||
|
<span
|
||||||
|
className="name-text"
|
||||||
|
title={f.jzrq === todayStr ? '今日净值已更新' : ''}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="muted">
|
||||||
|
#{f.code}
|
||||||
|
{dcaPlans?.[f.code]?.enabled === true && <span className="dca-indicator">定</span>}
|
||||||
|
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<div className="badge-v">
|
||||||
|
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
||||||
|
<strong>
|
||||||
|
{f.noValuation
|
||||||
|
? formatDisplayDate(f.jzrq)
|
||||||
|
: formatDisplayDate(f.gztime || f.time)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="icon-button danger"
|
||||||
|
onClick={() => !refreshing && onRemoveFund?.(f)}
|
||||||
|
title="删除"
|
||||||
|
disabled={refreshing}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
opacity: refreshing ? 0.6 : 1,
|
||||||
|
cursor: refreshing ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<Stat label="单位净值" value={f.dwjz ?? '—'} />
|
||||||
|
{f.noValuation ? (
|
||||||
|
<Stat
|
||||||
|
label="涨跌幅"
|
||||||
|
value={
|
||||||
|
f.zzl !== undefined && f.zzl !== null
|
||||||
|
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
delta={f.zzl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const hasTodayData = f.jzrq === todayStr;
|
||||||
|
let isYesterdayChange = false;
|
||||||
|
let isPreviousTradingDay = false;
|
||||||
|
if (!hasTodayData && isString(f.jzrq)) {
|
||||||
|
const today = toTz(todayStr).startOf('day');
|
||||||
|
const jzDate = toTz(f.jzrq).startOf('day');
|
||||||
|
const yesterday = today.clone().subtract(1, 'day');
|
||||||
|
if (jzDate.isSame(yesterday, 'day')) {
|
||||||
|
isYesterdayChange = true;
|
||||||
|
} else if (jzDate.isBefore(yesterday, 'day')) {
|
||||||
|
isPreviousTradingDay = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shouldHideChange =
|
||||||
|
isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
|
||||||
|
|
||||||
|
if (shouldHideChange) return null;
|
||||||
|
|
||||||
|
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅';
|
||||||
|
return (
|
||||||
|
<Stat
|
||||||
|
label={changeLabel}
|
||||||
|
value={
|
||||||
|
f.zzl !== undefined
|
||||||
|
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
delta={f.zzl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Stat
|
||||||
|
label="估值净值"
|
||||||
|
value={
|
||||||
|
f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="估值涨幅"
|
||||||
|
value={
|
||||||
|
f.estPricedCoverage > 0.05
|
||||||
|
? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%`
|
||||||
|
: isNumber(f.gszzl)
|
||||||
|
? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%`
|
||||||
|
: f.gszzl ?? '—'
|
||||||
|
}
|
||||||
|
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
{!profit ? (
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
style={{ flexDirection: 'column', gap: 4 }}
|
||||||
|
>
|
||||||
|
<span className="label">持仓金额</span>
|
||||||
|
<div
|
||||||
|
className="value muted"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => onHoldingClick?.(f)}
|
||||||
|
>
|
||||||
|
未设置 <SettingsIcon width="12" height="12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
||||||
|
onClick={() => onActionClick?.(f)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
|
||||||
|
</span>
|
||||||
|
<span className="value">
|
||||||
|
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span className="label">当日收益</span>
|
||||||
|
<span
|
||||||
|
className={`value ${
|
||||||
|
profit.profitToday != null
|
||||||
|
? profit.profitToday > 0
|
||||||
|
? 'up'
|
||||||
|
: profit.profitToday < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
: 'muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{profit.profitToday != null
|
||||||
|
? masked
|
||||||
|
? '******'
|
||||||
|
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{profit.profitTotal !== null && (
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPercentModeToggle?.(f.code);
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}
|
||||||
|
title="点击切换金额/百分比"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
持有收益{percentModes?.[f.code] ? '(%)' : ''}
|
||||||
|
<SwitchIcon />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`value ${
|
||||||
|
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{masked
|
||||||
|
? '******'
|
||||||
|
: <>
|
||||||
|
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
|
||||||
|
{percentModes?.[f.code]
|
||||||
|
? `${Math.abs(
|
||||||
|
holding?.cost * holding?.share
|
||||||
|
? (profit.profitTotal / (holding.cost * holding.share)) * 100
|
||||||
|
: 0,
|
||||||
|
).toFixed(2)}%`
|
||||||
|
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
|
||||||
|
</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{f.estPricedCoverage > 0.05 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
marginTop: -8,
|
||||||
|
marginBottom: 10,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const showIntraday =
|
||||||
|
Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2;
|
||||||
|
if (!showIntraday) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.gztime &&
|
||||||
|
toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.jzrq &&
|
||||||
|
f.gztime &&
|
||||||
|
toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FundIntradayChart
|
||||||
|
key={`${f.code}-intraday-${theme}`}
|
||||||
|
series={valuationSeries[f.code]}
|
||||||
|
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{layoutMode === 'drawer' ? (
|
||||||
|
<Tabs defaultValue={hasHoldings ? 'holdings' : 'trend'} className="w-full">
|
||||||
|
<TabsList className={`w-full ${hasHoldings ? 'grid grid-cols-2' : ''}`}>
|
||||||
|
{hasHoldings && (
|
||||||
|
<TabsTrigger value="holdings">前10重仓股票</TabsTrigger>
|
||||||
|
)}
|
||||||
|
<TabsTrigger value="trend">业绩走势</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
{hasHoldings && (
|
||||||
|
<TabsContent value="holdings" className="mt-3 outline-none">
|
||||||
|
<div className="list">
|
||||||
|
{f.holdings.map((h, idx) => (
|
||||||
|
<div className="item" key={idx}>
|
||||||
|
<span className="name">{h.name}</span>
|
||||||
|
<div className="values">
|
||||||
|
{isNumber(h.change) && (
|
||||||
|
<span
|
||||||
|
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{h.change > 0 ? '+' : ''}
|
||||||
|
{h.change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="weight">{h.weight}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
<TabsContent value="trend" className="mt-3 outline-none">
|
||||||
|
<FundTrendChart
|
||||||
|
key={`${f.code}-${theme}`}
|
||||||
|
code={f.code}
|
||||||
|
isExpanded
|
||||||
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
|
transactions={transactions?.[f.code] || []}
|
||||||
|
theme={theme}
|
||||||
|
hideHeader
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasHoldings && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
className="title"
|
||||||
|
onClick={() => onToggleCollapse?.(f.code)}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>前10重仓股票</span>
|
||||||
|
<ChevronIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
transform: collapsedCodes?.has(f.code)
|
||||||
|
? 'rotate(-90deg)'
|
||||||
|
: 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="muted">涨跌幅 / 占比</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!collapsedCodes?.has(f.code) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="list">
|
||||||
|
{f.holdings.map((h, idx) => (
|
||||||
|
<div className="item" key={idx}>
|
||||||
|
<span className="name">{h.name}</span>
|
||||||
|
<div className="values">
|
||||||
|
{isNumber(h.change) && (
|
||||||
|
<span
|
||||||
|
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{h.change > 0 ? '+' : ''}
|
||||||
|
{h.change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="weight">{h.weight}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FundTrendChart
|
||||||
|
key={`${f.code}-${theme}`}
|
||||||
|
code={f.code}
|
||||||
|
isExpanded={!collapsedTrends?.has(f.code)}
|
||||||
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
|
transactions={transactions?.[f.code] || []}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
290
app/components/FundIntradayChart.jsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
const CHART_COLORS = {
|
||||||
|
dark: {
|
||||||
|
danger: '#f87171',
|
||||||
|
success: '#34d399',
|
||||||
|
primary: '#22d3ee',
|
||||||
|
muted: '#9ca3af',
|
||||||
|
border: '#1f2937',
|
||||||
|
text: '#e5e7eb',
|
||||||
|
crosshairText: '#0f172a',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
danger: '#dc2626',
|
||||||
|
success: '#059669',
|
||||||
|
primary: '#0891b2',
|
||||||
|
muted: '#475569',
|
||||||
|
border: '#e2e8f0',
|
||||||
|
text: '#0f172a',
|
||||||
|
crosshairText: '#ffffff',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getChartThemeColors(theme) {
|
||||||
|
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
|
||||||
|
* series: Array<{ time: string, value: number, date?: string }>
|
||||||
|
* referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
|
||||||
|
* theme: 'light' | 'dark',用于亮色主题下坐标轴与 crosshair 样式
|
||||||
|
*/
|
||||||
|
export default function FundIntradayChart({ series = [], referenceNav, theme = 'dark' }) {
|
||||||
|
const chartRef = useRef(null);
|
||||||
|
const hoverTimeoutRef = useRef(null);
|
||||||
|
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!series.length) return { labels: [], datasets: [] };
|
||||||
|
const labels = series.map((d) => d.time);
|
||||||
|
const values = series.map((d) => d.value);
|
||||||
|
const ref = referenceNav != null && Number.isFinite(Number(referenceNav))
|
||||||
|
? Number(referenceNav)
|
||||||
|
: values[0];
|
||||||
|
const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0));
|
||||||
|
const lastPct = percentages[percentages.length - 1];
|
||||||
|
const riseColor = chartColors.danger;
|
||||||
|
const fallColor = chartColors.success;
|
||||||
|
const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: '涨跌幅',
|
||||||
|
data: percentages,
|
||||||
|
borderColor: lineColor,
|
||||||
|
backgroundColor: (ctx) => {
|
||||||
|
if (!ctx.chart.ctx) return lineColor + '33';
|
||||||
|
const gradient = ctx.chart.ctx.createLinearGradient(0, 0, 0, 120);
|
||||||
|
gradient.addColorStop(0, lineColor + '33');
|
||||||
|
gradient.addColorStop(1, lineColor + '00');
|
||||||
|
return gradient;
|
||||||
|
},
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: series.length <= 2 ? 3 : 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}, [series, referenceNav, chartColors.danger, chartColors.success]);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const colors = getChartThemeColors(theme);
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
external: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: colors.muted,
|
||||||
|
font: { size: 10 },
|
||||||
|
maxTicksLimit: 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
grid: { color: colors.border, drawBorder: false },
|
||||||
|
ticks: {
|
||||||
|
color: colors.muted,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHover: (event, chartElement, chart) => {
|
||||||
|
const target = event?.native?.target;
|
||||||
|
const currentChart = chart || chartRef.current;
|
||||||
|
if (!currentChart) return;
|
||||||
|
|
||||||
|
const tooltipActive = currentChart.tooltip?._active ?? [];
|
||||||
|
const activeElements = currentChart.getActiveElements
|
||||||
|
? currentChart.getActiveElements()
|
||||||
|
: [];
|
||||||
|
const hasActive =
|
||||||
|
(chartElement && chartElement.length > 0) ||
|
||||||
|
(tooltipActive && tooltipActive.length > 0) ||
|
||||||
|
(activeElements && activeElements.length > 0);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
hoverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActive) {
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
const c = chartRef.current || currentChart;
|
||||||
|
if (!c) return;
|
||||||
|
c.setActiveElements([]);
|
||||||
|
if (c.tooltip) {
|
||||||
|
c.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
c.update();
|
||||||
|
if (target) {
|
||||||
|
target.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const plugins = useMemo(() => {
|
||||||
|
const colors = getChartThemeColors(theme);
|
||||||
|
return [{
|
||||||
|
id: 'crosshair',
|
||||||
|
afterDraw: (chart) => {
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
const activeElements = chart.tooltip?._active?.length
|
||||||
|
? chart.tooltip._active
|
||||||
|
: chart.getActiveElements();
|
||||||
|
if (!activeElements?.length) return;
|
||||||
|
|
||||||
|
const activePoint = activeElements[0];
|
||||||
|
const x = activePoint.element.x;
|
||||||
|
const y = activePoint.element.y;
|
||||||
|
const topY = chart.scales.y.top;
|
||||||
|
const bottomY = chart.scales.y.bottom;
|
||||||
|
const leftX = chart.scales.x.left;
|
||||||
|
const rightX = chart.scales.x.right;
|
||||||
|
const index = activePoint.index;
|
||||||
|
const labels = chart.data.labels;
|
||||||
|
const data = chart.data.datasets[0]?.data;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = colors.muted;
|
||||||
|
ctx.moveTo(x, topY);
|
||||||
|
ctx.lineTo(x, bottomY);
|
||||||
|
ctx.moveTo(leftX, y);
|
||||||
|
ctx.lineTo(rightX, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const prim = colors.primary;
|
||||||
|
const textCol = colors.crosshairText;
|
||||||
|
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
if (labels && index in labels) {
|
||||||
|
const timeStr = String(labels[index]);
|
||||||
|
const tw = ctx.measureText(timeStr).width + 8;
|
||||||
|
const chartLeft = chart.scales.x.left;
|
||||||
|
const chartRight = chart.scales.x.right;
|
||||||
|
let labelLeft = x - tw / 2;
|
||||||
|
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||||
|
if (labelLeft + tw > chartRight) labelLeft = chartRight - tw;
|
||||||
|
const labelCenterX = labelLeft + tw / 2;
|
||||||
|
ctx.fillStyle = prim;
|
||||||
|
ctx.fillRect(labelLeft, bottomY, tw, 16);
|
||||||
|
ctx.fillStyle = textCol;
|
||||||
|
ctx.fillText(timeStr, labelCenterX, bottomY + 8);
|
||||||
|
}
|
||||||
|
if (data && index in data) {
|
||||||
|
const val = data[index];
|
||||||
|
const valueStr = isNumber(val) ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : String(val);
|
||||||
|
const vw = ctx.measureText(valueStr).width + 8;
|
||||||
|
ctx.fillStyle = prim;
|
||||||
|
ctx.fillRect(leftX, y - 8, vw, 16);
|
||||||
|
ctx.fillStyle = textCol;
|
||||||
|
ctx.fillText(valueStr, leftX + vw / 2, y);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
if (series.length < 2) return null;
|
||||||
|
|
||||||
|
const displayDate = series[0]?.date || series[series.length - 1]?.date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12, marginBottom: 4 }}>
|
||||||
|
<div className="muted" style={{ fontSize: 11, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
实时估值分时(按刷新记录)
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
...(theme === 'light'
|
||||||
|
? {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: chartColors.primary,
|
||||||
|
color: chartColors.primary,
|
||||||
|
background: 'transparent',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: 'var(--primary)',
|
||||||
|
color: '#0f172a',
|
||||||
|
}),
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
title="正在测试中的功能"
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative', height: 100, width: '100%', touchAction: 'pan-y' }}>
|
||||||
|
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Filler
|
Filler
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import {cachedRequest} from "../lib/cacheRequest";
|
import { cachedRequest } from '../lib/cacheRequest';
|
||||||
|
import FundHistoryNetValue from './FundHistoryNetValue';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -29,17 +30,45 @@ ChartJS.register(
|
|||||||
Filler
|
Filler
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) {
|
const CHART_COLORS = {
|
||||||
|
dark: {
|
||||||
|
danger: '#f87171',
|
||||||
|
success: '#34d399',
|
||||||
|
primary: '#22d3ee',
|
||||||
|
muted: '#9ca3af',
|
||||||
|
border: '#1f2937',
|
||||||
|
text: '#e5e7eb',
|
||||||
|
crosshairText: '#0f172a',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
danger: '#dc2626',
|
||||||
|
success: '#059669',
|
||||||
|
primary: '#0891b2',
|
||||||
|
muted: '#475569',
|
||||||
|
border: '#e2e8f0',
|
||||||
|
text: '#0f172a',
|
||||||
|
crosshairText: '#ffffff',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getChartThemeColors(theme) {
|
||||||
|
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||||
const [range, setRange] = useState('1m');
|
const [range, setRange] = useState('1m');
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
|
const hoverTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If collapsed, don't fetch data unless we have no data yet
|
// If collapsed, don't fetch data unless we have no data yet
|
||||||
if (!isExpanded && data.length > 0) return;
|
if (!isExpanded && data.length > 0) return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -73,7 +102,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
{ label: '近3月', value: '3m' },
|
{ label: '近3月', value: '3m' },
|
||||||
{ label: '近6月', value: '6m' },
|
{ label: '近6月', value: '6m' },
|
||||||
{ label: '近1年', value: '1y' },
|
{ label: '近1年', value: '1y' },
|
||||||
{ label: '近3年', value: '3y'}
|
{ label: '近3年', value: '3y' },
|
||||||
|
{ label: '成立来', value: 'all' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const change = useMemo(() => {
|
const change = useMemo(() => {
|
||||||
@@ -83,12 +113,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
return ((last - first) / first) * 100;
|
return ((last - first) / first) * 100;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Red for up, Green for down (CN market style)
|
// Red for up, Green for down (CN market style),随主题使用 CSS 变量
|
||||||
// Hardcoded hex values from globals.css for Chart.js
|
const upColor = chartColors.danger;
|
||||||
const upColor = '#f87171'; // --danger
|
const downColor = chartColors.success;
|
||||||
const downColor = '#34d399'; // --success
|
|
||||||
const lineColor = change >= 0 ? upColor : downColor;
|
const lineColor = change >= 0 ? upColor : downColor;
|
||||||
|
const primaryColor = chartColors.primary;
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
// Calculate percentage change based on the first data point
|
// Calculate percentage change based on the first data point
|
||||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
const firstValue = data.length > 0 ? data[0].value : 1;
|
||||||
@@ -139,8 +169,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||||
label: '买入',
|
label: '买入',
|
||||||
data: buyPoints,
|
data: buyPoints,
|
||||||
borderColor: '#ef4444', // Red
|
borderColor: '#ffffff',
|
||||||
backgroundColor: '#ef4444',
|
borderWidth: 1,
|
||||||
|
backgroundColor: primaryColor,
|
||||||
pointStyle: 'circle',
|
pointStyle: 'circle',
|
||||||
pointRadius: 2.5,
|
pointRadius: 2.5,
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4,
|
||||||
@@ -151,8 +182,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
label: '卖出',
|
label: '卖出',
|
||||||
data: sellPoints,
|
data: sellPoints,
|
||||||
borderColor: '#22c55e', // Green
|
borderColor: '#ffffff',
|
||||||
backgroundColor: '#22c55e',
|
borderWidth: 1,
|
||||||
|
backgroundColor: upColor,
|
||||||
pointStyle: 'circle',
|
pointStyle: 'circle',
|
||||||
pointRadius: 2.5,
|
pointRadius: 2.5,
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4,
|
||||||
@@ -161,9 +193,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}, [data, lineColor, transactions]);
|
}, [data, transactions, lineColor, primaryColor, upColor]);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
|
const colors = getChartThemeColors(theme);
|
||||||
return {
|
return {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -186,7 +219,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
drawBorder: false
|
drawBorder: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#9ca3af',
|
color: colors.muted,
|
||||||
font: { size: 10 },
|
font: { size: 10 },
|
||||||
maxTicksLimit: 4,
|
maxTicksLimit: 4,
|
||||||
maxRotation: 0
|
maxRotation: 0
|
||||||
@@ -195,14 +228,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'right',
|
position: 'left',
|
||||||
grid: {
|
grid: {
|
||||||
color: '#1f2937',
|
color: colors.border,
|
||||||
drawBorder: false,
|
drawBorder: false,
|
||||||
tickLength: 0
|
tickLength: 0
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#9ca3af',
|
color: colors.muted,
|
||||||
font: { size: 10 },
|
font: { size: 10 },
|
||||||
count: 5,
|
count: 5,
|
||||||
callback: (value) => `${value.toFixed(2)}%`
|
callback: (value) => `${value.toFixed(2)}%`
|
||||||
@@ -214,69 +247,126 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false,
|
intersect: false,
|
||||||
},
|
},
|
||||||
onHover: (event, chartElement) => {
|
onHover: (event, chartElement, chart) => {
|
||||||
event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default';
|
const target = event?.native?.target;
|
||||||
|
const currentChart = chart || chartRef.current;
|
||||||
|
if (!currentChart) return;
|
||||||
|
|
||||||
|
const tooltipActive = currentChart.tooltip?._active ?? [];
|
||||||
|
const activeElements = currentChart.getActiveElements
|
||||||
|
? currentChart.getActiveElements()
|
||||||
|
: [];
|
||||||
|
const hasActive =
|
||||||
|
(chartElement && chartElement.length > 0) ||
|
||||||
|
(tooltipActive && tooltipActive.length > 0) ||
|
||||||
|
(activeElements && activeElements.length > 0);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
|
||||||
|
},
|
||||||
|
onClick: () => {}
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const plugins = useMemo(() => [{
|
const plugins = useMemo(() => {
|
||||||
|
const colors = getChartThemeColors(theme);
|
||||||
|
return [{
|
||||||
id: 'crosshair',
|
id: 'crosshair',
|
||||||
|
afterEvent: (chart, args) => {
|
||||||
|
const { event, replay } = args || {};
|
||||||
|
if (!event || replay) return; // 忽略动画重放
|
||||||
|
|
||||||
|
const type = event.type;
|
||||||
|
if (type === 'mousemove' || type === 'click') {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
hoverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (!chart) return;
|
||||||
|
chart.setActiveElements([]);
|
||||||
|
if (chart.tooltip) {
|
||||||
|
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
chart.update();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
afterDraw: (chart) => {
|
afterDraw: (chart) => {
|
||||||
const ctx = chart.ctx;
|
const ctx = chart.ctx;
|
||||||
const datasets = chart.data.datasets;
|
const datasets = chart.data.datasets;
|
||||||
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
|
const primaryColor = colors.primary;
|
||||||
|
|
||||||
// Helper function to draw point label
|
// 绘制圆角矩形(兼容无 roundRect 的环境)
|
||||||
|
const drawRoundRect = (left, top, w, h, r) => {
|
||||||
|
const rad = Math.min(r, w / 2, h / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(left + rad, top);
|
||||||
|
ctx.lineTo(left + w - rad, top);
|
||||||
|
ctx.quadraticCurveTo(left + w, top, left + w, top + rad);
|
||||||
|
ctx.lineTo(left + w, top + h - rad);
|
||||||
|
ctx.quadraticCurveTo(left + w, top + h, left + w - rad, top + h);
|
||||||
|
ctx.lineTo(left + rad, top + h);
|
||||||
|
ctx.quadraticCurveTo(left, top + h, left, top + h - rad);
|
||||||
|
ctx.lineTo(left, top + rad);
|
||||||
|
ctx.quadraticCurveTo(left, top, left + rad, top);
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
|
||||||
const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
|
const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
|
||||||
const meta = chart.getDatasetMeta(datasetIndex);
|
const meta = chart.getDatasetMeta(datasetIndex);
|
||||||
if (!meta.data[index]) return;
|
if (!meta.data[index]) return;
|
||||||
const element = meta.data[index];
|
const element = meta.data[index];
|
||||||
// Check if element is visible/not skipped
|
|
||||||
if (element.skip) return;
|
if (element.skip) return;
|
||||||
|
|
||||||
const x = element.x;
|
const x = element.x;
|
||||||
const y = element.y + yOffset;
|
const y = element.y + yOffset;
|
||||||
|
const paddingH = 10;
|
||||||
|
const paddingV = 6;
|
||||||
|
const radius = 8;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.font = 'bold 11px sans-serif';
|
ctx.font = 'bold 11px sans-serif';
|
||||||
const labelWidth = ctx.measureText(text).width + 12;
|
const textW = ctx.measureText(text).width;
|
||||||
|
const w = textW + paddingH * 2;
|
||||||
// Draw label above the point
|
const h = 18;
|
||||||
ctx.globalAlpha = 0.8;
|
|
||||||
|
// 计算原始 left,并对左右边界做收缩,避免在最右/最左侧被裁剪
|
||||||
|
const chartLeft = chart.scales.x.left;
|
||||||
|
const chartRight = chart.scales.x.right;
|
||||||
|
let left = x - w / 2;
|
||||||
|
if (left < chartLeft) left = chartLeft;
|
||||||
|
if (left + w > chartRight) left = chartRight - w;
|
||||||
|
const centerX = left + w / 2;
|
||||||
|
|
||||||
|
const top = y - 24;
|
||||||
|
|
||||||
|
drawRoundRect(left, top, w, h, radius);
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
ctx.fillStyle = bgColor;
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(x - labelWidth/2, y - 24, labelWidth, 18);
|
ctx.fill();
|
||||||
|
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 0.7;
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(text, x, y - 15);
|
ctx.fillText(text, centerX, top + h / 2);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Draw default labels for first buy and sell points
|
// Resolve active elements (hover/focus) first — used to decide whether to show default labels
|
||||||
// Index 1 is Buy, Index 2 is Sell
|
|
||||||
if (datasets[1] && datasets[1].data) {
|
|
||||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
|
||||||
if (firstBuyIndex !== -1) {
|
|
||||||
// Check collision with Sell
|
|
||||||
let sellIndex = -1;
|
|
||||||
if (datasets[2] && datasets[2].data) {
|
|
||||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
|
||||||
}
|
|
||||||
const isCollision = (firstBuyIndex === sellIndex);
|
|
||||||
drawPointLabel(1, firstBuyIndex, '买入', '#ef4444', '#ffffff', isCollision ? -20 : 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (datasets[2] && datasets[2].data) {
|
|
||||||
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
|
||||||
if (firstSellIndex !== -1) {
|
|
||||||
drawPointLabel(2, firstSellIndex, '卖出', primaryColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Handle active elements (hover crosshair)
|
|
||||||
let activeElements = [];
|
let activeElements = [];
|
||||||
if (chart.tooltip?._active?.length) {
|
if (chart.tooltip?._active?.length) {
|
||||||
activeElements = chart.tooltip._active;
|
activeElements = chart.tooltip._active;
|
||||||
@@ -284,6 +374,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
activeElements = chart.getActiveElements();
|
activeElements = chart.getActiveElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if (firstBuyIndex !== -1) {
|
||||||
|
let sellIndex = -1;
|
||||||
|
if (datasets[2] && datasets[2].data) {
|
||||||
|
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||||
|
}
|
||||||
|
const isCollision = (firstBuyIndex === sellIndex);
|
||||||
|
drawPointLabel(1, 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 (firstSellIndex !== -1) {
|
||||||
|
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle active elements (hover crosshair)
|
||||||
if (activeElements && activeElements.length) {
|
if (activeElements && activeElements.length) {
|
||||||
const activePoint = activeElements[0];
|
const activePoint = activeElements[0];
|
||||||
const x = activePoint.element.x;
|
const x = activePoint.element.x;
|
||||||
@@ -297,16 +408,16 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.setLineDash([3, 3]);
|
ctx.setLineDash([3, 3]);
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeStyle = '#9ca3af';
|
ctx.strokeStyle = colors.muted;
|
||||||
|
|
||||||
// Draw vertical line
|
// Draw vertical line
|
||||||
ctx.moveTo(x, topY);
|
ctx.moveTo(x, topY);
|
||||||
ctx.lineTo(x, bottomY);
|
ctx.lineTo(x, bottomY);
|
||||||
|
|
||||||
// Draw horizontal line (based on first point - usually the main line)
|
// Draw horizontal line (based on first point - usually the main line)
|
||||||
ctx.moveTo(leftX, y);
|
ctx.moveTo(leftX, y);
|
||||||
ctx.lineTo(rightX, y);
|
ctx.lineTo(rightX, y);
|
||||||
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw labels
|
// Draw labels
|
||||||
@@ -317,7 +428,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
// Draw Axis Labels based on the first point (main line)
|
// Draw Axis Labels based on the first point (main line)
|
||||||
const datasetIndex = activePoint.datasetIndex;
|
const datasetIndex = activePoint.datasetIndex;
|
||||||
const index = activePoint.index;
|
const index = activePoint.index;
|
||||||
|
|
||||||
const labels = chart.data.labels;
|
const labels = chart.data.labels;
|
||||||
|
|
||||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
||||||
@@ -325,21 +436,27 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
const value = datasets[datasetIndex].data[index];
|
const value = datasets[datasetIndex].data[index];
|
||||||
|
|
||||||
if (dateStr !== undefined && value !== undefined) {
|
if (dateStr !== undefined && value !== undefined) {
|
||||||
// X axis label (date)
|
// X axis label (date) with boundary clamping
|
||||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||||
|
const chartLeft = chart.scales.x.left;
|
||||||
|
const chartRight = chart.scales.x.right;
|
||||||
|
let labelLeft = x - textWidth / 2;
|
||||||
|
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||||
|
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||||
|
const labelCenterX = labelLeft + textWidth / 2;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16);
|
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||||
ctx.fillStyle = '#0f172a'; // --background
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.fillText(dateStr, x, bottomY + 8);
|
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||||
|
|
||||||
// Y axis label (value)
|
// Y axis label (value)
|
||||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(rightX - valWidth, y - 8, valWidth, 16);
|
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||||
ctx.fillStyle = '#0f172a'; // --background
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(valueStr, rightX - valWidth / 2, y);
|
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,15 +472,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
if (dsIndex > 0 && datasets[dsIndex]) {
|
||||||
const label = datasets[dsIndex].label;
|
const label = datasets[dsIndex].label;
|
||||||
// Determine background color based on dataset index
|
// Determine background color based on dataset index
|
||||||
// 1 = Buy (Red), 2 = Sell (Theme Color)
|
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||||
const bgColor = dsIndex === 1 ? '#ef4444' : primaryColor;
|
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||||
|
|
||||||
// If collision, offset Buy label upwards
|
// If collision, offset Buy label upwards
|
||||||
let yOffset = 0;
|
let yOffset = 0;
|
||||||
if (isCollision && dsIndex === 1) {
|
if (isCollision && dsIndex === 1) {
|
||||||
yOffset = -20;
|
yOffset = -20;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -371,97 +488,107 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
|
}];
|
||||||
|
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
const chartBlock = (
|
||||||
<div
|
<>
|
||||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||||
className="title"
|
{loading && (
|
||||||
onClick={onToggleExpand}
|
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||||
>
|
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span>业绩走势</span>
|
|
||||||
<ChevronIcon
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
className="muted"
|
|
||||||
style={{
|
|
||||||
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{data.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
|
||||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
|
||||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isExpanded && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
|
||||||
{loading && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
|
|
||||||
}}>
|
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && data.length === 0 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(255,255,255,0.02)', zIndex: 10
|
|
||||||
}}>
|
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.length > 0 && (
|
|
||||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
|
|
||||||
{ranges.map(r => (
|
|
||||||
<button
|
|
||||||
key={r.value}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '6px 0',
|
|
||||||
fontSize: '11px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: 'none',
|
|
||||||
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
|
|
||||||
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
fontWeight: range === r.value ? 600 : 400
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
{!loading && data.length === 0 && (
|
||||||
|
<div className="chart-overlay">
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.length > 0 && (
|
||||||
|
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="trend-range-bar">
|
||||||
|
{ranges.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FundHistoryNetValue code={code} range={range} theme={theme} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: hideHeader ? 0 : 16 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!hideHeader && (
|
||||||
|
<div
|
||||||
|
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
className="title"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>业绩走势</span>
|
||||||
|
<ChevronIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||||
|
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hideHeader && data.length > 0 && (
|
||||||
|
<div className="row" style={{ marginBottom: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||||
|
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hideHeader ? (
|
||||||
|
chartBlock
|
||||||
|
) : (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{chartBlock}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
|
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
|
||||||
|
|
||||||
@@ -56,129 +57,124 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
|||||||
const isAllValid = items.every(it => it.name.trim() !== '');
|
const isAllValid = items.every(it => it.name.trim() !== '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<>
|
||||||
className="modal-overlay"
|
<Dialog
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onOpenChange={(open) => {
|
||||||
aria-label="管理分组"
|
if (!open) onClose();
|
||||||
onClick={onClose}
|
}}
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="glass card modal"
|
|
||||||
style={{ maxWidth: '500px', width: '90vw' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<DialogContent
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
className="glass card modal"
|
||||||
<SettingsIcon width="20" height="20" />
|
overlayClassName="modal-overlay"
|
||||||
<span>管理分组</span>
|
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||||
</div>
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
>
|
||||||
<CloseIcon width="20" height="20" />
|
<DialogTitle asChild>
|
||||||
</button>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<SettingsIcon width="20" height="20" />
|
||||||
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
<span>管理分组</span>
|
||||||
{items.length === 0 ? (
|
</div>
|
||||||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
|
||||||
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
|
|
||||||
<p>暂无自定义分组</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</DialogTitle>
|
||||||
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
|
|
||||||
<AnimatePresence mode="popLayout">
|
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||||||
{items.map((item) => (
|
{items.length === 0 ? (
|
||||||
<Reorder.Item
|
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
key={item.id}
|
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
|
||||||
value={item}
|
<p>暂无自定义分组</p>
|
||||||
className="group-manage-item glass"
|
</div>
|
||||||
layout
|
) : (
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<AnimatePresence mode="popLayout">
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
{items.map((item) => (
|
||||||
transition={{
|
<Reorder.Item
|
||||||
type: 'spring',
|
key={item.id}
|
||||||
stiffness: 500,
|
value={item}
|
||||||
damping: 35,
|
className="group-manage-item glass"
|
||||||
mass: 1,
|
layout
|
||||||
layout: { duration: 0.2 }
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
}}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
>
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
|
transition={{
|
||||||
<DragIcon width="18" height="18" className="muted" />
|
type: 'spring',
|
||||||
</div>
|
stiffness: 500,
|
||||||
<input
|
damping: 35,
|
||||||
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
|
mass: 1,
|
||||||
value={item.name}
|
layout: { duration: 0.2 }
|
||||||
onChange={(e) => handleRename(item.id, e.target.value)}
|
|
||||||
placeholder="请输入分组名称..."
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: '36px',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
|
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={() => handleDeleteClick(item.id, item.name)}
|
|
||||||
title="删除分组"
|
|
||||||
style={{ width: '36px', height: '36px', flexShrink: 0 }}
|
|
||||||
>
|
>
|
||||||
<TrashIcon width="16" height="16" />
|
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
|
||||||
</button>
|
<DragIcon width="18" height="18" className="muted" />
|
||||||
</Reorder.Item>
|
</div>
|
||||||
))}
|
<input
|
||||||
</AnimatePresence>
|
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
|
||||||
</Reorder.Group>
|
value={item.name}
|
||||||
)}
|
onChange={(e) => handleRename(item.id, e.target.value)}
|
||||||
<button
|
placeholder="请输入分组名称..."
|
||||||
className="add-group-row-btn"
|
style={{
|
||||||
onClick={handleAddRow}
|
flex: 1,
|
||||||
style={{
|
height: '36px',
|
||||||
width: '100%',
|
background: 'rgba(0,0,0,0.2)',
|
||||||
marginTop: 12,
|
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
|
||||||
padding: '10px',
|
}}
|
||||||
borderRadius: '12px',
|
/>
|
||||||
border: '1px dashed var(--border)',
|
<button
|
||||||
background: 'rgba(255,255,255,0.02)',
|
className="icon-button danger"
|
||||||
color: 'var(--muted)',
|
onClick={() => handleDeleteClick(item.id, item.name)}
|
||||||
fontSize: '14px',
|
title="删除分组"
|
||||||
display: 'flex',
|
style={{ width: '36px', height: '36px', flexShrink: 0 }}
|
||||||
alignItems: 'center',
|
>
|
||||||
justifyContent: 'center',
|
<TrashIcon width="16" height="16" />
|
||||||
gap: '8px',
|
</button>
|
||||||
cursor: 'pointer',
|
</Reorder.Item>
|
||||||
transition: 'all 0.2s ease'
|
))}
|
||||||
}}
|
</AnimatePresence>
|
||||||
>
|
</Reorder.Group>
|
||||||
<PlusIcon width="16" height="16" />
|
)}
|
||||||
<span>新增分组</span>
|
<button
|
||||||
</button>
|
className="add-group-row-btn"
|
||||||
</div>
|
onClick={handleAddRow}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon width="16" height="16" />
|
||||||
|
<span>新增分组</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
{!isAllValid && (
|
{!isAllValid && (
|
||||||
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
|
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
|
||||||
所有分组名称均不能为空
|
所有分组名称均不能为空
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!isAllValid}
|
disabled={!isAllValid}
|
||||||
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
|
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
|
||||||
>
|
>
|
||||||
完成
|
完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
@@ -190,6 +186,6 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,81 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { Dialog, DialogContent, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
||||||
import { CloseIcon, PlusIcon } from './Icons';
|
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 }) {
|
export default function GroupModal({ onClose, onConfirm }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog
|
||||||
className="modal-overlay"
|
open
|
||||||
role="dialog"
|
onOpenChange={(open) => {
|
||||||
aria-modal="true"
|
if (!open) onClose?.();
|
||||||
aria-label="新增分组"
|
}}
|
||||||
onClick={onClose}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<DialogContent
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
overlayClassName="modal-overlay z-[9999]"
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="glass card modal"
|
|
||||||
style={{ maxWidth: '400px' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="glass card modal !max-w-[280px] !w-full">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div className="flex items-center justify-between mb-5">
|
||||||
<PlusIcon width="20" height="20" />
|
<div className="flex items-center gap-2.5">
|
||||||
<span>新增分组</span>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field className="mb-5">
|
||||||
|
<FieldLabel htmlFor="group-modal-name" className="text-sm text-[var(--muted-foreground)] mb-2 block">
|
||||||
|
分组名称(最多 8 个字)
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<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 || '';
|
||||||
|
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>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
|
||||||
<CloseIcon width="20" height="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginBottom: 20 }}>
|
</DialogContent>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称(最多 8 个字)</label>
|
</Dialog>
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
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());
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
370
app/components/GroupSummary.jsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons';
|
||||||
|
|
||||||
|
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
||||||
|
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
|
const previousValue = useRef(value);
|
||||||
|
const isFirstChange = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousValue.current === value) return;
|
||||||
|
|
||||||
|
if (isFirstChange.current) {
|
||||||
|
isFirstChange.current = false;
|
||||||
|
previousValue.current = value;
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = previousValue.current;
|
||||||
|
const end = value;
|
||||||
|
const duration = 400;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (currentTime) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const ease = 1 - Math.pow(1 - progress, 4);
|
||||||
|
const current = start + (end - start) * ease;
|
||||||
|
setDisplayValue(current);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
previousValue.current = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{prefix}
|
||||||
|
{Math.abs(displayValue).toFixed(decimals)}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupSummary({
|
||||||
|
funds,
|
||||||
|
holdings,
|
||||||
|
groupName,
|
||||||
|
getProfit,
|
||||||
|
stickyTop,
|
||||||
|
masked,
|
||||||
|
onToggleMasked,
|
||||||
|
}) {
|
||||||
|
const [showPercent, setShowPercent] = useState(true);
|
||||||
|
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||||
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
|
const rowRef = useRef(null);
|
||||||
|
const [assetSize, setAssetSize] = useState(24);
|
||||||
|
const [metricSize, setMetricSize] = useState(18);
|
||||||
|
const [winW, setWinW] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setWinW(window.innerWidth);
|
||||||
|
const onR = () => setWinW(window.innerWidth);
|
||||||
|
window.addEventListener('resize', onR);
|
||||||
|
return () => window.removeEventListener('resize', onR);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 根据窗口宽度设置基础字号,保证小屏数字不会撑破布局
|
||||||
|
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;
|
||||||
|
let totalHoldingReturn = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
let hasHolding = false;
|
||||||
|
let hasAnyTodayData = false;
|
||||||
|
|
||||||
|
funds.forEach((fund) => {
|
||||||
|
const holding = holdings[fund.code];
|
||||||
|
const profit = getProfit(fund, holding);
|
||||||
|
|
||||||
|
if (profit) {
|
||||||
|
hasHolding = true;
|
||||||
|
totalAsset += profit.amount;
|
||||||
|
if (profit.profitToday != null) {
|
||||||
|
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||||
|
hasAnyTodayData = true;
|
||||||
|
}
|
||||||
|
if (profit.profitTotal !== null) {
|
||||||
|
totalHoldingReturn += profit.profitTotal;
|
||||||
|
if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') {
|
||||||
|
totalCost += holding.cost * holding.share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAsset,
|
||||||
|
totalProfitToday,
|
||||||
|
totalHoldingReturn,
|
||||||
|
hasHolding,
|
||||||
|
returnRate,
|
||||||
|
hasAnyTodayData,
|
||||||
|
};
|
||||||
|
}, [funds, holdings, getProfit]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = rowRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const height = el.clientHeight;
|
||||||
|
const tooTall = height > 80;
|
||||||
|
if (tooTall) {
|
||||||
|
setAssetSize((s) => Math.max(16, s - 1));
|
||||||
|
setMetricSize((s) => Math.max(12, s - 1));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
winW,
|
||||||
|
summary.totalAsset,
|
||||||
|
summary.totalProfitToday,
|
||||||
|
summary.totalHoldingReturn,
|
||||||
|
summary.returnRate,
|
||||||
|
showPercent,
|
||||||
|
assetSize,
|
||||||
|
metricSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summary.hasHolding) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isSticky ? 'group-summary-sticky' : ''}
|
||||||
|
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="glass card group-summary-card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="sticky-toggle-btn"
|
||||||
|
onClick={() => setIsSticky(!isSticky)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
padding: 4,
|
||||||
|
opacity: 0.6,
|
||||||
|
zIndex: 10,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSticky ? (
|
||||||
|
<PinIcon width="14" height="14" />
|
||||||
|
) : (
|
||||||
|
<PinOffIcon width="14" height="14" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
className="row"
|
||||||
|
style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
<div className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="fav-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (onToggleMasked) {
|
||||||
|
onToggleMasked();
|
||||||
|
} else {
|
||||||
|
setIsMasked((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 2,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<EyeOffIcon width="16" height="16" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon width="16" height="16" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||||
|
{isMasked ? (
|
||||||
|
<span
|
||||||
|
className="mask-text"
|
||||||
|
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||||
|
>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: '12px', marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
当日收益
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
summary.hasAnyTodayData
|
||||||
|
? summary.totalProfitToday > 0
|
||||||
|
? 'up'
|
||||||
|
: summary.totalProfitToday < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
: 'muted'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
|
) : summary.hasAnyTodayData ? (
|
||||||
|
<>
|
||||||
|
<span style={{ marginRight: 1 }}>
|
||||||
|
{summary.totalProfitToday > 0
|
||||||
|
? '+'
|
||||||
|
: summary.totalProfitToday < 0
|
||||||
|
? '-'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.totalProfitToday)}
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: metricSize }}>--</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: 4,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
持有收益{showPercent ? '(%)' : ''}{' '}
|
||||||
|
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
summary.totalHoldingReturn > 0
|
||||||
|
? 'up'
|
||||||
|
: summary.totalHoldingReturn < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowPercent(!showPercent)}
|
||||||
|
title="点击切换金额/百分比"
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ marginRight: 1 }}>
|
||||||
|
{summary.totalHoldingReturn > 0
|
||||||
|
? '+'
|
||||||
|
: summary.totalHoldingReturn < 0
|
||||||
|
? '-'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
{showPercent ? (
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.returnRate)}
|
||||||
|
suffix="%"
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.totalHoldingReturn)}
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { CloseIcon, SettingsIcon } from './Icons';
|
import { CloseIcon, SettingsIcon } from './Icons';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
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 }}
|
|
||||||
className="glass card modal"
|
className="glass card modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
overlayClassName="modal-overlay"
|
||||||
style={{ maxWidth: '320px' }}
|
style={{ maxWidth: '320px', zIndex: 99 }}
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">持仓操作</DialogTitle>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
<span>持仓操作</span>
|
<span>持仓操作</span>
|
||||||
{hasHistory && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="button secondary"
|
||||||
onClick={() => onAction('history')}
|
onClick={() => onAction('history')}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
padding: '4px 8px',
|
padding: '4px 10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
background: 'rgba(255,255,255,0.1)',
|
height: '28px',
|
||||||
border: 'none',
|
display: 'flex',
|
||||||
borderRadius: '4px',
|
alignItems: 'center',
|
||||||
color: 'var(--text)',
|
gap: 4,
|
||||||
cursor: 'pointer',
|
}}
|
||||||
display: 'flex',
|
title="查看交易记录"
|
||||||
alignItems: 'center',
|
>
|
||||||
gap: 4
|
<span>📜</span>
|
||||||
}}
|
<span>交易记录</span>
|
||||||
title="查看交易记录"
|
</button>
|
||||||
>
|
|
||||||
<span>📜</span>
|
|
||||||
<span>记录</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
@@ -62,12 +57,27 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid" style={{ gap: 12 }}>
|
<div className="grid" style={{ gap: 12 }}>
|
||||||
<button className="button col-6" onClick={() => onAction('buy')} style={{ background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)' }}>
|
<button
|
||||||
|
className="button col-4"
|
||||||
|
onClick={() => onAction('buy')}
|
||||||
|
style={{ background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)', fontSize: 14 }}
|
||||||
|
>
|
||||||
加仓
|
加仓
|
||||||
</button>
|
</button>
|
||||||
<button className="button col-6" onClick={() => onAction('sell')} style={{ background: 'rgba(248, 113, 113, 0.1)', border: '1px solid var(--danger)', color: 'var(--danger)' }}>
|
<button
|
||||||
|
className="button col-4"
|
||||||
|
onClick={() => onAction('sell')}
|
||||||
|
style={{ background: 'rgba(248, 113, 113, 0.1)', border: '1px solid var(--danger)', color: 'var(--danger)', fontSize: 14 }}
|
||||||
|
>
|
||||||
减仓
|
减仓
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="button col-4 dca-btn"
|
||||||
|
onClick={() => onAction('dca')}
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
定投
|
||||||
|
</button>
|
||||||
<button className="button col-12" onClick={() => onAction('edit')} style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>
|
<button className="button col-12" onClick={() => onAction('edit')} style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>
|
||||||
编辑持仓
|
编辑持仓
|
||||||
</button>
|
</button>
|
||||||
@@ -79,13 +89,13 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
background: 'linear-gradient(180deg, #ef4444, #f87171)',
|
background: 'linear-gradient(180deg, #ef4444, #f87171)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#2b0b0b',
|
color: '#2b0b0b',
|
||||||
fontWeight: 600
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
清空持仓
|
清空持仓
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { CloseIcon, SettingsIcon } from './Icons';
|
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 }) {
|
||||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||||
|
|
||||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||||
|
const dwjzRef = useRef(dwjz);
|
||||||
|
useEffect(() => {
|
||||||
|
dwjzRef.current = dwjz;
|
||||||
|
}, [dwjz]);
|
||||||
|
|
||||||
const [share, setShare] = useState('');
|
const [share, setShare] = useState('');
|
||||||
const [cost, setCost] = useState('');
|
const [cost, setCost] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [profit, setProfit] = useState('');
|
const [profit, setProfit] = useState('');
|
||||||
|
|
||||||
|
const holdingSig = useMemo(() => {
|
||||||
|
if (!holding) return '';
|
||||||
|
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
|
||||||
|
}, [holding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (holding) {
|
if (holding) {
|
||||||
const s = holding.share || 0;
|
const s = holding.share || 0;
|
||||||
@@ -21,14 +34,17 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
setShare(String(s));
|
setShare(String(s));
|
||||||
setCost(String(c));
|
setCost(String(c));
|
||||||
|
|
||||||
if (dwjz > 0) {
|
const price = dwjzRef.current;
|
||||||
const a = s * dwjz;
|
if (price > 0) {
|
||||||
const p = (dwjz - c) * s;
|
const a = s * price;
|
||||||
|
const p = (price - c) * s;
|
||||||
setAmount(a.toFixed(2));
|
setAmount(a.toFixed(2));
|
||||||
setProfit(p.toFixed(2));
|
setProfit(p.toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [holding, fund, dwjz]);
|
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [holdingSig]);
|
||||||
|
|
||||||
const handleModeChange = (newMode) => {
|
const handleModeChange = (newMode) => {
|
||||||
if (newMode === mode) return;
|
if (newMode === mode) return;
|
||||||
@@ -89,25 +105,21 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
? (share && cost && !isNaN(share) && !isNaN(cost))
|
? (share && cost && !isNaN(share) && !isNaN(cost))
|
||||||
: (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
|
: (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
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 }}
|
|
||||||
className="glass card modal"
|
className="glass card modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
overlayClassName="modal-overlay"
|
||||||
style={{ maxWidth: '400px' }}
|
style={{ maxWidth: '400px', zIndex: 999, width: '90vw' }}
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">编辑持仓</DialogTitle>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
@@ -158,6 +170,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!amount ? 'error' : ''}`}
|
className={`input ${!amount ? 'error' : ''}`}
|
||||||
value={amount}
|
value={amount}
|
||||||
@@ -192,6 +205,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!share ? 'error' : ''}`}
|
className={`input ${!share ? 'error' : ''}`}
|
||||||
value={share}
|
value={share}
|
||||||
@@ -209,6 +223,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
step="any"
|
step="any"
|
||||||
className={`input ${!cost ? 'error' : ''}`}
|
className={`input ${!cost ? 'error' : ''}`}
|
||||||
value={cost}
|
value={cost}
|
||||||
@@ -235,7 +250,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ export function RefreshIcon(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResetIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg t="1772152323013" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4796" width="16" height="16"><path fill="currentColor" d="M864 512a352 352 0 0 0-600.96-248.96c-15.744 15.872-40.704 42.88-63.232 67.648H320a32 32 0 1 1 0 64H128a31.872 31.872 0 0 1-32-32v-192a32 32 0 1 1 64 0v108.672c20.544-22.528 42.688-46.4 57.856-61.504a416 416 0 1 1 0 588.288 32 32 0 1 1 45.248-45.248A352 352 0 0 0 864 512z" p-id="4797"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChevronIcon(props) {
|
export function ChevronIcon(props) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -236,3 +243,37 @@ export function CameraIcon(props) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SunIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoonIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchIcon({ props }) {
|
||||||
|
return (
|
||||||
|
<svg t="1772945896369" className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="2524" width="13" height="13">
|
||||||
|
<path
|
||||||
|
d="M885.247 477.597H132c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.247c17.673 0 32 14.327 32 32s-14.327 32-32 32z"
|
||||||
|
fill="currentColor" p-id="2525"></path>
|
||||||
|
<path
|
||||||
|
d="M893.366 477.392c-8.189 0-16.379-3.124-22.627-9.373L709.954 307.235c-12.497-12.497-12.497-32.758 0-45.255 12.496-12.497 32.758-12.497 45.254 0l160.785 160.785c12.497 12.497 12.497 32.758 0 45.255-6.248 6.248-14.437 9.372-22.627 9.372zM893.366 609.607H140.119c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.248c17.673 0 32 14.327 32 32s-14.328 32-32.001 32z"
|
||||||
|
fill="currentColor" p-id="2526"></path>
|
||||||
|
<path
|
||||||
|
d="M292.784 770.597c-8.189 0-16.379-3.124-22.627-9.373L109.373 600.439c-12.497-12.496-12.497-32.758 0-45.254 12.497-12.498 32.758-12.498 45.255 0L315.412 715.97c12.497 12.496 12.497 32.758 0 45.254-6.249 6.249-14.438 9.373-22.628 9.373z"
|
||||||
|
fill="currentColor" p-id="2527"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
import { MailIcon } from './Icons';
|
import { MailIcon } from './Icons';
|
||||||
|
|
||||||
export default function LoginModal({
|
export default function LoginModal({
|
||||||
@@ -56,15 +57,21 @@ export default function LoginModal({
|
|||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
|
||||||
请输入邮箱验证码以完成注册/登录
|
请输入邮箱验证码以完成注册/登录
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InputOTP
|
||||||
className="input"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入验证码"
|
|
||||||
value={loginOtp}
|
|
||||||
onChange={(e) => setLoginOtp(e.target.value)}
|
|
||||||
disabled={loginLoading}
|
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
value={loginOtp}
|
||||||
|
onChange={(value) => setLoginOtp(value)}
|
||||||
|
disabled={loginLoading}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginError && (
|
{loginError && (
|
||||||
|
|||||||
485
app/components/MarketIndexAccordion.jsx
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
'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,
|
||||||
|
}) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchMarketIndices()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setIndices([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化选中指数(本地偏好 > 默认集合)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!indices.length || typeof window === 'undefined') 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);
|
||||||
|
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, 0.9);
|
||||||
|
}
|
||||||
|
.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
@@ -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
@@ -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-[77vh] max-h-[88vh] 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1115
app/components/MobileFundTable.jsx
Normal file
229
app/components/MobileSettingModal.jsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件)
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - 是否打开
|
||||||
|
* @param {() => void} props.onClose - 关闭回调
|
||||||
|
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||||
|
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||||
|
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调
|
||||||
|
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||||
|
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
||||||
|
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||||
|
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||||
|
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||||
|
*/
|
||||||
|
export default function MobileSettingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
columns = [],
|
||||||
|
columnVisibility,
|
||||||
|
onColumnReorder,
|
||||||
|
onToggleColumnVisibility,
|
||||||
|
onResetColumnOrder,
|
||||||
|
onResetColumnVisibility,
|
||||||
|
showFullFundName,
|
||||||
|
onToggleShowFullFundName,
|
||||||
|
}) {
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setResetConfirmOpen(false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleReorder = (newItems) => {
|
||||||
|
const newOrder = newItems.map((item) => item.id);
|
||||||
|
onColumnReorder?.(newOrder);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) onClose();
|
||||||
|
}}
|
||||||
|
direction="bottom"
|
||||||
|
handleOnly={isReordering}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
className="glass"
|
||||||
|
defaultHeight="77vh"
|
||||||
|
minHeight="40vh"
|
||||||
|
maxHeight="90vh"
|
||||||
|
>
|
||||||
|
<DrawerHeader className="mobile-setting-header flex-row items-center justify-between gap-2 py-5 pt-5 text-base font-semibold">
|
||||||
|
<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="mobile-setting-body flex flex-1 flex-col overflow-y-auto">
|
||||||
|
{onToggleShowFullFundName && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||||
|
<Switch
|
||||||
|
checked={!!showFullFundName}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onToggleShowFullFundName?.(!!checked);
|
||||||
|
}}
|
||||||
|
title={showFullFundName ? '关闭' : '开启'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||||
|
拖拽调整列顺序
|
||||||
|
</p>
|
||||||
|
{(onResetColumnOrder || onResetColumnVisibility) && (
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetConfirmOpen(true)}
|
||||||
|
title="重置表头设置"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResetIcon width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||||
|
暂无可配置列
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={columns}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="mobile-setting-list"
|
||||||
|
layoutScroll
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{columns.map((item, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={item.id || `col-${index}`}
|
||||||
|
value={item}
|
||||||
|
className="mobile-setting-item glass"
|
||||||
|
layout
|
||||||
|
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,
|
||||||
|
damping: 35,
|
||||||
|
mass: 1,
|
||||||
|
layout: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<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, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span>{item.header}</span>
|
||||||
|
{item.id === 'totalChangePercent' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
估值涨幅与持有收益的汇总
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onToggleColumnVisibility && (
|
||||||
|
<Switch
|
||||||
|
checked={columnVisibility?.[item.id] !== false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onToggleColumnVisibility(item.id, !!checked);
|
||||||
|
}}
|
||||||
|
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
key="mobile-reset-confirm"
|
||||||
|
title="重置表头设置"
|
||||||
|
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetColumnOrder?.();
|
||||||
|
onResetColumnVisibility?.();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1278
app/components/PcFundTable.jsx
Normal file
293
app/components/PcTableSettingModal.jsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 表格个性化设置侧弹框
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - 是否打开
|
||||||
|
* @param {() => void} props.onClose - 关闭回调
|
||||||
|
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||||
|
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||||
|
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调,参数为新的列 id 顺序
|
||||||
|
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||||
|
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
|
||||||
|
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||||
|
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
|
||||||
|
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||||
|
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||||
|
*/
|
||||||
|
export default function PcTableSettingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
columns = [],
|
||||||
|
columnVisibility,
|
||||||
|
onColumnReorder,
|
||||||
|
onToggleColumnVisibility,
|
||||||
|
onResetColumnOrder,
|
||||||
|
onResetColumnVisibility,
|
||||||
|
onResetSizing,
|
||||||
|
showFullFundName,
|
||||||
|
onToggleShowFullFundName,
|
||||||
|
}) {
|
||||||
|
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setResetOrderConfirmOpen(false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleReorder = (newItems) => {
|
||||||
|
const newOrder = newItems.map((item) => item.id);
|
||||||
|
onColumnReorder?.(newOrder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="drawer"
|
||||||
|
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-table-setting-drawer glass"
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="pc-table-setting-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<SettingsIcon width="20" height="20" />
|
||||||
|
<span>个性化设置</span>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{onToggleShowFullFundName && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button pc-table-column-switch"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleShowFullFundName(!showFullFundName);
|
||||||
|
}}
|
||||||
|
title={showFullFundName ? '关闭' : '开启'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '0 4px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`dca-toggle-track ${showFullFundName ? 'enabled' : ''}`}>
|
||||||
|
<span
|
||||||
|
className="dca-toggle-thumb"
|
||||||
|
style={{ left: showFullFundName ? 16 : 2 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="pc-table-setting-subtitle">表头设置</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||||
|
拖拽调整列顺序
|
||||||
|
</p>
|
||||||
|
{onResetColumnOrder && (
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetOrderConfirmOpen(true)}
|
||||||
|
title="重置列顺序"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResetIcon width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||||
|
暂无可配置列
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={columns}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="pc-table-setting-list"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{columns.map((item, index) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={item.id || `col-${index}`}
|
||||||
|
value={item}
|
||||||
|
className="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 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span>{item.header}</span>
|
||||||
|
{item.id === 'totalChangePercent' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
估值涨幅与持有收益的汇总
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onToggleColumnVisibility && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button pc-table-column-switch"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
|
||||||
|
}}
|
||||||
|
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '0 4px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
|
||||||
|
<span
|
||||||
|
className="dca-toggle-thumb"
|
||||||
|
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
{onResetSizing && (
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onResetSizing();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 20,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResetIcon width="16" height="16" />
|
||||||
|
重置列宽
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{resetOrderConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
key="reset-order-confirm"
|
||||||
|
title="重置表头设置"
|
||||||
|
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetColumnOrder?.();
|
||||||
|
onResetColumnVisibility?.();
|
||||||
|
setResetOrderConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetOrderConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
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
@@ -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;
|
||||||
|
}
|
||||||
40
app/components/RefreshButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RefreshIcon } from './Icons';
|
||||||
|
|
||||||
|
export default function RefreshButton({ refreshCycleStartRef, refreshMs, manualRefresh, refreshing, fundsLength }) {
|
||||||
|
|
||||||
|
// 刷新周期进度 0~1,用于环形进度条
|
||||||
|
const [refreshProgress, setRefreshProgress] = useState(0);
|
||||||
|
|
||||||
|
// 刷新进度条:每 100ms 更新一次进度
|
||||||
|
useEffect(() => {
|
||||||
|
if (fundsLength === 0 || refreshMs <= 0) return;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - refreshCycleStartRef.current;
|
||||||
|
const p = Math.min(1, elapsed / refreshMs);
|
||||||
|
setRefreshProgress(p);
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [fundsLength, refreshMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="refresh-btn-wrap"
|
||||||
|
style={{ '--progress': refreshProgress }}
|
||||||
|
title={`刷新周期 ${Math.round(refreshMs / 1000)} 秒`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="立即刷新"
|
||||||
|
onClick={manualRefresh}
|
||||||
|
disabled={refreshing || fundsLength === 0}
|
||||||
|
aria-busy={refreshing}
|
||||||
|
title="立即刷新"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
export default function ScanImportConfirmModal({
|
export default function ScanImportConfirmModal({
|
||||||
scannedFunds,
|
scannedFunds,
|
||||||
@@ -9,8 +18,24 @@ export default function ScanImportConfirmModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onToggle,
|
onToggle,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
refreshing
|
refreshing,
|
||||||
|
groups = [],
|
||||||
|
isOcrScan = false
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState('all');
|
||||||
|
const [expandAfterAdd, setExpandAfterAdd] = useState(true);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(selectedGroupId, expandAfterAdd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (val) => {
|
||||||
|
if (!val) return null;
|
||||||
|
const num = parseFloat(String(val).replace(/,/g, ''));
|
||||||
|
if (isNaN(num)) return null;
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -28,7 +53,7 @@ export default function ScanImportConfirmModal({
|
|||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal"
|
className="glass card modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ width: 460, maxWidth: '90vw' }}
|
style={{ width: 480, maxWidth: '90vw' }}
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
|
||||||
<span>确认导入基金</span>
|
<span>确认导入基金</span>
|
||||||
@@ -36,50 +61,100 @@ export default function ScanImportConfirmModal({
|
|||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{isOcrScan && (
|
||||||
|
<div className="ocr-warning" style={{ marginBottom: 12 }}>
|
||||||
|
<span>拍照识别方案目前还在优化,请确认识别结果是否正确。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{scannedFunds.length === 0 ? (
|
{scannedFunds.length === 0 ? (
|
||||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
|
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||||
未识别到有效的基金代码,请尝试更清晰的截图或手动搜索。
|
未识别到有效的基金代码,请尝试更清晰的截图或手动搜索。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="search-results pending-list" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
<>
|
||||||
{scannedFunds.map((item) => {
|
<div className="search-results pending-list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||||
const isSelected = selectedScannedCodes.has(item.code);
|
{scannedFunds.map((item) => {
|
||||||
const isAlreadyAdded = item.status === 'added';
|
const isSelected = selectedScannedCodes.has(item.code);
|
||||||
const isInvalid = item.status === 'invalid';
|
const isAlreadyAdded = item.status === 'added';
|
||||||
const isDisabled = isAlreadyAdded || isInvalid;
|
const isInvalid = item.status === 'invalid';
|
||||||
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
|
const isDisabled = isAlreadyAdded || isInvalid;
|
||||||
return (
|
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
|
||||||
<div
|
const holdAmounts = formatAmount(item.holdAmounts);
|
||||||
key={item.code}
|
const holdGains = formatAmount(item.holdGains);
|
||||||
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
|
const hasHoldingData = holdAmounts !== null && holdGains !== null;
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
return (
|
||||||
onClick={() => {
|
<div
|
||||||
if (isDisabled) return;
|
key={item.code}
|
||||||
onToggle(item.code);
|
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
|
||||||
}}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
onClick={() => {
|
||||||
>
|
if (isDisabled) return;
|
||||||
<div className="fund-info">
|
onToggle(item.code);
|
||||||
<span className="fund-name">{displayName}</span>
|
}}
|
||||||
<span className="fund-code muted">#{item.code}</span>
|
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
|
||||||
</div>
|
>
|
||||||
{isAlreadyAdded ? (
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span className="added-label">已添加</span>
|
<div className="fund-info">
|
||||||
) : isInvalid ? (
|
<span className="fund-name">{displayName}</span>
|
||||||
<span className="added-label">未找到</span>
|
<span className="fund-code muted">#{item.code}</span>
|
||||||
) : (
|
</div>
|
||||||
<div className="checkbox">
|
{isAlreadyAdded ? (
|
||||||
{isSelected && <div className="checked-mark" />}
|
<span className="added-label">已添加</span>
|
||||||
|
) : isInvalid ? (
|
||||||
|
<span className="added-label">未找到</span>
|
||||||
|
) : (
|
||||||
|
<div className="checkbox">
|
||||||
|
{isSelected && <div className="checked-mark" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{hasHoldingData && !isDisabled && (
|
||||||
</div>
|
<div style={{ display: 'flex', gap: 16, marginTop: 6, paddingLeft: 0 }}>
|
||||||
);
|
{holdAmounts !== null && (
|
||||||
})}
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
</div>
|
持有金额:<span style={{ color: 'var(--text)', fontWeight: 500 }}>¥{holdAmounts.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{holdGains !== null && (
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
持有收益:<span style={{ color: holdGains >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight: 500 }}>
|
||||||
|
{holdGains >= 0 ? '+' : '-'}¥{Math.abs(holdGains).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>添加后展开详情</span>
|
||||||
|
<Switch
|
||||||
|
checked={expandAfterAdd}
|
||||||
|
onCheckedChange={(checked) => setExpandAfterAdd(!!checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
||||||
|
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="选择分组" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="fav">自选</SelectItem>
|
||||||
|
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
|
||||||
<button className="button secondary" onClick={onClose}>取消</button>
|
<button className="button secondary" onClick={onClose}>取消</button>
|
||||||
<button className="button" onClick={onConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
<button className="button" onClick={handleConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
function getDroppedImageFiles(dataTransfer) {
|
||||||
|
if (!dataTransfer?.files?.length) return [];
|
||||||
|
return Array.from(dataTransfer.files).filter((f) =>
|
||||||
|
IMAGE_TYPES.includes(f.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning }) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isScanning) setIsDragging(true);
|
||||||
|
}, [isScanning]);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (isScanning || !onFilesDrop) return;
|
||||||
|
const files = getDroppedImageFiles(e.dataTransfer);
|
||||||
|
if (files.length) onFilesDrop(files);
|
||||||
|
}, [isScanning, onFilesDrop]);
|
||||||
|
|
||||||
|
const dropZoneStyle = {
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: '20px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
transition: 'border-color 0.2s ease, background 0.2s ease',
|
||||||
|
cursor: isScanning ? 'not-allowed' : 'pointer',
|
||||||
|
pointerEvents: isScanning ? 'none' : 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -18,7 +60,7 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
|||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal"
|
className="glass card modal scan-pick-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ width: 420, maxWidth: '90vw' }}
|
style={{ width: 420, maxWidth: '90vw' }}
|
||||||
>
|
>
|
||||||
@@ -26,7 +68,28 @@ export default function ScanPickModal({ onClose, onPick, isScanning }) {
|
|||||||
<span>选择持仓截图</span>
|
<span>选择持仓截图</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
|
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
从相册选择一张或多张持仓截图,系统将自动识别其中的基金代码(6位数字),并支持批量导入。
|
从相册选择一张或多张持仓截图,系统将自动识别其中的<span style={{ color: 'var(--primary)' }}>基金代码(6位数字)</span>,并支持批量导入。
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`scan-pick-dropzone muted ${isDragging ? 'dragging' : ''}`}
|
||||||
|
style={dropZoneStyle}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={!isScanning ? onPick : undefined}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="拖拽图片到此处或点击选择"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isScanning) onPick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.5, color: isDragging ? 'var(--primary)' : 'var(--muted)', textAlign: 'center' }}>
|
||||||
|
{isDragging ? '松开即可导入' : '拖拽图片到此处,或点击选择'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button className="button secondary" onClick={onClose}>取消</button>
|
<button className="button secondary" onClick={onClose}>取消</button>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { SettingsIcon } from './Icons';
|
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';
|
||||||
|
|
||||||
export default function SettingsModal({
|
export default function SettingsModal({
|
||||||
onClose,
|
onClose,
|
||||||
@@ -10,75 +14,202 @@ export default function SettingsModal({
|
|||||||
exportLocalData,
|
exportLocalData,
|
||||||
importFileRef,
|
importFileRef,
|
||||||
handleImportFileChange,
|
handleImportFileChange,
|
||||||
importMsg
|
importMsg,
|
||||||
|
isMobile,
|
||||||
|
containerWidth = 1200,
|
||||||
|
setContainerWidth,
|
||||||
|
onResetContainerWidth,
|
||||||
}) {
|
}) {
|
||||||
|
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;
|
||||||
|
const onPointerUp = () => setSliderDragging(false);
|
||||||
|
document.addEventListener('pointerup', onPointerUp);
|
||||||
|
document.addEventListener('pointercancel', onPointerUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerup', onPointerUp);
|
||||||
|
document.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
};
|
||||||
|
}, [sliderDragging]);
|
||||||
|
|
||||||
|
// 外部的 tempSeconds 变更时,同步到本地显示,但不立即生效
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSeconds(tempSeconds);
|
||||||
|
}, [tempSeconds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}>
|
<Dialog
|
||||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
open
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
onOpenChange={(open) => {
|
||||||
<SettingsIcon width="20" height="20" />
|
if (!open) onClose?.();
|
||||||
<span>设置</span>
|
}}
|
||||||
<span className="muted">配置刷新频率</span>
|
>
|
||||||
</div>
|
<DialogContent
|
||||||
|
overlayClassName={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''} z-[9999]`}
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
className="!p-0 z-[10000]"
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
showCloseButton={false}
|
||||||
<div className="chips" style={{ marginBottom: 12 }}>
|
>
|
||||||
{[10, 30, 60, 120, 300].map((s) => (
|
<div className="glass card modal">
|
||||||
<button
|
<div className="title" style={{ marginBottom: 12 }}>
|
||||||
key={s}
|
<SettingsIcon width="20" height="20" />
|
||||||
type="button"
|
<DialogTitle asChild>
|
||||||
className={`chip ${tempSeconds === s ? 'active' : ''}`}
|
<span>设置</span>
|
||||||
onClick={() => setTempSeconds(s)}
|
</DialogTitle>
|
||||||
aria-pressed={tempSeconds === s}
|
|
||||||
>
|
|
||||||
{s} 秒
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
className="input"
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
type="number"
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||||
min="10"
|
<div className="chips" style={{ marginBottom: 12 }}>
|
||||||
step="5"
|
{[30, 60, 120, 300].map((s) => (
|
||||||
value={tempSeconds}
|
<button
|
||||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
key={s}
|
||||||
placeholder="自定义秒数"
|
type="button"
|
||||||
/>
|
className={`chip ${localSeconds === s ? 'active' : ''}`}
|
||||||
{tempSeconds < 10 && (
|
onClick={() => setLocalSeconds(s)}
|
||||||
<div className="error-text" style={{ marginTop: 8 }}>
|
aria-pressed={localSeconds === s}
|
||||||
最小 10 秒
|
>
|
||||||
|
{s} 秒
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min="30"
|
||||||
|
step="5"
|
||||||
|
value={localSeconds}
|
||||||
|
onChange={(e) => setLocalSeconds(Number(e.target.value))}
|
||||||
|
placeholder="自定义秒数"
|
||||||
|
/>
|
||||||
|
{localSeconds < 30 && (
|
||||||
|
<div className="error-text" style={{ marginTop: 8 }}>
|
||||||
|
最小 30 秒
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && setContainerWidth && (
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
|
||||||
|
{onResetContainerWidth && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetWidthConfirmOpen(true)}
|
||||||
|
title="重置页面宽度"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResetIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<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 }}>
|
||||||
|
{clampedWidth}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
|
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
|
||||||
</div>
|
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
|
|
||||||
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
|
||||||
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={importFileRef}
|
|
||||||
type="file"
|
|
||||||
accept="application/json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleImportFileChange}
|
|
||||||
/>
|
|
||||||
{importMsg && (
|
|
||||||
<div className="muted" style={{ marginTop: 8 }}>
|
|
||||||
{importMsg}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
|
||||||
</div>
|
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImportFileChange}
|
||||||
|
/>
|
||||||
|
{importMsg && (
|
||||||
|
<div className="muted" style={{ marginTop: 8 }}>
|
||||||
|
{importMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 10}>保存并关闭</button>
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={(e) => saveSettings(e, localSeconds)}
|
||||||
|
disabled={localSeconds < 30}
|
||||||
|
>
|
||||||
|
保存并关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="重置页面宽度"
|
||||||
|
message="是否重置页面宽度为默认值 1200px?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetContainerWidth();
|
||||||
|
setResetWidthConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetWidthConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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,14 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
import { fetchSmartFundNetValue } from '../api/fund';
|
import { fetchSmartFundNetValue } from '../api/fund';
|
||||||
import { DatePicker, NumericInput } from './Common';
|
import { DatePicker, NumericInput } from './Common';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import PendingTradesModal from './PendingTradesModal';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -58,7 +65,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
}
|
}
|
||||||
}, [showPendingList, currentPendingTrades]);
|
}, [showPendingList, currentPendingTrades]);
|
||||||
|
|
||||||
const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz));
|
const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (isNumber(fund?.gsz) ? fund?.gsz : Number(fund?.dwjz));
|
||||||
const [price, setPrice] = useState(getEstimatePrice());
|
const [price, setPrice] = useState(getEstimatePrice());
|
||||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||||
const [actualDate, setActualDate] = useState(null);
|
const [actualDate, setActualDate] = useState(null);
|
||||||
@@ -152,50 +159,35 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
|
|
||||||
const [revokeTrade, setRevokeTrade] = useState(null);
|
const [revokeTrade, setRevokeTrade] = useState(null);
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
aria-modal="true"
|
className="glass card modal trade-modal"
|
||||||
aria-label={isBuy ? "加仓" : "减仓"}
|
overlayClassName="modal-overlay"
|
||||||
onClick={onClose}
|
overlayStyle={{ zIndex: 99 }}
|
||||||
initial={{ opacity: 0 }}
|
style={{ maxWidth: '420px', width: '90vw', zIndex: 99 }}
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="glass card modal"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ maxWidth: '420px' }}
|
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">{isBuy ? '加仓' : '减仓'}</DialogTitle>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: '20px' }}>{isBuy ? '📥' : '📤'}</span>
|
<span style={{ fontSize: '20px' }}>{isBuy ? '📥' : '📤'}</span>
|
||||||
<span>{showPendingList ? '待交易队列' : (showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓'))}</span>
|
<span>{showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
{!showConfirm && currentPendingTrades.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="trade-pending-alert"
|
||||||
marginBottom: 16,
|
|
||||||
background: 'rgba(230, 162, 60, 0.1)',
|
|
||||||
border: '1px solid rgba(230, 162, 60, 0.2)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#e6a23c',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
onClick={() => setShowPendingList(true)}
|
onClick={() => setShowPendingList(true)}
|
||||||
>
|
>
|
||||||
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
||||||
@@ -203,55 +195,6 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPendingList ? (
|
|
||||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
|
||||||
<div className="pending-list-header" style={{ position: 'sticky', top: 0, zIndex: 1, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(6px)', paddingBottom: 8, marginBottom: 8, borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<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} style={{ background: 'rgba(255,255,255,0.05)', padding: 12, borderRadius: 8, marginBottom: 8 }}>
|
|
||||||
<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 style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
|
||||||
<button
|
|
||||||
className="button secondary"
|
|
||||||
onClick={() => setRevokeTrade(trade)}
|
|
||||||
style={{
|
|
||||||
padding: '2px 8px',
|
|
||||||
fontSize: '10px',
|
|
||||||
height: 'auto',
|
|
||||||
background: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'var(--text)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
撤销
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!showConfirm && (
|
{!showConfirm && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||||
@@ -262,7 +205,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
{showConfirm ? (
|
{showConfirm ? (
|
||||||
isBuy ? (
|
isBuy ? (
|
||||||
<div style={{ fontSize: '14px' }}>
|
<div style={{ fontSize: '14px' }}>
|
||||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
<div className="trade-confirm-card">
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span className="muted">基金名称</span>
|
<span className="muted">基金名称</span>
|
||||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||||
@@ -287,7 +230,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<span className="muted">买入日期</span>
|
<span className="muted">买入日期</span>
|
||||||
<span>{date}</span>
|
<span>{date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||||
<span className="muted">交易时段</span>
|
<span className="muted">交易时段</span>
|
||||||
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +243,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||||
@@ -309,7 +252,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{price ? (
|
{price ? (
|
||||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
|
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
|
||||||
@@ -325,18 +268,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary trade-back-btn"
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
返回修改
|
返回修改
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button"
|
className="button queue-button"
|
||||||
onClick={handleFinalConfirm}
|
onClick={handleFinalConfirm}
|
||||||
disabled={loadingPrice}
|
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 ? '确认买入' : '加入待处理队列')}
|
{loadingPrice ? '请稍候' : (price ? '确认买入' : '加入待处理队列')}
|
||||||
</button>
|
</button>
|
||||||
@@ -344,7 +287,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: '14px' }}>
|
<div style={{ fontSize: '14px' }}>
|
||||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
<div className="trade-confirm-card">
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<span className="muted">基金名称</span>
|
<span className="muted">基金名称</span>
|
||||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||||
@@ -369,7 +312,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<span className="muted">卖出日期</span>
|
<span className="muted">卖出日期</span>
|
||||||
<span>{date}</span>
|
<span>{date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||||
<span className="muted">预计回款</span>
|
<span className="muted">预计回款</span>
|
||||||
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +325,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||||
@@ -391,7 +334,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{price ? (
|
{price ? (
|
||||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||||
<div style={{ fontSize: '12px' }}>
|
<div style={{ fontSize: '12px' }}>
|
||||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
|
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
|
||||||
@@ -407,15 +350,15 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary trade-back-btn"
|
||||||
onClick={() => setShowConfirm(false)}
|
onClick={() => setShowConfirm(false)}
|
||||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
返回修改
|
返回修改
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button"
|
className="button queue-button"
|
||||||
onClick={handleFinalConfirm}
|
onClick={handleFinalConfirm}
|
||||||
disabled={loadingPrice}
|
disabled={loadingPrice}
|
||||||
style={{ flex: 1, background: 'var(--danger)', opacity: loadingPrice ? 0.6 : 1 }}
|
style={{ flex: 1, background: 'var(--danger)', opacity: loadingPrice ? 0.6 : 1 }}
|
||||||
@@ -471,36 +414,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
交易时段
|
交易时段
|
||||||
</label>
|
</label>
|
||||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(false)}
|
onClick={() => setIsAfter3pm(false)}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: 'none',
|
|
||||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
|
||||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px 8px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
15:00前
|
15:00前
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(true)}
|
onClick={() => setIsAfter3pm(true)}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: 'none',
|
|
||||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
|
||||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px 8px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
15:00后
|
15:00后
|
||||||
</button>
|
</button>
|
||||||
@@ -543,17 +468,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<button
|
<button
|
||||||
key={opt.label}
|
key={opt.label}
|
||||||
type="button"
|
type="button"
|
||||||
|
className="trade-amount-btn"
|
||||||
onClick={() => handleSetShareFraction(opt.value)}
|
onClick={() => handleSetShareFraction(opt.value)}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
background: 'rgba(255,255,255,0.1)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'var(--text)',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -562,7 +478,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
)}
|
)}
|
||||||
{holding && (
|
{holding && (
|
||||||
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
||||||
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span style={{ color: '#e6a23c', marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span className="trade-pending-status" style={{ marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -613,36 +529,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
交易时段
|
交易时段
|
||||||
</label>
|
</label>
|
||||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(false)}
|
onClick={() => setIsAfter3pm(false)}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: 'none',
|
|
||||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
|
||||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px 8px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
15:00前
|
15:00前
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
onClick={() => setIsAfter3pm(true)}
|
onClick={() => setIsAfter3pm(true)}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: 'none',
|
|
||||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
|
||||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px 8px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
15:00后
|
15:00后
|
||||||
</button>
|
</button>
|
||||||
@@ -662,7 +560,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||||
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
<button type="button" className="button secondary trade-cancel-btn" onClick={onClose} style={{ flex: 1 }}>取消</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button"
|
className="button"
|
||||||
@@ -674,9 +572,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</>
|
</DialogContent>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{revokeTrade && (
|
{revokeTrade && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -692,6 +588,12 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
<PendingTradesModal
|
||||||
|
open={showPendingList}
|
||||||
|
trades={currentPendingTrades}
|
||||||
|
onClose={() => setShowPendingList(false)}
|
||||||
|
onRevoke={(trade) => setRevokeTrade(trade)}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export default function TransactionHistoryModal({
|
export default function TransactionHistoryModal({
|
||||||
fund,
|
fund,
|
||||||
transactions = [],
|
transactions = [],
|
||||||
pendingTransactions = [],
|
pendingTransactions = [],
|
||||||
onClose,
|
onClose,
|
||||||
onDeleteTransaction,
|
onDeleteTransaction,
|
||||||
onDeletePending,
|
onDeletePending,
|
||||||
onAddHistory
|
onAddHistory,
|
||||||
}) {
|
}) {
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
|
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
|
||||||
|
|
||||||
@@ -39,31 +45,46 @@ export default function TransactionHistoryModal({
|
|||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseClick = (event) => {
|
||||||
|
// 只关闭交易记录弹框,避免事件冒泡影响到其他弹框(例如 HoldingActionModal)
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open) => {
|
||||||
|
if (!open) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
showCloseButton={false}
|
||||||
aria-modal="true"
|
className="glass card modal tx-history-modal"
|
||||||
onClick={onClose}
|
overlayClassName="modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
overlayStyle={{ zIndex: 998 }}
|
||||||
animate={{ opacity: 1 }}
|
style={{
|
||||||
exit={{ opacity: 0 }}
|
maxWidth: '480px',
|
||||||
style={{ zIndex: 1100 }} // Higher than TradeModal if stacked, but usually TradeModal closes or this opens on top
|
width: '90vw',
|
||||||
>
|
maxHeight: '80vh',
|
||||||
<motion.div
|
display: 'flex',
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
flexDirection: 'column',
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
zIndex: 999, // 保持原有层级,确保在其他弹框之上
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
}}
|
||||||
className="glass card modal"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">交易记录</DialogTitle>
|
||||||
|
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: '20px' }}>📜</span>
|
<span style={{ fontSize: '20px' }}>📜</span>
|
||||||
<span>交易记录</span>
|
<span>交易记录</span>
|
||||||
</div>
|
</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" />
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,12 +94,12 @@ export default function TransactionHistoryModal({
|
|||||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="button primary"
|
className="button primary"
|
||||||
onClick={onAddHistory}
|
onClick={onAddHistory}
|
||||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
|
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto', width: '80px' }}
|
||||||
>
|
>
|
||||||
➕ 添加记录
|
添加记录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,11 +109,18 @@ export default function TransactionHistoryModal({
|
|||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
||||||
{pendingTransactions.map((item) => (
|
{pendingTransactions.map((item) => (
|
||||||
<div key={item.id} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
<div key={item.id} className="tx-history-pending-item">
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
{item.type === 'buy' ? '买入' : '卖出'}
|
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||||
</span>
|
{item.type === 'buy' ? '买入' : '卖出'}
|
||||||
|
</span>
|
||||||
|
{item.type === 'buy' && item.isDca && (
|
||||||
|
<span className="tx-history-dca-badge">
|
||||||
|
定投
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>{item.date} {item.isAfter3pm ? '(15:00后)' : ''}</span>
|
<span className="muted" style={{ fontSize: '12px' }}>{item.date} {item.isAfter3pm ? '(15:00后)' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||||
@@ -100,14 +128,17 @@ export default function TransactionHistoryModal({
|
|||||||
<span>{item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`}</span>
|
<span>{item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||||
<button
|
<Button
|
||||||
className="button secondary"
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
onClick={() => handleDeleteClick(item, 'pending')}
|
onClick={() => handleDeleteClick(item, 'pending')}
|
||||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)' }}
|
style={{ paddingInline: 10 }}
|
||||||
>
|
>
|
||||||
撤销
|
撤销
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -121,11 +152,18 @@ export default function TransactionHistoryModal({
|
|||||||
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
||||||
) : (
|
) : (
|
||||||
sortedTransactions.map((item) => (
|
sortedTransactions.map((item) => (
|
||||||
<div key={item.id} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
<div key={item.id} className="tx-history-record-item">
|
||||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
{item.type === 'buy' ? '买入' : '卖出'}
|
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||||
</span>
|
{item.type === 'buy' ? '买入' : '卖出'}
|
||||||
|
</span>
|
||||||
|
{item.type === 'buy' && item.isDca && (
|
||||||
|
<span className="tx-history-dca-badge">
|
||||||
|
定投
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>{item.date}</span>
|
<span className="muted" style={{ fontSize: '12px' }}>{item.date}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
|
||||||
@@ -144,13 +182,16 @@ export default function TransactionHistoryModal({
|
|||||||
)}
|
)}
|
||||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||||
<span className="muted"></span>
|
<span className="muted"></span>
|
||||||
<button
|
<Button
|
||||||
className="button secondary"
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
onClick={() => handleDeleteClick(item, 'history')}
|
onClick={() => handleDeleteClick(item, 'history')}
|
||||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)', color: 'var(--muted)' }}
|
style={{ paddingInline: 10 }}
|
||||||
>
|
>
|
||||||
删除记录
|
删除记录
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -158,22 +199,21 @@ export default function TransactionHistoryModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</motion.div>
|
<AnimatePresence>
|
||||||
|
{deleteConfirm && (
|
||||||
<AnimatePresence>
|
<ConfirmModal
|
||||||
{deleteConfirm && (
|
key="delete-confirm"
|
||||||
<ConfirmModal
|
title={deleteConfirm.type === 'pending' ? '撤销交易' : '删除记录'}
|
||||||
key="delete-confirm"
|
message={deleteConfirm.type === 'pending'
|
||||||
title={deleteConfirm.type === 'pending' ? "撤销交易" : "删除记录"}
|
? '确定要撤销这笔待处理交易吗?'
|
||||||
message={deleteConfirm.type === 'pending'
|
: '确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。'}
|
||||||
? "确定要撤销这笔待处理交易吗?"
|
onConfirm={handleConfirmDelete}
|
||||||
: "确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。"}
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
onConfirm={handleConfirmDelete}
|
confirmText="确认删除"
|
||||||
onCancel={() => setDeleteConfirm(null)}
|
/>
|
||||||
confirmText="确认删除"
|
)}
|
||||||
/>
|
</AnimatePresence>
|
||||||
)}
|
</DialogContent>
|
||||||
</AnimatePresence>
|
</Dialog>
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export default function WeChatModal({ onClose }) {
|
|||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="trade-pending-alert"
|
||||||
|
>
|
||||||
|
<span>⚠️ 入群须知:禁止讨论和基金买卖以及投资的有关内容,可反馈软件相关需求和问题。</span>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={weChatGroupImg}
|
src={weChatGroupImg}
|
||||||
|
|||||||
2003
app/globals.css
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]);
|
||||||
|
}
|
||||||
204
app/hooks/useFundFuzzyMatcher.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
|
||||||
|
|
||||||
|
const FUND_CODE_SEARCH_URL = 'https://fund.eastmoney.com/js/fundcode_search.js';
|
||||||
|
const FUND_LIST_CACHE_KEY = 'eastmoney_fundcode_search_list';
|
||||||
|
const FUND_LIST_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const formatEastMoneyFundList = (rawList) => {
|
||||||
|
if (!Array.isArray(rawList)) return [];
|
||||||
|
|
||||||
|
return rawList
|
||||||
|
.map((item) => {
|
||||||
|
if (!Array.isArray(item)) return null;
|
||||||
|
const code = String(item[0] ?? '').trim();
|
||||||
|
const name = String(item[2] ?? '').trim();
|
||||||
|
if (!code || !name) return null;
|
||||||
|
return { code, name };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFundFuzzyMatcher = () => {
|
||||||
|
const allFundFuseRef = useRef(null);
|
||||||
|
const allFundLoadPromiseRef = useRef(null);
|
||||||
|
|
||||||
|
const getAllFundFuse = useCallback(async () => {
|
||||||
|
if (allFundFuseRef.current) return allFundFuseRef.current;
|
||||||
|
if (allFundLoadPromiseRef.current) return allFundLoadPromiseRef.current;
|
||||||
|
|
||||||
|
allFundLoadPromiseRef.current = (async () => {
|
||||||
|
const [fuseModule, allFundList] = await Promise.all([
|
||||||
|
import('fuse.js'),
|
||||||
|
cachedRequest(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
|
||||||
|
reject(new Error('NO_BROWSER_ENV'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevR = window.r;
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `${FUND_CODE_SEARCH_URL}?_=${Date.now()}`;
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (document.body.contains(script)) {
|
||||||
|
document.body.removeChild(script);
|
||||||
|
}
|
||||||
|
if (prevR === undefined) {
|
||||||
|
try {
|
||||||
|
delete window.r;
|
||||||
|
} catch (e) {
|
||||||
|
window.r = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.r = prevR;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
const snapshot = Array.isArray(window.r) ? JSON.parse(JSON.stringify(window.r)) : [];
|
||||||
|
cleanup();
|
||||||
|
const parsed = formatEastMoneyFundList(snapshot);
|
||||||
|
if (!parsed.length) {
|
||||||
|
reject(new Error('PARSE_ALL_FUND_FAILED'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('LOAD_ALL_FUND_FAILED'));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}),
|
||||||
|
FUND_LIST_CACHE_KEY,
|
||||||
|
{ cacheTime: FUND_LIST_CACHE_TIME }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const Fuse = fuseModule.default;
|
||||||
|
const fuse = new Fuse(Array.isArray(allFundList) ? allFundList : [], {
|
||||||
|
keys: ['name', 'code'],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.5,
|
||||||
|
ignoreLocation: true,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
allFundFuseRef.current = fuse;
|
||||||
|
return fuse;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await allFundLoadPromiseRef.current;
|
||||||
|
} catch (e) {
|
||||||
|
allFundLoadPromiseRef.current = null;
|
||||||
|
clearCachedRequest(FUND_LIST_CACHE_KEY);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizeFundText = useCallback((value) => {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[((]/g, '(')
|
||||||
|
.replace(/[))]/g, ')')
|
||||||
|
.replace(/[·•]/g, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/[^\u4e00-\u9fa5A-Z0-9()]/g, '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseFundQuerySignals = useCallback((rawName) => {
|
||||||
|
const normalized = normalizeFundText(rawName);
|
||||||
|
const hasETF = normalized.includes('ETF');
|
||||||
|
const hasLOF = normalized.includes('LOF');
|
||||||
|
const hasLink = normalized.includes('联接');
|
||||||
|
const shareMatch = normalized.match(/([A-Z])(?:类)?$/i);
|
||||||
|
const shareClass = shareMatch ? shareMatch[1].toUpperCase() : null;
|
||||||
|
|
||||||
|
const core = normalized
|
||||||
|
.replace(/基金/g, '')
|
||||||
|
.replace(/ETF联接/g, '')
|
||||||
|
.replace(/联接[A-Z]?/g, '')
|
||||||
|
.replace(/ETF/g, '')
|
||||||
|
.replace(/LOF/g, '')
|
||||||
|
.replace(/[A-Z](?:类)?$/g, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalized,
|
||||||
|
core,
|
||||||
|
hasETF,
|
||||||
|
hasLOF,
|
||||||
|
hasLink,
|
||||||
|
shareClass,
|
||||||
|
};
|
||||||
|
}, [normalizeFundText]);
|
||||||
|
|
||||||
|
const resolveFundCodeByFuzzy = useCallback(async (name) => {
|
||||||
|
const querySignals = parseFundQuerySignals(name);
|
||||||
|
if (!querySignals.normalized) return null;
|
||||||
|
|
||||||
|
const len = querySignals.normalized.length;
|
||||||
|
const strictThreshold = len <= 4 ? 0.16 : len <= 8 ? 0.22 : 0.28;
|
||||||
|
const relaxedThreshold = Math.min(0.45, strictThreshold + 0.16);
|
||||||
|
const scoreGapThreshold = len <= 5 ? 0.08 : 0.06;
|
||||||
|
|
||||||
|
const fuse = await getAllFundFuse();
|
||||||
|
const recalled = fuse.search(name, { limit: 50 });
|
||||||
|
if (!recalled.length) return null;
|
||||||
|
|
||||||
|
const stage1 = recalled.filter((item) => (item.score ?? 1) <= relaxedThreshold);
|
||||||
|
if (!stage1.length) return null;
|
||||||
|
|
||||||
|
const ranked = stage1
|
||||||
|
.map((item) => {
|
||||||
|
const candidateSignals = parseFundQuerySignals(item?.item?.name || '');
|
||||||
|
let finalScore = item.score ?? 1;
|
||||||
|
|
||||||
|
if (querySignals.hasETF) {
|
||||||
|
finalScore += candidateSignals.hasETF ? -0.04 : 0.2;
|
||||||
|
}
|
||||||
|
if (querySignals.hasLOF) {
|
||||||
|
finalScore += candidateSignals.hasLOF ? -0.04 : 0.2;
|
||||||
|
}
|
||||||
|
if (querySignals.hasLink) {
|
||||||
|
finalScore += candidateSignals.hasLink ? -0.03 : 0.18;
|
||||||
|
}
|
||||||
|
if (querySignals.shareClass) {
|
||||||
|
finalScore += candidateSignals.shareClass === querySignals.shareClass ? -0.03 : 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (querySignals.core && candidateSignals.core) {
|
||||||
|
if (candidateSignals.core.includes(querySignals.core)) {
|
||||||
|
finalScore -= 0.06;
|
||||||
|
} else if (!querySignals.core.includes(candidateSignals.core)) {
|
||||||
|
finalScore += 0.06;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...item, finalScore };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.finalScore - b.finalScore);
|
||||||
|
|
||||||
|
const top1 = ranked[0];
|
||||||
|
if (!top1 || top1.finalScore > strictThreshold) return null;
|
||||||
|
|
||||||
|
const top2 = ranked[1];
|
||||||
|
if (top2 && (top2.finalScore - top1.finalScore) < scoreGapThreshold) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return top1?.item?.code || null;
|
||||||
|
}, [getAllFundFuse, parseFundQuerySignals]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolveFundCodeByFuzzy,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFundFuzzyMatcher;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import AnalyticsGate from './components/AnalyticsGate';
|
import AnalyticsGate from './components/AnalyticsGate';
|
||||||
|
import PwaRegister from './components/PwaRegister';
|
||||||
|
import ThemeColorSync from './components/ThemeColorSync';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -11,14 +14,31 @@ export default function RootLayout({ children }) {
|
|||||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="apple-mobile-web-app-title" content="基估宝" />
|
||||||
</head>
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
<body>
|
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||||
<AnalyticsGate GA_ID={GA_ID} />
|
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
|
||||||
{children}
|
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
|
||||||
</body>
|
<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
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ThemeColorSync />
|
||||||
|
<PwaRegister />
|
||||||
|
<AnalyticsGate GA_ID={GA_ID} />
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/lib/tradingCalendar.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* A股交易日历:基于 chinese-days 节假日数据,严格判断某日期是否为交易日
|
||||||
|
* 交易日 = 周一至周五 且 不在法定节假日
|
||||||
|
* 调休补班日(周末变工作日)A股仍休市,故不视为交易日
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/chinese-days@1/dist/years';
|
||||||
|
const yearCache = new Map(); // year -> Set<dateStr> (holidays)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载某年的节假日数据
|
||||||
|
* @param {number} year
|
||||||
|
* @returns {Promise<Set<string>>} 节假日日期集合,格式 YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export async function loadHolidaysForYear(year) {
|
||||||
|
if (yearCache.has(year)) {
|
||||||
|
return yearCache.get(year);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${CDN_BASE}/${year}.json`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const holidays = new Set(Object.keys(data?.holidays ?? {}));
|
||||||
|
yearCache.set(year, holidays);
|
||||||
|
return holidays;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[tradingCalendar] 加载 ${year} 年节假日失败:`, e);
|
||||||
|
yearCache.set(year, new Set());
|
||||||
|
return yearCache.get(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载多个年份的节假日数据
|
||||||
|
* @param {number[]} years
|
||||||
|
*/
|
||||||
|
export async function loadHolidaysForYears(years) {
|
||||||
|
await Promise.all([...new Set(years)].map(loadHolidaysForYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某日期是否为 A股交易日
|
||||||
|
* @param {dayjs.Dayjs} date - dayjs 对象
|
||||||
|
* @param {Map<number, Set<string>>} [cache] - 可选,已加载的年份缓存,默认使用内部 yearCache
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isTradingDay(date, cache = yearCache) {
|
||||||
|
const dayOfWeek = date.day(); // 0=周日, 6=周六
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
|
||||||
|
|
||||||
|
const dateStr = date.format('YYYY-MM-DD');
|
||||||
|
const year = date.year();
|
||||||
|
const holidays = cache.get(year);
|
||||||
|
if (!holidays) return true; // 未加载该年数据时,仅排除周末
|
||||||
|
return !holidays.has(dateStr);
|
||||||
|
}
|
||||||
123
app/lib/valuationTimeseries.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* 记录每次调用基金估值接口的结果,用于分时图。
|
||||||
|
* 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。
|
||||||
|
*/
|
||||||
|
import { isPlainObject, isString } from 'lodash';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fundValuationTimeseries';
|
||||||
|
|
||||||
|
function getStored() {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
return isPlainObject(parsed) ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStored(data) {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('valuationTimeseries persist failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function toDateStr(gztimeOrNow) {
|
||||||
|
if (isString(gztimeOrNow) && /^\d{4}-\d{2}-\d{2}/.test(gztimeOrNow)) {
|
||||||
|
return gztimeOrNow.slice(0, 10);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一条估值。仅当 value 为有效数字时写入。
|
||||||
|
* 数据清理:若当前点所属日期大于已存点的最大日期,则清空该基金下所有旧日期的数据,只保留当日分时。
|
||||||
|
*
|
||||||
|
* @param {string} code - 基金代码
|
||||||
|
* @param {{ gsz?: number | null, gztime?: string | null }} payload - 估值与时间(来自接口)
|
||||||
|
* @returns {Array<{ time: string, value: number, date: string }>} 该基金当前分时序列(按时间升序)
|
||||||
|
*/
|
||||||
|
export function recordValuation(code, payload) {
|
||||||
|
const value = payload?.gsz != null ? Number(payload.gsz) : NaN;
|
||||||
|
if (!Number.isFinite(value)) return getValuationSeries(code);
|
||||||
|
|
||||||
|
const gztime = payload?.gztime ?? null;
|
||||||
|
const dateStr = toDateStr(gztime);
|
||||||
|
if (!dateStr) return getValuationSeries(code);
|
||||||
|
|
||||||
|
const timeLabel = isString(gztime) && gztime.length > 10
|
||||||
|
? gztime.slice(11, 16)
|
||||||
|
: (() => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const newPoint = { time: timeLabel, value, date: dateStr };
|
||||||
|
|
||||||
|
const all = getStored();
|
||||||
|
const list = Array.isArray(all[code]) ? all[code] : [];
|
||||||
|
|
||||||
|
const existingDates = list.map((p) => p.date).filter(Boolean);
|
||||||
|
const latestStoredDate = existingDates.length ? existingDates.reduce((a, b) => (a > b ? a : b), '') : '';
|
||||||
|
|
||||||
|
let nextList;
|
||||||
|
if (dateStr > latestStoredDate) {
|
||||||
|
nextList = [newPoint];
|
||||||
|
} else if (dateStr === latestStoredDate) {
|
||||||
|
const hasSameTime = list.some((p) => p.time === timeLabel);
|
||||||
|
if (hasSameTime) return list;
|
||||||
|
nextList = [...list, newPoint];
|
||||||
|
} else {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
all[code] = nextList;
|
||||||
|
setStored(all);
|
||||||
|
return nextList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取某基金的分时序列(只读)
|
||||||
|
* @param {string} code - 基金代码
|
||||||
|
* @returns {Array<{ time: string, value: number, date: string }>}
|
||||||
|
*/
|
||||||
|
export function getValuationSeries(code) {
|
||||||
|
const all = getStored();
|
||||||
|
const list = Array.isArray(all[code]) ? all[code] : [];
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除某基金的全部分时数据(如用户删除该基金时调用)
|
||||||
|
* @param {string} code - 基金代码
|
||||||
|
*/
|
||||||
|
export function clearFund(code) {
|
||||||
|
const all = getStored();
|
||||||
|
if (!(code in all)) return;
|
||||||
|
const next = { ...all };
|
||||||
|
delete next[code];
|
||||||
|
setStored(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部分时数据,用于页面初始 state
|
||||||
|
* @returns {{ [code: string]: Array<{ time: string, value: number, date: string }> }}
|
||||||
|
*/
|
||||||
|
export function getAllValuationSeries() {
|
||||||
|
return getStored();
|
||||||
|
}
|
||||||
2548
app/page.jsx
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
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
@@ -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 }
|
||||||
179
components/ui/dialog.jsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
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
|
||||||
|
}) {
|
||||||
|
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({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-[var(--dialog-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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
overlayClassName,
|
||||||
|
overlayStyle,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
"mobile-dialog-glass",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<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">
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<button type="button" className="button secondary px-4 h-11 rounded-xl cursor-pointer">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold text-[var(--foreground)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
262
components/ui/drawer.jsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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
|
||||||
|
const match = String(vhStr).match(/^([\d.]+)\s*vh$/)
|
||||||
|
if (!match) return null
|
||||||
|
return (window.innerHeight * Number(match[1])) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const ctx = React.useContext(DrawerScrollLockContext)
|
||||||
|
const { open = false, ...scrollLockProps } = ctx || {}
|
||||||
|
// modal={false} 时 vaul 不渲染/隐藏 Overlay,用自定义遮罩 div 保证始终有遮罩;点击遮罩关闭
|
||||||
|
return (
|
||||||
|
<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 cursor-default bg-[var(--drawer-overlay,rgba(0,0,0,0.45))] backdrop-blur-[6px]",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...scrollLockProps}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DrawerPrimitive.Close>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
defaultHeight = "77vh",
|
||||||
|
minHeight = "20vh",
|
||||||
|
maxHeight = "90vh",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [heightPx, setHeightPx] = React.useState(() =>
|
||||||
|
typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const dragRef = React.useRef({ startY: 0, startHeight: 0 });
|
||||||
|
|
||||||
|
const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]);
|
||||||
|
const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const px = parseVhToPx(defaultHeight);
|
||||||
|
if (px != null) setHeightPx(px);
|
||||||
|
}, [defaultHeight]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const max = parseVhToPx(maxHeight);
|
||||||
|
const min = parseVhToPx(minHeight);
|
||||||
|
setHeightPx((prev) => {
|
||||||
|
if (prev == null) return parseVhToPx(defaultHeight);
|
||||||
|
const clamped = Math.min(prev, max ?? prev);
|
||||||
|
return Math.max(clamped, min ?? clamped);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", sync);
|
||||||
|
return () => window.removeEventListener("resize", sync);
|
||||||
|
}, [defaultHeight, minHeight, maxHeight]);
|
||||||
|
|
||||||
|
const handlePointerDown = React.useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 };
|
||||||
|
},
|
||||||
|
[heightPx, defaultHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const move = (e) => {
|
||||||
|
const clientY = e.clientY ?? e.touches?.[0]?.clientY;
|
||||||
|
const { startY, startHeight } = dragRef.current;
|
||||||
|
const delta = startY - clientY;
|
||||||
|
const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta));
|
||||||
|
setHeightPx(next);
|
||||||
|
};
|
||||||
|
const up = () => setIsDragging(false);
|
||||||
|
document.addEventListener("mousemove", move, { passive: true });
|
||||||
|
document.addEventListener("mouseup", up);
|
||||||
|
document.addEventListener("touchmove", move, { passive: true });
|
||||||
|
document.addEventListener("touchend", up);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", move);
|
||||||
|
document.removeEventListener("mouseup", up);
|
||||||
|
document.removeEventListener("touchmove", move);
|
||||||
|
document.removeEventListener("touchend", up);
|
||||||
|
};
|
||||||
|
}, [isDragging, minPx, maxPx]);
|
||||||
|
|
||||||
|
const contentStyle = React.useMemo(() => {
|
||||||
|
if (heightPx == null) return undefined;
|
||||||
|
return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined };
|
||||||
|
}, [heightPx, maxPx]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
style={contentStyle}
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content fixed z-50 flex h-auto flex-col bg-[var(--card)] text-[var(--text)] border-[var(--border)]",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-[var(--radius)] data-[vaul-drawer-direction=top]:border-b drawer-shadow-top",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[88vh] data-[vaul-drawer-direction=bottom]:rounded-t-[20px] data-[vaul-drawer-direction=bottom]:border-t drawer-shadow-bottom",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
"drawer-content-theme",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-label="拖动调整高度"
|
||||||
|
onMouseDown={handlePointerDown}
|
||||||
|
onTouchStart={handlePointerDown}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-[var(--muted)] cursor-n-resize touch-none select-none",
|
||||||
|
"group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
|
||||||
|
"hover:bg-[var(--muted-foreground)/0.4] active:bg-[var(--muted-foreground)/0.6]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 border-b border-[var(--border)] group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
"drawer-header-theme",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("font-semibold text-[var(--text)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-sm text-[var(--muted)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
79
components/ui/input-otp.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||||
|
className={cn("disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-12 w-10 items-center justify-center rounded-md border-2 bg-background text-lg font-semibold shadow-sm transition-all duration-200",
|
||||||
|
"border-input/60 dark:border-input/80",
|
||||||
|
"text-foreground dark:text-foreground",
|
||||||
|
"first:rounded-l-md last:rounded-r-md",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary",
|
||||||
|
"data-[active=true]:border-primary data-[active=true]:ring-2 data-[active=true]:ring-primary/30 dark:data-[active=true]:ring-primary/40",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:text-destructive",
|
||||||
|
"dark:bg-slate-900/50 dark:data-[active=true]:bg-slate-800/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-6 w-px animate-caret-blink bg-primary duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" className="text-muted-foreground dark:text-muted-foreground/50" {...props}>
|
||||||
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
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
@@ -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 }
|
||||||
197
components/ui/select.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium whitespace-nowrap shadow-sm transition-all duration-200 outline-none",
|
||||||
|
"border-input bg-background text-foreground",
|
||||||
|
"hover:border-primary/60 hover:ring-1 hover:ring-primary/30",
|
||||||
|
"focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-input disabled:hover:ring-0",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||||
|
"data-[placeholder]:text-muted-foreground",
|
||||||
|
"data-[size=default]:h-11 data-[size=sm]:h-10",
|
||||||
|
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-60 transition-transform duration-200" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-[100] max-h-(--radix-select-content-available-height) min-w-[var(--radix-select-trigger-width)] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-xl border shadow-2xl",
|
||||||
|
"bg-popover/80 text-popover-foreground dark:bg-popover/70",
|
||||||
|
"backdrop-blur-xl backdrop-saturate-[180%]",
|
||||||
|
"border-border/60",
|
||||||
|
"ring-1 ring-black/5 dark:ring-white/10",
|
||||||
|
"shadow-black/5 dark:shadow-black/60",
|
||||||
|
"animate-in fade-in zoom-in-95 duration-200 ease-out",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150",
|
||||||
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}>
|
||||||
|
<SelectScrollUpButton className="bg-transparent text-muted-foreground/50" />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn("p-1.5", position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton className="bg-transparent text-muted-foreground/50" />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2.5 px-3 text-sm font-medium transition-colors duration-150 outline-none",
|
||||||
|
"text-foreground",
|
||||||
|
"hover:bg-primary/10 dark:hover:bg-primary/20",
|
||||||
|
"focus:bg-primary/10 dark:focus:bg-primary/20",
|
||||||
|
"data-[highlighted]:bg-primary/10 dark:data-[highlighted]:bg-primary/20",
|
||||||
|
"data-[state=checked]:bg-primary/10 dark:data-[state=checked]:bg-primary/20",
|
||||||
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-3 flex size-4 items-center justify-center text-primary">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/60", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
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
@@ -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 }
|
||||||
42
components/ui/switch.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef(({ className, size = "default", ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch inline-flex shrink-0 cursor-pointer items-center rounded-full border shadow-xs outline-none",
|
||||||
|
"border-[var(--border)]",
|
||||||
|
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||||
|
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"hover:data-[state=unchecked]:bg-[var(--input)] hover:data-[state=unchecked]:border-[var(--muted)]",
|
||||||
|
"data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||||
|
"data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--primary)]",
|
||||||
|
"data-[state=unchecked]:bg-[var(--input)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block rounded-full ring-0",
|
||||||
|
"bg-[var(--background)]",
|
||||||
|
"transition-transform duration-200 ease-out",
|
||||||
|
"group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
|
||||||
|
"data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=checked]:bg-[var(--primary-foreground)]",
|
||||||
|
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--switch-thumb)]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
89
components/ui/tabs.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius)] p-[3px] text-[var(--muted)] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none border border-[var(--tabs-list-border)]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-[var(--tabs-list-bg)]",
|
||||||
|
line: "gap-1 bg-transparent border-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200",
|
||||||
|
"text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--tabs-list-bg)]",
|
||||||
|
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)]/50 focus-visible:outline-1 focus-visible:outline-[var(--ring)]",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
|
||||||
|
"group-data-[variant=default]/tabs-list:data-[state=active]:bg-[var(--tabs-trigger-active-bg)] group-data-[variant=default]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)] group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
|
||||||
|
"group-data-[variant=line]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)]",
|
||||||
|
"after:absolute after:h-0.5 after:bg-[var(--tabs-trigger-active-text)] after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none text-[var(--text)]", className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
BIN
doc/weChatGroupDevelop.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
@@ -7,6 +7,7 @@ services:
|
|||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}
|
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}
|
||||||
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID}
|
NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID}
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL: ${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Supabase 配置
|
# Supabase 配置
|
||||||
# 从 Supabase 项目设置中获取这些值:https://app.supabase.com/project/_/settings/api
|
# 从 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_URL=your_supabase_project_url
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
@@ -13,4 +13,10 @@ NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=your_web3forms_access_key
|
|||||||
# 从 Google Analytics 中获取这些值 https://analytics.google.com/analytics/web/
|
# 从 Google Analytics 中获取这些值 https://analytics.google.com/analytics/web/
|
||||||
NEXT_PUBLIC_GA_ID=G-xxxxxxxxxx
|
NEXT_PUBLIC_GA_ID=G-xxxxxxxxxx
|
||||||
|
|
||||||
|
# GitHub Release 检查配置
|
||||||
|
# 若需要在页面中展示「发现新版本」更新提示,请配置为对应仓库的最新 Release 接口地址
|
||||||
|
# 例如本仓库默认值:
|
||||||
|
# https://api.github.com/repos/hzm0321/real-time-fund/releases/latest
|
||||||
|
NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=
|
||||||
|
|
||||||
# 如果要用 Github Actions 部署,需要在 Github 项目 Settings → secrets and actions → Actions → 创建 Repository secrets
|
# 如果要用 Github Actions 部署,需要在 Github 项目 Settings → secrets and actions → Actions → 创建 Repository secrets
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const config = [
|
|||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/set-state-in-effect': 'off'
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'no-debugger': 'error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"jsx": "react"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
6115
package-lock.json
generated
30
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.6",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -13,25 +13,49 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^9.3.1",
|
"@dicebear/core": "^9.3.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"ahooks": "^3.9.6",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lodash": "^4.17.23",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"tesseract.js": "^5.1.1"
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tesseract.js": "^5.1.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"vaul": "^1.1.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^16.1.5",
|
"eslint-config-next": "^16.1.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.2.7",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"shadcn": "^3.8.5",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
|
|||||||
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/Icon-60@3x.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
104742
public/allFund.json
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
@@ -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' });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||