Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
20
README.md
20
README.md
@@ -5,6 +5,16 @@
|
||||
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
|
||||
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=hzm0321%2Freal-time-fund&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
|
||||
@@ -79,7 +89,11 @@
|
||||
官方验证码位数默认为8位,可自行修改。常见一般为6位。
|
||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email → Minimum password length 和 Email OTP Length 都改为6位。
|
||||
|
||||
5. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
||||
5. 关闭确认邮件
|
||||
|
||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
||||
|
||||
6. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
||||
|
||||
更多 Supabase 相关内容查阅官方文档。
|
||||
|
||||
@@ -126,9 +140,9 @@ docker compose up -d
|
||||
|
||||
## 💬 开发者交流群
|
||||
|
||||
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
||||
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
||||
|
||||
微信开发群人数已满200,如需加入请加微信号 `hzm1998hzm` 。加v备注:`基估宝开发`,邀请入群。
|
||||
<img src="./doc/weChatGroupDevelop.jpg" width="300">
|
||||
|
||||
## 📝 免责声明
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
@@ -1,14 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, PlusIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClose, onAdd }) {
|
||||
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, holdings = {}, onClose, onAdd }) {
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
|
||||
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
||||
|
||||
const getHoldingAmount = (fund) => {
|
||||
const holding = holdings[fund?.code];
|
||||
if (!holding || !holding.share || holding.share <= 0) return null;
|
||||
const nav = Number(fund?.dwjz) || Number(fund?.gsz) || Number(fund?.estGsz) || 0;
|
||||
if (!nav) return null;
|
||||
return holding.share * nav;
|
||||
};
|
||||
|
||||
const toggleSelect = (code) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -18,24 +30,21 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '500px', width: '90vw' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加基金到分组</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<PlusIcon width="20" height="20" />
|
||||
@@ -63,9 +72,14 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
<div className="checkbox" style={{ marginRight: 12 }}>
|
||||
{selected.has(fund.code) && <div className="checked-mark" />}
|
||||
</div>
|
||||
<div className="fund-info" style={{ flex: 1 }}>
|
||||
<div className="fund-info" style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{fund.name}</div>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund.code}</div>
|
||||
{getHoldingAmount(fund) != null && (
|
||||
<div className="muted" style={{ fontSize: '12px', marginTop: 2 }}>
|
||||
持仓金额:<span style={{ color: 'var(--foreground)', fontWeight: 500 }}>¥{getHoldingAmount(fund).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -84,7 +98,7 @@ export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClo
|
||||
确定 ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker } from './Common';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
const [type, setType] = useState('');
|
||||
@@ -77,30 +82,36 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="添加历史记录"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 1200 }}
|
||||
>
|
||||
<motion.div
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
style={{ maxWidth: '420px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 9998 }}
|
||||
style={{ maxWidth: '420px', zIndex: 9999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加历史记录</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<span>添加历史记录</span>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon />
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={handleCloseClick}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -200,15 +211,18 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
*此处补录的买入/卖出仅作记录展示,不会改变当前持仓金额与份额;实际持仓请在持仓设置中维护。
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary full-width"
|
||||
onClick={handleSubmit}
|
||||
disabled={!type || !date || !netValue || !amount || !share || loading}
|
||||
>
|
||||
确认添加
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={!type || !date || !netValue || !amount || !share || loading}
|
||||
>
|
||||
确认添加
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v13';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v15';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -75,12 +75,16 @@ export default function Announcement() {
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.2 版本更新内容如下:</p>
|
||||
<p>1. 新增 ios pwa 应用支持。</p>
|
||||
<p>2. 引入 shadcn ui 组件库,逐步调整项目样式。</p>
|
||||
<p>3. 列表模式表头固定。</p>
|
||||
<p>4. 列表模式点击名称,展示基金详情弹框。</p>
|
||||
<p>注:强烈建议苹果用户通过 Safari 浏览器→分享→添加应用到主屏幕,实现保存网页成APP效果。(安卓同理)</p>
|
||||
<p>v0.2.4 版本更新内容如下:</p>
|
||||
<p>1. 调整设置持仓相关弹框样式。</p>
|
||||
<p>2. 基金详情弹框支持设置持仓相关参数。</p>
|
||||
<p>3. 添加基金到分组弹框展示持仓金额数据。</p>
|
||||
<p>4. 已登录用户新增手动同步按钮。</p>
|
||||
<br/>
|
||||
<p>答疑:</p>
|
||||
<p>1. 因估值数据源问题,大部分海外基金估值数据不准或没有,暂时没有解决方案。</p>
|
||||
<p>2. 因交易日用户人数过多,为控制服务器免费额度上限,暂时减少数据自动同步频率,新增手动同步按钮。</p>
|
||||
<p>如有建议,欢迎进用户支持群反馈。</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -15,19 +15,31 @@ export default function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确定删除',
|
||||
icon,
|
||||
confirmVariant = 'danger', // 'danger' | 'primary' | 'secondary'
|
||||
}) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) onCancel();
|
||||
};
|
||||
|
||||
const confirmButtonToneClass =
|
||||
confirmVariant === 'primary'
|
||||
? 'button'
|
||||
: confirmVariant === 'secondary'
|
||||
? 'button secondary'
|
||||
: 'button danger';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="!z-[12000]"
|
||||
showCloseButton={false}
|
||||
className="max-w-[400px] flex flex-col gap-5 p-6"
|
||||
className="!z-[12010] max-w-[400px] flex flex-col gap-5 p-6"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center gap-3 text-left">
|
||||
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||
{icon || (
|
||||
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||
)}
|
||||
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||
@@ -43,7 +55,7 @@ export default function ConfirmModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button danger min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
className={`${confirmButtonToneClass} min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import { isNumber } from 'lodash';
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -170,173 +174,191 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="定投设置"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal dca-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{
|
||||
maxWidth: '420px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 999,
|
||||
width: '90vw',
|
||||
}}
|
||||
>
|
||||
<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="请输入每次定投金额"
|
||||
/>
|
||||
<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 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>
|
||||
<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 style={{ border: feeRate === '' ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
</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={feeRate}
|
||||
onChange={setFeeRate}
|
||||
step={0.01}
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={0}
|
||||
placeholder="0.12"
|
||||
placeholder="请输入每次定投金额"
|
||||
/>
|
||||
</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 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>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<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={day}
|
||||
ref={active ? monthlyDayRef : null}
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
||||
onClick={() => setMonthlyDay(day)}
|
||||
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
|
||||
onClick={() => setCycle(opt.value)}
|
||||
>
|
||||
{day}日
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
{(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>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
{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"
|
||||
@@ -346,17 +368,18 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={!isValid()}
|
||||
onClick={handleSubmit}
|
||||
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
||||
>
|
||||
保存定投
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function FundCard({
|
||||
onToggleCollapse,
|
||||
onToggleTrendCollapse,
|
||||
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||
masked = false,
|
||||
}) {
|
||||
const holding = holdings[f?.code];
|
||||
const profit = getHoldingProfit?.(f, holding) ?? null;
|
||||
@@ -226,27 +227,29 @@ export default function FundCard({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
|
||||
onClick={() => onHoldingClick?.(f)}
|
||||
>
|
||||
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
|
||||
未设置 <SettingsIcon width="12" height="12" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="stat"
|
||||
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
|
||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => onActionClick?.(f)}
|
||||
>
|
||||
<span
|
||||
className="label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
|
||||
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
|
||||
</span>
|
||||
<span className="value">
|
||||
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
|
||||
</span>
|
||||
<span className="value">¥{profit.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">当日收益</span>
|
||||
@@ -262,7 +265,9 @@ export default function FundCard({
|
||||
}`}
|
||||
>
|
||||
{profit.profitToday != null
|
||||
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
? masked
|
||||
? '******'
|
||||
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -288,14 +293,18 @@ export default function FundCard({
|
||||
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
|
||||
}`}
|
||||
>
|
||||
{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)}`}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1,61 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, PlusIcon } from './Icons';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldLabel, FieldContent } from '@/components/ui/field';
|
||||
import { PlusIcon, CloseIcon } from './Icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function GroupModal({ onClose, onConfirm }) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="新增分组"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose?.();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
style={{ maxWidth: '400px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<DialogContent
|
||||
overlayClassName="modal-overlay z-[9999]"
|
||||
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<PlusIcon width="20" height="20" />
|
||||
<span>新增分组</span>
|
||||
<div className="glass card modal !max-w-[280px] !w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PlusIcon className="w-5 h-5 shrink-0 text-[var(--foreground)]" aria-hidden />
|
||||
<DialogTitle asChild>
|
||||
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--secondary)] transition-colors duration-200 cursor-pointer"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 20 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称(最多 8 个字)</label>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,11 @@ export default function GroupSummary({
|
||||
groupName,
|
||||
getProfit,
|
||||
stickyTop,
|
||||
masked,
|
||||
onToggleMasked,
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(false);
|
||||
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
@@ -74,6 +76,12 @@ export default function GroupSummary({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof masked === 'boolean') {
|
||||
setIsMasked(masked);
|
||||
}
|
||||
}, [masked]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let totalAsset = 0;
|
||||
let totalProfitToday = 0;
|
||||
@@ -185,7 +193,13 @@ export default function GroupSummary({
|
||||
</div>
|
||||
<button
|
||||
className="fav-button"
|
||||
onClick={() => setIsMasked((value) => !value)}
|
||||
onClick={() => {
|
||||
if (onToggleMasked) {
|
||||
onToggleMasked();
|
||||
} else {
|
||||
setIsMasked((value) => !value);
|
||||
}
|
||||
}}
|
||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||
style={{
|
||||
margin: 0,
|
||||
|
||||
@@ -1,53 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, SettingsIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="持仓操作"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '320px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '320px', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">持仓操作</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>持仓操作</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('history')}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
title="查看交易记录"
|
||||
>
|
||||
<span>📜</span>
|
||||
<span>交易记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={() => onAction('history')}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
height: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
title="查看交易记录"
|
||||
>
|
||||
<span>📜</span>
|
||||
<span>交易记录</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
@@ -92,13 +89,13 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
background: 'linear-gradient(180deg, #ef4444, #f87171)',
|
||||
border: 'none',
|
||||
color: '#2b0b0b',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
清空持仓
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CloseIcon, SettingsIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||
@@ -89,25 +93,21 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
? (share && cost && !isNaN(share) && !isNaN(cost))
|
||||
: (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑持仓"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '400px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
style={{ maxWidth: '400px', zIndex: 999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">编辑持仓</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
@@ -238,7 +238,7 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import FitText from './FitText';
|
||||
import FundCard from './FundCard';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { CloseIcon, ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
@@ -55,6 +55,8 @@ const MOBILE_COLUMN_HEADERS = {
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
|
||||
const RowSortableContext = createContext(null);
|
||||
|
||||
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,7 +86,9 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
style={{ ...style, position: 'relative' }}
|
||||
{...attributes}
|
||||
>
|
||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||
<RowSortableContext.Provider value={{ setActivatorNodeRef, listeners }}>
|
||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||
</RowSortableContext.Provider>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +108,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用底部弹框展示卡片视图
|
||||
* @param {boolean} [props.masked] - 是否隐藏持仓相关金额
|
||||
*/
|
||||
export default function MobileFundTable({
|
||||
data = [],
|
||||
@@ -122,10 +127,14 @@ export default function MobileFundTable({
|
||||
getFundCardProps,
|
||||
blockDrawerClose = false,
|
||||
closeDrawerRef,
|
||||
masked = false,
|
||||
}) {
|
||||
const [isNameSortMode, setIsNameSortMode] = useState(false);
|
||||
|
||||
// 排序模式下拖拽手柄无需长按,直接拖动即可;非排序模式长按整行触发拖拽
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { delay: 400, tolerance: 5 },
|
||||
activationConstraint: isNameSortMode ? { delay: 0, tolerance: 5 } : { delay: 400, tolerance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
@@ -297,6 +306,19 @@ export default function MobileFundTable({
|
||||
};
|
||||
|
||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sortBy !== 'default') setIsNameSortMode(false);
|
||||
}, [sortBy]);
|
||||
|
||||
// 排序模式下,点击页面任意区域(含表格外)退出排序;使用冒泡阶段,避免先于排序按钮处理
|
||||
useEffect(() => {
|
||||
if (!isNameSortMode) return;
|
||||
const onDocClick = () => setIsNameSortMode(false);
|
||||
document.addEventListener('click', onDocClick);
|
||||
return () => document.removeEventListener('click', onDocClick);
|
||||
}, [isNameSortMode]);
|
||||
|
||||
const [cardSheetRow, setCardSheetRow] = useState(null);
|
||||
const tableContainerRef = useRef(null);
|
||||
const portalHeaderRef = useRef(null);
|
||||
@@ -334,13 +356,19 @@ export default function MobileFundTable({
|
||||
const nextStickyTop = getEffectiveStickyTop();
|
||||
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
|
||||
|
||||
const tableRect = tableContainerRef.current?.getBoundingClientRect();
|
||||
const tableEl = tableContainerRef.current;
|
||||
const tableRect = tableEl?.getBoundingClientRect();
|
||||
if (!tableRect) {
|
||||
setShowPortalHeader(window.scrollY >= nextStickyTop);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPortalHeader(tableRect.top <= nextStickyTop);
|
||||
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||
const hasPassedHeader = (tableRect.top + headerHeight) <= nextStickyTop;
|
||||
const hasTableInView = tableRect.bottom > nextStickyTop;
|
||||
|
||||
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||
};
|
||||
|
||||
const throttledVerticalUpdate = throttle(updateVerticalState, 1000/60, { leading: true, trailing: true });
|
||||
@@ -442,7 +470,8 @@ export default function MobileFundTable({
|
||||
};
|
||||
|
||||
// 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps)
|
||||
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet }) => {
|
||||
// 当 isNameSortMode 且 sortBy==='default' 时,左侧显示排序/拖拽图标,可拖动行排序
|
||||
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet, isNameSortMode: nameSortMode, sortBy: currentSortBy }) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
@@ -451,10 +480,23 @@ export default function MobileFundTable({
|
||||
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
const rowSortable = useContext(RowSortableContext);
|
||||
const showDragHandle = nameSortMode && currentSortBy === 'default' && rowSortable;
|
||||
|
||||
return (
|
||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isGroupTab ? (
|
||||
{showDragHandle ? (
|
||||
<span
|
||||
ref={rowSortable.setActivatorNodeRef}
|
||||
className="icon-button fav-button"
|
||||
title="拖动排序"
|
||||
style={{ backgroundColor: 'transparent', touchAction: 'none', cursor: 'grab', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...rowSortable.listeners}
|
||||
>
|
||||
<DragIcon width="18" height="18" />
|
||||
</span>
|
||||
) : isGroupTab ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
@@ -519,7 +561,7 @@ export default function MobileFundTable({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{holdingAmountDisplay}
|
||||
{masked ? '******' : holdingAmountDisplay}
|
||||
{hasDca && <span className="dca-indicator">定</span>}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
@@ -581,6 +623,31 @@ export default function MobileFundTable({
|
||||
>
|
||||
<SettingsIcon width="18" height="18" />
|
||||
</button>
|
||||
{sortBy === 'default' && (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-button ${isNameSortMode ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setIsNameSortMode((prev) => !prev);
|
||||
}}
|
||||
title={isNameSortMode ? '退出排序' : '拖动排序'}
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
minWidth: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isNameSortMode ? 'var(--primary)' : 'var(--text)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SortIcon width="18" height="18" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (info) => (
|
||||
@@ -588,6 +655,8 @@ export default function MobileFundTable({
|
||||
info={info}
|
||||
showFullFundName={showFullFundName}
|
||||
onOpenCardSheet={getFundCardProps ? (row) => setCardSheetRow(row) : undefined}
|
||||
isNameSortMode={isNameSortMode}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
),
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||
@@ -595,25 +664,41 @@ export default function MobileFundTable({
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
cell: (info) => (
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.latestNavDate ?? '-';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateNav',
|
||||
header: '估算净值',
|
||||
cell: (info) => (
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.estimateNavDate ?? '-';
|
||||
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
||||
},
|
||||
{
|
||||
@@ -671,10 +756,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? '******' : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -701,10 +786,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? '******' : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr && !isUpdated ? (
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -730,10 +815,10 @@ export default function MobileFundTable({
|
||||
<div style={{ width: '100%' }}>
|
||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasTotal ? '******' : amountStr}
|
||||
</FitText>
|
||||
</span>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -746,7 +831,7 @@ export default function MobileFundTable({
|
||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps]
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -934,7 +1019,7 @@ export default function MobileFundTable({
|
||||
>
|
||||
{(setActivatorNodeRef, listeners) => (
|
||||
<div
|
||||
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
||||
ref={sortBy === 'default' && !isNameSortMode ? setActivatorNodeRef : undefined}
|
||||
className="table-row"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
@@ -942,7 +1027,8 @@ export default function MobileFundTable({
|
||||
zIndex: 1,
|
||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||
}}
|
||||
{...(sortBy === 'default' ? listeners : {})}
|
||||
onClick={isNameSortMode ? () => setIsNameSortMode(false) : undefined}
|
||||
{...(sortBy === 'default' && !isNameSortMode ? listeners : {})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const columnId = cell.column.id;
|
||||
|
||||
@@ -198,6 +198,8 @@ export default function MobileSettingModal({
|
||||
key="mobile-reset-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||
|
||||
const NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
@@ -131,6 +131,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
* @param {React.MutableRefObject<(() => void) | null>} [props.closeDialogRef] - 注入关闭弹框的方法,用于确认删除时关闭
|
||||
* @param {boolean} [props.blockDialogClose] - 为 true 时阻止点击遮罩关闭弹框(如删除确认弹框打开时)
|
||||
* @param {number} [props.stickyTop] - 表头固定时的 top 偏移(与 MobileFundTable 一致,用于适配导航栏、筛选栏等)
|
||||
* @param {boolean} [props.masked] - 是否隐藏持仓相关金额
|
||||
*/
|
||||
export default function PcFundTable({
|
||||
data = [],
|
||||
@@ -149,6 +150,7 @@ export default function PcFundTable({
|
||||
closeDialogRef,
|
||||
blockDialogClose = false,
|
||||
stickyTop = 0,
|
||||
masked = false,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -412,7 +414,12 @@ export default function PcFundTable({
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPortalHeader(rect.top <= nextStickyTop);
|
||||
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||
const hasPassedHeader = (rect.top + headerHeight) <= nextStickyTop;
|
||||
const hasTableInView = rect.bottom > nextStickyTop;
|
||||
|
||||
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||
|
||||
setPortalHorizontal((prev) => {
|
||||
const next = {
|
||||
@@ -561,11 +568,20 @@ export default function PcFundTable({
|
||||
header: '最新净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.latestNavDate ?? '-';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
@@ -576,11 +592,20 @@ export default function PcFundTable({
|
||||
header: '估算净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const date = original.estimateNavDate ?? '-';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
@@ -655,9 +680,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? '******' : amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -713,7 +738,7 @@ export default function PcFundTable({
|
||||
>
|
||||
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
{masked ? '******' : (info.getValue() ?? '—')}
|
||||
</FitText>
|
||||
</div>
|
||||
<button
|
||||
@@ -751,9 +776,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasProfit ? '******' : amountStr}
|
||||
</FitText>
|
||||
{percentStr && !isUpdated ? (
|
||||
{percentStr && !isUpdated && !masked ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -783,9 +808,9 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
{masked && hasTotal ? '******' : amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
{percentStr && !masked ? (
|
||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
@@ -1112,6 +1137,8 @@ export default function PcFundTable({
|
||||
<ConfirmModal
|
||||
title="重置列宽"
|
||||
message="是否重置表格列宽为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={handleResetSizing}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
|
||||
@@ -267,6 +267,8 @@ export default function PcTableSettingModal({
|
||||
key="reset-order-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
|
||||
94
app/components/PendingTradesModal.jsx
Normal file
94
app/components/PendingTradesModal.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PendingTradesModal({
|
||||
open,
|
||||
trades = [],
|
||||
onClose,
|
||||
onRevoke,
|
||||
}) {
|
||||
const handleOpenChange = (nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal trade-modal"
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 998 }}
|
||||
style={{ maxWidth: '420px', zIndex: 999, width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">待交易队列</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>📥</span>
|
||||
<span>待交易队列</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{trades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)',
|
||||
}}
|
||||
>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
{trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
<span className="muted">份额/金额</span>
|
||||
<span>{trade.share ? `${trade.share} 份` : `¥${trade.amount}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => onRevoke?.(trade)}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
38
app/components/PwaRegister.jsx
Normal file
38
app/components/PwaRegister.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 在客户端注册 Service Worker,满足 Android Chrome PWA 安装条件(需 HTTPS + manifest + SW)。
|
||||
* 仅在生产环境且浏览器支持时注册。
|
||||
*/
|
||||
export default function PwaRegister() {
|
||||
useEffect(() => {// 检测核心能力
|
||||
const isPwaSupported =
|
||||
'serviceWorker' in navigator &&
|
||||
'BeforeInstallPromptEvent' in window;
|
||||
console.log('PWA 支持:', isPwaSupported);
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
process.env.NODE_ENV !== 'production'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/', updateViaCache: 'none' })
|
||||
.then((reg) => {
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const newWorker = reg.installing;
|
||||
newWorker?.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// 可选:提示用户刷新以获取新版本
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
@@ -20,6 +22,22 @@ export default function SettingsModal({
|
||||
}) {
|
||||
const [sliderDragging, setSliderDragging] = useState(false);
|
||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
||||
const pageWidthTrackRef = useRef(null);
|
||||
|
||||
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
const pageWidthPercent = ((clampedWidth - 600) / (2000 - 600)) * 100;
|
||||
|
||||
const updateWidthByClientX = (clientX) => {
|
||||
if (!pageWidthTrackRef.current || !setContainerWidth) return;
|
||||
const rect = pageWidthTrackRef.current.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const ratio = (clientX - rect.left) / rect.width;
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
const rawWidth = 600 + clampedRatio * (2000 - 600);
|
||||
const snapped = Math.round(rawWidth / 10) * 10;
|
||||
setContainerWidth(snapped);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sliderDragging) return;
|
||||
@@ -32,129 +50,158 @@ export default function SettingsModal({
|
||||
};
|
||||
}, [sliderDragging]);
|
||||
|
||||
// 外部的 tempSeconds 变更时,同步到本地显示,但不立即生效
|
||||
useEffect(() => {
|
||||
setLocalSeconds(tempSeconds);
|
||||
}, [tempSeconds]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置"
|
||||
onClick={onClose}
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose?.();
|
||||
}}
|
||||
>
|
||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>设置</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||
<div className="chips" style={{ marginBottom: 12 }}>
|
||||
{[30, 60, 120, 300].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`chip ${tempSeconds === s ? 'active' : ''}`}
|
||||
onClick={() => setTempSeconds(s)}
|
||||
aria-pressed={tempSeconds === s}
|
||||
>
|
||||
{s} 秒
|
||||
</button>
|
||||
))}
|
||||
<DialogContent
|
||||
overlayClassName={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''} z-[9999]`}
|
||||
className="!p-0 z-[10000]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="glass card modal">
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<DialogTitle asChild>
|
||||
<span>设置</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="30"
|
||||
step="5"
|
||||
value={tempSeconds}
|
||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
||||
placeholder="自定义秒数"
|
||||
/>
|
||||
{tempSeconds < 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 && (
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||
<div className="chips" style={{ marginBottom: 12 }}>
|
||||
{[30, 60, 120, 300].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setResetWidthConfirmOpen(true)}
|
||||
title="重置页面宽度"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
className={`chip ${localSeconds === s ? 'active' : ''}`}
|
||||
onClick={() => setLocalSeconds(s)}
|
||||
aria-pressed={localSeconds === s}
|
||||
>
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="14" height="14" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={600}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
|
||||
onChange={(e) => setContainerWidth(Number(e.target.value))}
|
||||
onPointerDown={() => setSliderDragging(true)}
|
||||
className="page-width-slider"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
accentColor: 'var(--primary)',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
||||
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<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}
|
||||
<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 className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<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="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button
|
||||
className="button"
|
||||
onClick={(e) => saveSettings(e, localSeconds)}
|
||||
disabled={localSeconds < 30}
|
||||
>
|
||||
保存并关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||
<ConfirmModal
|
||||
title="重置页面宽度"
|
||||
message="是否重置页面宽度为默认值 1200px?"
|
||||
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => {
|
||||
onResetContainerWidth();
|
||||
setResetWidthConfirmOpen(false);
|
||||
@@ -163,6 +210,6 @@ export default function SettingsModal({
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
37
app/components/ThemeColorSync.jsx
Normal file
37
app/components/ThemeColorSync.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const THEME_COLORS = {
|
||||
dark: '#0f172a',
|
||||
light: '#ffffff',
|
||||
};
|
||||
|
||||
function getThemeColor() {
|
||||
if (typeof document === 'undefined') return THEME_COLORS.dark;
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
return THEME_COLORS[theme] ?? THEME_COLORS.dark;
|
||||
}
|
||||
|
||||
function applyThemeColor() {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', getThemeColor());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前亮/暗主题同步 PWA theme-color meta,使 Android 状态栏与页面主题一致。
|
||||
* 监听 document.documentElement 的 data-theme 变化并更新 meta。
|
||||
*/
|
||||
export default function ThemeColorSync() {
|
||||
useEffect(() => {
|
||||
applyThemeColor();
|
||||
const observer = new MutationObserver(() => applyThemeColor());
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
@@ -10,6 +10,12 @@ import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon } from './Icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import PendingTradesModal from './PendingTradesModal';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -153,36 +159,33 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
const [revokeTrade, setRevokeTrade] = useState(null);
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isBuy ? "加仓" : "减仓"}
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal trade-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 99 }}
|
||||
style={{ maxWidth: '420px', width: '90vw', zIndex: 99 }}
|
||||
>
|
||||
<DialogTitle className="sr-only">{isBuy ? '加仓' : '减仓'}</DialogTitle>
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>{isBuy ? '📥' : '📤'}</span>
|
||||
<span>{showPendingList ? '待交易队列' : (showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓'))}</span>
|
||||
<span>{showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓')}</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
||||
{!showConfirm && currentPendingTrades.length > 0 && (
|
||||
<div
|
||||
className="trade-pending-alert"
|
||||
onClick={() => setShowPendingList(true)}
|
||||
@@ -192,49 +195,6 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPendingList ? (
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-header trade-pending-header">
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setShowPendingList(false)}
|
||||
style={{ padding: '4px 8px', fontSize: '12px' }}
|
||||
>
|
||||
< 返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{currentPendingTrades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>{trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
|
||||
<span className="muted">份额/金额</span>
|
||||
<span>{trade.share ? `${trade.share} 份` : `¥${trade.amount}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary trade-revoke-btn"
|
||||
onClick={() => setRevokeTrade(trade)}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!showConfirm && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||
@@ -316,10 +276,10 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
className="button queue-button"
|
||||
onClick={handleFinalConfirm}
|
||||
disabled={loadingPrice}
|
||||
style={{ flex: 1, background: 'var(--primary)', opacity: loadingPrice ? 0.6 : 1, color: '#05263b' }}
|
||||
style={{ flex: 1, background: 'var(--primary)', opacity: loadingPrice ? 0.6 : 1 }}
|
||||
>
|
||||
{loadingPrice ? '请稍候' : (price ? '确认买入' : '加入待处理队列')}
|
||||
</button>
|
||||
@@ -398,7 +358,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
className="button queue-button"
|
||||
onClick={handleFinalConfirm}
|
||||
disabled={loadingPrice}
|
||||
style={{ flex: 1, background: 'var(--danger)', opacity: loadingPrice ? 0.6 : 1 }}
|
||||
@@ -612,9 +572,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
<AnimatePresence>
|
||||
{revokeTrade && (
|
||||
<ConfirmModal
|
||||
@@ -630,6 +588,12 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<PendingTradesModal
|
||||
open={showPendingList}
|
||||
trades={currentPendingTrades}
|
||||
onClose={() => setShowPendingList(false)}
|
||||
onRevoke={(trade) => setRevokeTrade(trade)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { CloseIcon } from './Icons';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function TransactionHistoryModal({
|
||||
fund,
|
||||
transactions = [],
|
||||
pendingTransactions = [],
|
||||
onClose,
|
||||
export default function TransactionHistoryModal({
|
||||
fund,
|
||||
transactions = [],
|
||||
pendingTransactions = [],
|
||||
onClose,
|
||||
onDeleteTransaction,
|
||||
onDeletePending,
|
||||
onAddHistory
|
||||
onAddHistory,
|
||||
}) {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
|
||||
|
||||
@@ -39,31 +45,46 @@ export default function TransactionHistoryModal({
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
// 只关闭交易记录弹框,避免事件冒泡影响到其他弹框(例如 HoldingActionModal)
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 1100 }} // Higher than TradeModal if stacked, but usually TradeModal closes or this opens on top
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="glass card modal tx-history-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||
overlayClassName="modal-overlay"
|
||||
overlayStyle={{ zIndex: 998 }}
|
||||
style={{
|
||||
maxWidth: '480px',
|
||||
width: '90vw',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 999, // 保持原有层级,确保在其他弹框之上
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">交易记录</DialogTitle>
|
||||
|
||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '20px' }}>📜</span>
|
||||
<span>交易记录</span>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={handleCloseClick}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -73,10 +94,10 @@ export default function TransactionHistoryModal({
|
||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
||||
</div>
|
||||
<button
|
||||
className="button primary"
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={onAddHistory}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
|
||||
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto', width: '80px' }}
|
||||
>
|
||||
添加记录
|
||||
</button>
|
||||
@@ -108,13 +129,16 @@ export default function TransactionHistoryModal({
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary tx-history-action-btn"
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => handleDeleteClick(item, 'pending')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -158,13 +182,16 @@ export default function TransactionHistoryModal({
|
||||
)}
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="muted"></span>
|
||||
<button
|
||||
className="button secondary tx-history-action-btn"
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="destructive"
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => handleDeleteClick(item, 'history')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
style={{ paddingInline: 10 }}
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -172,22 +199,21 @@ export default function TransactionHistoryModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{deleteConfirm && (
|
||||
<ConfirmModal
|
||||
key="delete-confirm"
|
||||
title={deleteConfirm.type === 'pending' ? "撤销交易" : "删除记录"}
|
||||
message={deleteConfirm.type === 'pending'
|
||||
? "确定要撤销这笔待处理交易吗?"
|
||||
: "确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。"}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{deleteConfirm && (
|
||||
<ConfirmModal
|
||||
key="delete-confirm"
|
||||
title={deleteConfirm.type === 'pending' ? '撤销交易' : '删除记录'}
|
||||
message={deleteConfirm.type === 'pending'
|
||||
? '确定要撤销这笔待处理交易吗?'
|
||||
: '确定要删除这条交易记录吗?\n注意:删除记录不会恢复已变更的持仓数据。'}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
confirmText="确认删除"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ export default function WeChatModal({ onClose }) {
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="trade-pending-alert"
|
||||
>
|
||||
<span>⚠️ 入群须知:禁止讨论和基金买卖以及投资的有关内容,可反馈软件相关需求和问题。</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={weChatGroupImg}
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
--popover: #111827;
|
||||
--popover-foreground: #e5e7eb;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #1f2937;
|
||||
--secondary: #0b1220;
|
||||
--secondary-foreground: #e5e7eb;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent-foreground: #e5e7eb;
|
||||
--destructive: #f87171;
|
||||
--input: #1f2937;
|
||||
--input: #0b1220;
|
||||
--ring: #22d3ee;
|
||||
--chart-1: #22d3ee;
|
||||
--chart-2: #60a5fa;
|
||||
@@ -76,7 +76,7 @@
|
||||
--muted-foreground: #475569;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #dc2626;
|
||||
--input: #e2e8f0;
|
||||
--input: #f1f5f9;
|
||||
--ring: #0891b2;
|
||||
--chart-1: #0891b2;
|
||||
--chart-2: #2563eb;
|
||||
@@ -1695,6 +1695,11 @@ input[type="number"] {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* 基金名称表头排序按钮在排序模式下的高亮 */
|
||||
.mobile-fund-table .mobile-fund-table-header .icon-button.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.mobile-fund-table .table-row .name-cell .name-cell-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -2073,7 +2078,6 @@ input[type="number"] {
|
||||
|
||||
/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
|
||||
[data-slot="dialog-content"] {
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
@@ -2266,6 +2270,10 @@ input[type="number"] {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
[data-theme="light"] .trade-modal .queue-button {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.trade-time-slot {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -2306,6 +2314,13 @@ input[type="number"] {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* 亮色主题:TradeModal */
|
||||
[data-theme="light"] .trade-pending-alert {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
[data-theme="light"] .trade-modal .trade-pending-header {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-bottom-color: var(--border);
|
||||
@@ -3445,7 +3460,7 @@ input[type="number"] {
|
||||
--accent-foreground: #f8fafc;
|
||||
--destructive: #f87171;
|
||||
--border: #1f2937;
|
||||
--input: #1e293b;
|
||||
--input: #0b1220;
|
||||
--ring: #22d3ee;
|
||||
--chart-1: #22d3ee;
|
||||
--chart-2: #60a5fa;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import './globals.css';
|
||||
import AnalyticsGate from './components/AnalyticsGate';
|
||||
import PwaRegister from './components/PwaRegister';
|
||||
import ThemeColorSync from './components/ThemeColorSync';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
export const metadata = {
|
||||
@@ -18,6 +21,9 @@ export default function RootLayout({ children }) {
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
{/* 初始为暗色;ThemeColorSync 会按 data-theme 同步为亮/暗 */}
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
{/* 尽早设置 data-theme,减少首屏主题闪烁;与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
|
||||
<script
|
||||
@@ -27,8 +33,11 @@ export default function RootLayout({ children }) {
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AnalyticsGate GA_ID={GA_ID} />
|
||||
{children}
|
||||
<ThemeColorSync />
|
||||
<PwaRegister />
|
||||
<AnalyticsGate GA_ID={GA_ID} />
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
119
app/page.jsx
119
app/page.jsx
@@ -61,6 +61,7 @@ import WeChatModal from "./components/WeChatModal";
|
||||
import DcaModal from "./components/DcaModal";
|
||||
import githubImg from "./assets/github.svg";
|
||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||
import { toast as sonnerToast } from 'sonner';
|
||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
||||
import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
|
||||
@@ -184,6 +185,8 @@ export default function HomePage() {
|
||||
|
||||
// 视图模式
|
||||
const [viewMode, setViewMode] = useState('card'); // card, list
|
||||
// 全局隐藏金额状态(影响分组汇总、列表和卡片)
|
||||
const [maskAmounts, setMaskAmounts] = useState(false);
|
||||
|
||||
// 用户认证状态
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -659,7 +662,9 @@ export default function HomePage() {
|
||||
isUpdated: f.jzrq === todayStr,
|
||||
hasDca: dcaPlans[f.code]?.enabled === true,
|
||||
latestNav,
|
||||
latestNavDate: yesterdayDate,
|
||||
estimateNav,
|
||||
estimateNavDate: estimateTime,
|
||||
yesterdayChangePercent,
|
||||
yesterdayChangeValue,
|
||||
yesterdayDate,
|
||||
@@ -1427,7 +1432,6 @@ export default function HomePage() {
|
||||
const fields = Array.from(new Set([
|
||||
'jzrq',
|
||||
'dwjz',
|
||||
'gsz',
|
||||
...(Array.isArray(extraFields) ? extraFields : [])
|
||||
]));
|
||||
const items = list.map((item) => {
|
||||
@@ -2078,29 +2082,30 @@ export default function HomePage() {
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupabaseConfigured || !user?.id) return;
|
||||
const channel = supabase
|
||||
.channel(`user-configs-${user.id}`)
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
const incoming = payload?.new?.data;
|
||||
if (!isPlainObject(incoming)) return;
|
||||
const incomingComparable = getComparablePayload(incoming);
|
||||
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
})
|
||||
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
const incoming = payload?.new?.data;
|
||||
if (!isPlainObject(incoming)) return;
|
||||
const incomingComparable = getComparablePayload(incoming);
|
||||
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
})
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user?.id]);
|
||||
// 实时同步
|
||||
// useEffect(() => {
|
||||
// if (!isSupabaseConfigured || !user?.id) return;
|
||||
// const channel = supabase
|
||||
// .channel(`user-configs-${user.id}`)
|
||||
// .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
// const incoming = payload?.new?.data;
|
||||
// if (!isPlainObject(incoming)) return;
|
||||
// const incomingComparable = getComparablePayload(incoming);
|
||||
// if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
// await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
// })
|
||||
// .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
|
||||
// const incoming = payload?.new?.data;
|
||||
// if (!isPlainObject(incoming)) return;
|
||||
// const incomingComparable = getComparablePayload(incoming);
|
||||
// if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
|
||||
// await applyCloudConfig(incoming, payload.new.updated_at);
|
||||
// })
|
||||
// .subscribe();
|
||||
// return () => {
|
||||
// supabase.removeChannel(channel);
|
||||
// };
|
||||
// }, [user?.id]);
|
||||
|
||||
const handleSendOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2585,9 +2590,11 @@ export default function HomePage() {
|
||||
await refreshAll(codes);
|
||||
};
|
||||
|
||||
const saveSettings = (e) => {
|
||||
const saveSettings = (e, secondsOverride) => {
|
||||
e?.preventDefault?.();
|
||||
const ms = Math.max(30, Number(tempSeconds)) * 1000;
|
||||
const seconds = secondsOverride ?? tempSeconds;
|
||||
const ms = Math.max(30, Number(seconds)) * 1000;
|
||||
setTempSeconds(Math.round(ms / 1000));
|
||||
setRefreshMs(ms);
|
||||
storageHelper.setItem('refreshMs', String(ms));
|
||||
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||
@@ -3067,9 +3074,6 @@ export default function HomePage() {
|
||||
const dataToSync = payload || collectLocalPayload(); // Fallback to full sync if no payload
|
||||
const now = nowInTz().toISOString();
|
||||
|
||||
let upsertData = null;
|
||||
let updateError = null;
|
||||
|
||||
if (isPartial) {
|
||||
// 增量更新:使用 RPC 调用
|
||||
const { error: rpcError } = await supabase.rpc('update_user_config_partial', {
|
||||
@@ -3080,7 +3084,7 @@ export default function HomePage() {
|
||||
console.error('增量同步失败,尝试全量同步', rpcError);
|
||||
// RPC 失败回退到全量更新
|
||||
const fullPayload = collectLocalPayload();
|
||||
const { data, error } = await supabase
|
||||
const { error } = await supabase
|
||||
.from('user_configs')
|
||||
.upsert(
|
||||
{
|
||||
@@ -3089,17 +3093,12 @@ export default function HomePage() {
|
||||
updated_at: now
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
.select();
|
||||
upsertData = data;
|
||||
updateError = error;
|
||||
} else {
|
||||
// RPC 成功,模拟 upsertData 格式以便后续逻辑通过
|
||||
upsertData = [{ id: 'rpc_success' }];
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
} else {
|
||||
// 全量更新
|
||||
const { data, error } = await supabase
|
||||
const { error } = await supabase
|
||||
.from('user_configs')
|
||||
.upsert(
|
||||
{
|
||||
@@ -3108,15 +3107,8 @@ export default function HomePage() {
|
||||
updated_at: now
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
.select();
|
||||
upsertData = data;
|
||||
updateError = error;
|
||||
}
|
||||
|
||||
if (updateError) throw updateError;
|
||||
if (!upsertData || upsertData.length === 0) {
|
||||
throw new Error('同步失败:未写入任何数据,请检查账号状态或重新登录');
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
storageHelper.setItem('localUpdatedAt', now);
|
||||
@@ -3724,6 +3716,26 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item"
|
||||
disabled={isSyncing}
|
||||
onClick={async () => {
|
||||
setUserMenuOpen(false);
|
||||
if (user?.id) await syncUserConfig(user.id);
|
||||
}}
|
||||
title="手动同步配置到云端"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<span className="loading-spinner" style={{ width: 16, height: 16, border: '2px solid var(--muted)', borderTopColor: 'var(--primary)', borderRadius: '50%', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" stroke="var(--primary)" />
|
||||
<path d="M12 12v9" stroke="var(--accent)" />
|
||||
<path d="m16 16-4-4-4 4" stroke="var(--accent)" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isSyncing ? '同步中...' : '同步'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
@@ -3936,6 +3948,8 @@ export default function HomePage() {
|
||||
groupName={getGroupName()}
|
||||
getProfit={getHoldingProfit}
|
||||
stickyTop={navbarHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||
masked={maskAmounts}
|
||||
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
||||
/>
|
||||
|
||||
{currentTab !== 'all' && currentTab !== 'fav' && (
|
||||
@@ -4030,6 +4044,7 @@ export default function HomePage() {
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
closeDialogRef={fundDetailDialogCloseRef}
|
||||
blockDialogClose={!!fundDeleteConfirm}
|
||||
masked={maskAmounts}
|
||||
getFundCardProps={(row) => {
|
||||
const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null);
|
||||
if (!fund) return {};
|
||||
@@ -4058,6 +4073,7 @@ export default function HomePage() {
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
layoutMode: 'drawer',
|
||||
};
|
||||
}}
|
||||
@@ -4133,9 +4149,11 @@ export default function HomePage() {
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
layoutMode: 'drawer',
|
||||
};
|
||||
}}
|
||||
masked={maskAmounts}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -4176,6 +4194,7 @@ export default function HomePage() {
|
||||
}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onToggleTrendCollapse={toggleTrendCollapse}
|
||||
masked={maskAmounts}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -4210,6 +4229,7 @@ export default function HomePage() {
|
||||
<ConfirmModal
|
||||
title="确认登出"
|
||||
message="确定要退出当前账号吗?"
|
||||
icon={<LogoutIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />}
|
||||
confirmText="确认登出"
|
||||
onConfirm={() => {
|
||||
setLogoutConfirmOpen(false);
|
||||
@@ -4229,6 +4249,10 @@ export default function HomePage() {
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => {
|
||||
if (!user?.id) {
|
||||
sonnerToast.error('请先登录后再提交反馈');
|
||||
return;
|
||||
}
|
||||
setFeedbackNonce((n) => n + 1);
|
||||
setFeedbackOpen(true);
|
||||
}}
|
||||
@@ -4296,6 +4320,7 @@ export default function HomePage() {
|
||||
<AddFundToGroupModal
|
||||
allFunds={funds}
|
||||
currentGroupCodes={groups.find(g => g.id === currentTab)?.codes || []}
|
||||
holdings={holdings}
|
||||
onClose={() => setAddFundToGroupOpen(false)}
|
||||
onAdd={handleAddFundsToGroup}
|
||||
/>
|
||||
|
||||
60
components/ui/button.jsx
Normal file
60
components/ui/button.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[linear-gradient(#0ea5e9,#0891b2)] text-white hover:bg-[linear-gradient(#0284c7,#0e7490)]",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -49,15 +49,17 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
overlayClassName,
|
||||
overlayStyle,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} style={overlayStyle} />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
245
components/ui/field.jsx
Normal file
245
components/ui/field.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
})
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
fieldVariants({ orientation }),
|
||||
// iOS 聚焦时若输入框字体 < 16px 会触发缩放,小屏下强制 16px 避免缩放
|
||||
"max-md:[&_input]:text-base max-md:[&_textarea]:text-base max-md:[&_select]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content">
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map((error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
23
components/ui/label.jsx
Normal file
23
components/ui/label.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
43
components/ui/progress.jsx
Normal file
43
components/ui/progress.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
// 细高条,轻玻璃质感,统一用 CSS 变量
|
||||
"relative w-full overflow-hidden rounded-full",
|
||||
"h-1.5 sm:h-1.5",
|
||||
"bg-[var(--input)]/70 dark:bg-[var(--input)]/40",
|
||||
"border border-[var(--border)]/80 dark:border-[var(--border)]/80",
|
||||
"shadow-[0_0_0_1px_rgba(15,23,42,0.02)] dark:shadow-[0_0_0_1px_rgba(15,23,42,0.6)]",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"h-full w-full flex-1",
|
||||
// 金融风轻渐变,兼容明暗主题
|
||||
"bg-gradient-to-r from-[var(--primary)] to-[var(--primary)]/80",
|
||||
"dark:from-[var(--primary)] dark:to-[var(--secondary)]/90",
|
||||
// 柔和发光,不喧宾夺主
|
||||
"shadow-[0_0_8px_rgba(245,158,11,0.35)] dark:shadow-[0_0_14px_rgba(245,158,11,0.45)]",
|
||||
// 平滑进度动画
|
||||
"transition-[transform,box-shadow] duration-250 ease-out"
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
27
components/ui/separator.jsx
Normal file
27
components/ui/separator.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
61
components/ui/sonner.jsx
Normal file
61
components/ui/sonner.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
// 外层容器:固定在页面顶部中间
|
||||
className="toaster pointer-events-none fixed inset-x-0 top-4 z-[70] flex items-start justify-center px-4 sm:top-6"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="h-4 w-4 text-emerald-500" />,
|
||||
info: <InfoIcon className="h-4 w-4 text-sky-500" />,
|
||||
warning: <TriangleAlertIcon className="h-4 w-4 text-amber-500" />,
|
||||
error: <OctagonXIcon className="h-4 w-4 text-destructive" />,
|
||||
loading: <Loader2Icon className="h-4 w-4 animate-spin text-primary" />,
|
||||
}}
|
||||
richColors
|
||||
// 统一 toast 样式,使用 ui-ux-pro-max 建议的明暗主题对比度
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
// 基础:浅色模式下使用高对比白色卡片,暗色模式使用深色卡片
|
||||
"pointer-events-auto relative flex w-full max-w-sm items-start gap-3 rounded-xl border border-slate-200 bg-white/90 text-slate-900 px-4 py-3 shadow-lg shadow-black/10 backdrop-blur-md transition-all duration-200 " +
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top sm:data-[state=open]:slide-in-from-bottom " +
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right " +
|
||||
"data-[swipe=move]:translate-x-[var(--sonner-swipe-move-x)] data-[swipe=move]:transition-none " +
|
||||
"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-x-[var(--sonner-swipe-end-x)] " +
|
||||
"dark:border-slate-800 dark:bg-slate-900/90 dark:text-slate-100",
|
||||
title: "text-sm font-medium",
|
||||
description: "mt-1 text-xs text-slate-600 dark:text-slate-400",
|
||||
closeButton:
|
||||
"cursor-pointer text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
actionButton:
|
||||
"inline-flex h-8 items-center justify-center rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||
cancelButton:
|
||||
"inline-flex h-8 items-center justify-center rounded-full border border-border bg-background px-3 text-xs font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
// 状态色:成功/信息/警告只强化边框,错误使用红色背景,满足你“提示为红色”的需求
|
||||
success: "border-emerald-500/70",
|
||||
info: "border-sky-500/70",
|
||||
warning: "border-amber-500/70",
|
||||
error: "bg-destructive text-destructive-foreground border-destructive/80",
|
||||
loading: "border-primary/60",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
BIN
doc/weChatGroupDevelop.jpg
Normal file
BIN
doc/weChatGroupDevelop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -1,6 +1,6 @@
|
||||
# Supabase 配置
|
||||
# 从 Supabase 项目设置中获取这些值:https://app.supabase.com/project/_/settings/api
|
||||
# 复制此文件为 .env.local 并填入实际值
|
||||
# 复制此文件为 .env.local 并填入实际值(docker 部署复制成 .env)
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
@@ -26,10 +26,12 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -9903,6 +9905,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -10720,7 +10732,7 @@
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11669,6 +11681,16 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -29,10 +29,12 @@
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "18.3.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
34
public/manifest.webmanifest
Normal file
34
public/manifest.webmanifest
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "基估宝",
|
||||
"short_name": "基估宝",
|
||||
"description": "基金管理管家",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"id": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/Icon-60@3x.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["finance", "utilities"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
18
public/sw.js
Normal file
18
public/sw.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// 最小 Service Worker,满足 Android Chrome「添加到主屏幕」的安装条件
|
||||
const CACHE_NAME = 'jigubao-v1';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => {
|
||||
return new Response('', { status: 503, statusText: 'Service Unavailable' });
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user