30 Commits

Author SHA1 Message Date
hzm
5981440881 feat:隐藏持仓的时候同时隐藏颜色 2026-03-12 23:01:39 +08:00
hzm
2816a6c0dd feat:移动端连续弹框引起的滚动问题 2026-03-12 22:31:34 +08:00
hzm
e1eb3ea8ca feat:弹框样式调整 2026-03-12 22:22:42 +08:00
hzm
15df89a9dd feat:移动端列去掉年份显示 2026-03-12 22:00:30 +08:00
hzm
8849b547ce fix:解决移动端 Dialog 滚动问题 2026-03-12 21:53:11 +08:00
hzm
7953b906a5 feat: 登录时数据覆盖操作增加二次确认 2026-03-12 20:29:17 +08:00
hzm
d00c8cf3eb feat: 部分估值展示内容去除年份 2026-03-12 20:22:09 +08:00
hzm
966c853eb5 fix: 估算收益未设置持仓金额显示问题 2026-03-12 10:03:21 +08:00
hzm
063be7d08e fix:修复移动端drawer 自动滚动到顶部的行为 2026-03-12 08:45:39 +08:00
hzm
613b5f02e8 feat:分组统计适配小屏场景 2026-03-11 22:08:44 +08:00
hzm
643b23b97c feat:移动端基金详情亮色主题兼容 2026-03-11 21:42:22 +08:00
hzm
32df6fc196 feat:移动端 drawer 背景色调整 2026-03-11 21:09:45 +08:00
hzm
8c55e97d9c Revert "fix: 弹框居中写法调整,增强兼容性"
This reverts commit 5293a32748.
2026-03-11 14:13:09 +08:00
hzm0321
efe61a825a Merge pull request #61 from hzm0321/develop
fix: 弹框居中写法调整,增强兼容性
2026-03-11 14:10:19 +08:00
黄振敏
5293a32748 fix: 弹框居中写法调整,增强兼容性 2026-03-11 11:48:53 +08:00
hzm
c28dd2d278 feat:新增同步按钮 2026-03-11 08:46:49 +08:00
hzm
6a719fad1e feat:发布 0.2.4 2026-03-11 08:35:04 +08:00
hzm
c10c4a5d0e feat:添加基金到分组弹框增加持仓金额显示 2026-03-11 08:22:09 +08:00
hzm
bcfbc2bcde feat:为减轻服务器压力,临时关闭实时订阅 user 表 2026-03-10 23:26:31 +08:00
hzm
5200b9292b feat:基金详情支持编辑持仓金额 2026-03-10 23:15:47 +08:00
hzm
1e081167b3 feat:持有相关数据隐藏完善 2026-03-10 21:31:41 +08:00
hzm
f11fc46bce feat: supabase 说明更新 2026-03-10 14:47:55 +08:00
hzm
fb7c852705 feat:取消估值值变化触发的同步 2026-03-10 13:07:58 +08:00
hzm
391c631ccb feat:更新开发群聊二维码 2026-03-10 08:28:42 +08:00
hzm
79b0100d98 feat:调整新建分组弹框样式 2026-03-10 08:20:31 +08:00
hzm
be91fad303 feat:新增安卓 pwa 支持 2026-03-10 07:34:04 +08:00
hzm
3530a8eeb2 Merge remote-tracking branch 'origin/main' 2026-03-10 07:21:08 +08:00
hzm
4dc0988197 fix:警告提示文字颜色修复 2026-03-10 07:20:44 +08:00
hzm0321
f3be5e759e Add Star History section to README
Added Star History section with dynamic chart images.
2026-03-09 22:57:13 +08:00
hzm
11bb886209 feat:修改公告样式 2026-03-09 22:52:53 +08:00
37 changed files with 1677 additions and 598 deletions

View File

@@ -5,6 +5,16 @@
1. [https://hzm0321.github.io/real-time-fund/](https://hzm0321.github.io/real-time-fund/)
2. [https://fund.cc.cd/](https://fund.cc.cd/) (加速国内访问)
## Star History
<a href="https://www.star-history.com/?repos=hzm0321%2Freal-time-fund&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=hzm0321/real-time-fund&type=date&legend=top-left" />
</picture>
</a>
## ✨ 特性
- **实时估值**:通过输入基金编号,实时获取并展示基金的单位净值、估值净值及实时涨跌幅。
@@ -79,7 +89,11 @@
官方验证码位数默认为8位可自行修改。常见一般为6位。
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email → Minimum password length 和 Email OTP Length 都改为6位。
5. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
5. 关闭确认邮件
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
更多 Supabase 相关内容查阅官方文档。
@@ -128,7 +142,7 @@ docker compose up -d
欢迎基金实时开发者加入微信群聊讨论开发与协作:
微信开发群人数已满200如需加入请加微信号 `hzm1998hzm` 。加v备注`基估宝开发`,邀请入群。
<img src="./doc/weChatGroupDevelop.jpg" width="300">
## 📝 免责声明

View File

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

View File

@@ -1,10 +1,15 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon } from './Icons';
import { fetchSmartFundNetValue } from '../api/fund';
import { DatePicker } from './Common';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export default function AddHistoryModal({ fund, onClose, onConfirm }) {
const [type, setType] = useState('');
@@ -77,30 +82,36 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
onClose();
};
const handleOpenChange = (open) => {
if (!open) {
onClose?.();
}
};
const handleCloseClick = (event) => {
event.stopPropagation();
onClose?.();
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="添加历史记录"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 1200 }}
>
<motion.div
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="glass card modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
style={{ maxWidth: '420px' }}
onClick={(e) => e.stopPropagation()}
overlayClassName="modal-overlay"
overlayStyle={{ zIndex: 9998 }}
style={{ maxWidth: '420px', zIndex: 9999, width: '90vw' }}
>
<DialogTitle className="sr-only">添加历史记录</DialogTitle>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<span>添加历史记录</span>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon />
<button
className="icon-button"
onClick={handleCloseClick}
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
@@ -200,15 +211,18 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
*此处补录的买入/卖出仅作记录展示不会改变当前持仓金额与份额实际持仓请在持仓设置中维护
</div>
<button
className="button primary full-width"
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="button"
variant="default"
size="lg"
onClick={handleSubmit}
disabled={!type || !date || !netValue || !amount || !share || loading}
>
确认添加
</button>
</motion.div>
</motion.div>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v14';
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v15';
export default function Announcement() {
const [isVisible, setIsVisible] = useState(false);
@@ -75,11 +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.3 版本更新内容如下</p>
<p>1. 二次确认弹框层级问题修复</p>
<p>2. 净值列新增日期</p>
<p>3. 重发微信用户支持群二维码(底部提交反馈处)</p>
<p>用户支持群禁止讨论基金及金融买卖相关内容</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' }}>

View File

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

View File

@@ -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,30 +174,28 @@ 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()}
overlayClassName="modal-overlay"
style={{
maxWidth: '420px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
zIndex: 999,
width: '90vw',
}}
>
<DialogTitle className="sr-only">定投设置</DialogTitle>
<div
className="scrollbar-y-styled"
style={{
@@ -376,8 +378,8 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
</button>
</div>
</div>
</motion.div>
</motion.div>
</DialogContent>
</Dialog>
);
}

View File

@@ -34,6 +34,17 @@ const getBrowserTimeZone = () => {
const TZ = getBrowserTimeZone();
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
const formatDisplayDate = (value) => {
if (!value) return '-';
const d = toTz(value);
if (!d.isValid()) return value;
const hasTime = /[T\s]\d{2}:\d{2}/.test(String(value));
return hasTime ? d.format('MM-DD HH:mm') : d.format('MM-DD');
};
export default function FundCard({
fund: f,
todayStr,
@@ -59,6 +70,7 @@ export default function FundCard({
onToggleCollapse,
onToggleTrendCollapse,
layoutMode = 'card', // 'card' | 'drawer'drawer 时前10重仓与业绩走势以 Tabs 展示
masked = false,
}) {
const holding = holdings[f?.code];
const profit = getHoldingProfit?.(f, holding) ?? null;
@@ -69,7 +81,7 @@ export default function FundCard({
boxShadow: 'none',
paddingLeft: 0,
paddingRight: 0,
background: 'transparent',
background: theme === 'light' ? 'rgb(250,250,250)' : 'none',
} : {};
return (
@@ -90,6 +102,7 @@ export default function FundCard({
e.stopPropagation();
onRemoveFromGroup?.(f.code);
}}
style={{backgroundColor: 'transparent'}}
title="从当前分组移除"
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
@@ -124,7 +137,11 @@ export default function FundCard({
<div className="actions">
<div className="badge-v">
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
<strong>
{f.noValuation
? formatDisplayDate(f.jzrq)
: formatDisplayDate(f.gztime || f.time)}
</strong>
</div>
<div className="row" style={{ gap: 4 }}>
<button
@@ -226,27 +243,29 @@ export default function FundCard({
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
cursor: 'pointer',
}}
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
onClick={() => onHoldingClick?.(f)}
>
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
未设置 <SettingsIcon width="12" height="12" />
</div>
</div>
) : (
<>
<div
className="stat"
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
onClick={() => onActionClick?.(f)}
>
<span
className="label"
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
</span>
<span className="value">
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
</span>
<span className="value">¥{profit.amount.toFixed(2)}</span>
</div>
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">当日收益</span>
@@ -262,7 +281,9 @@ export default function FundCard({
}`}
>
{profit.profitToday != null
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
? masked
? '******'
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
: '--'}
</span>
</div>
@@ -288,6 +309,9 @@ export default function FundCard({
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
}`}
>
{masked
? '******'
: <>
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
{percentModes?.[f.code]
? `${Math.abs(
@@ -296,6 +320,7 @@ export default function FundCard({
: 0,
).toFixed(2)}%`
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
</>}
</span>
</div>
)}

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion';
import { AnimatePresence, Reorder } from 'framer-motion';
import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
@@ -56,33 +57,27 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
const isAllValid = items.every(it => it.name.trim() !== '');
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="管理分组"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<>
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
<DialogContent
className="glass card modal"
style={{ maxWidth: '500px', width: '90vw' }}
onClick={(e) => e.stopPropagation()}
overlayClassName="modal-overlay"
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogTitle asChild>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>管理分组</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
</DialogTitle>
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
{items.length === 0 ? (
@@ -178,7 +173,8 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
完成
</button>
</div>
</motion.div>
</DialogContent>
</Dialog>
<AnimatePresence>
{deleteConfirm && (
@@ -190,6 +186,6 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
/>
)}
</AnimatePresence>
</motion.div>
</>
);
}

View File

@@ -1,61 +1,81 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon, PlusIcon } from './Icons';
import { Dialog, DialogContent, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Field, FieldLabel, FieldContent } from '@/components/ui/field';
import { PlusIcon, CloseIcon } from './Icons';
import { cn } from '@/lib/utils';
export default function GroupModal({ onClose, onConfirm }) {
const [name, setName] = useState('');
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="新增分组"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose?.();
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
<DialogContent
overlayClassName="modal-overlay z-[9999]"
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<PlusIcon width="20" height="20" />
<span>新增分组</span>
<div className="glass card modal !max-w-[280px] !w-full">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<PlusIcon className="w-5 h-5 shrink-0 text-[var(--foreground)]" aria-hidden />
<DialogTitle asChild>
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
</DialogTitle>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="form-group" style={{ marginBottom: 20 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称最多 8 个字</label>
<Field className="mb-5">
<FieldLabel htmlFor="group-modal-name" className="text-sm text-[var(--muted-foreground)] mb-2 block">
分组名称最多 8 个字
</FieldLabel>
<FieldContent>
<input
className="input"
id="group-modal-name"
className={cn(
'flex h-11 w-full rounded-xl border border-[var(--border)] bg-[var(--input)] px-3.5 py-2 text-sm text-[var(--foreground)] outline-none',
'placeholder:text-[var(--muted-foreground)]',
'transition-colors duration-200 focus:border-[var(--ring)] focus:ring-2 focus:ring-[var(--ring)]/20 focus:ring-offset-2 focus:ring-offset-[var(--card)]',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
autoFocus
placeholder="请输入分组名称..."
value={name}
onChange={(e) => {
const v = e.target.value || '';
// 限制最多 8 个字符(兼容中英文),超出部分自动截断
setName(v.slice(0, 8));
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) onConfirm(name.trim());
}}
/>
</FieldContent>
</Field>
<div className="flex gap-3">
<Button
variant="secondary"
className="flex-1 h-11 rounded-xl cursor-pointer bg-[var(--secondary)] text-[var(--foreground)] hover:bg-[var(--secondary)]/80 border border-[var(--border)]"
onClick={onClose}
>
取消
</Button>
<Button
className="flex-1 h-11 rounded-xl cursor-pointer"
onClick={() => name.trim() && onConfirm(name.trim())}
disabled={!name.trim()}
>
确定
</Button>
</div>
<div className="row" style={{ gap: 12 }}>
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button className="button" onClick={() => name.trim() && onConfirm(name.trim())} disabled={!name.trim()} style={{ flex: 1 }}>确定</button>
</div>
</motion.div>
</motion.div>
</DialogContent>
</Dialog>
);
}

View File

@@ -56,9 +56,11 @@ export default function GroupSummary({
groupName,
getProfit,
stickyTop,
masked,
onToggleMasked,
}) {
const [showPercent, setShowPercent] = useState(true);
const [isMasked, setIsMasked] = useState(false);
const [isMasked, setIsMasked] = useState(masked ?? false);
const [isSticky, setIsSticky] = useState(false);
const rowRef = useRef(null);
const [assetSize, setAssetSize] = useState(24);
@@ -74,6 +76,31 @@ export default function GroupSummary({
}
}, []);
// 根据窗口宽度设置基础字号,保证小屏数字不会撑破布局
useEffect(() => {
if (!winW) return;
if (winW <= 360) {
setAssetSize(18);
setMetricSize(14);
} else if (winW <= 414) {
setAssetSize(22);
setMetricSize(16);
} else if (winW <= 768) {
setAssetSize(24);
setMetricSize(18);
} else {
setAssetSize(26);
setMetricSize(20);
}
}, [winW]);
useEffect(() => {
if (typeof masked === 'boolean') {
setIsMasked(masked);
}
}, [masked]);
const summary = useMemo(() => {
let totalAsset = 0;
let totalProfitToday = 0;
@@ -185,7 +212,13 @@ export default function GroupSummary({
</div>
<button
className="fav-button"
onClick={() => setIsMasked((value) => !value)}
onClick={() => {
if (onToggleMasked) {
onToggleMasked();
} else {
setIsMasked((value) => !value);
}
}}
aria-label={isMasked ? '显示资产' : '隐藏资产'}
style={{
margin: 0,
@@ -211,6 +244,7 @@ export default function GroupSummary({
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
{isMasked ? (
<span
className="mask-text"
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
>
******
@@ -245,7 +279,9 @@ export default function GroupSummary({
}}
>
{isMasked ? (
<span style={{ fontSize: metricSize }}>******</span>
<span className="mask-text" style={{ fontSize: metricSize }}>
******
</span>
) : summary.hasAnyTodayData ? (
<>
<span style={{ marginRight: 1 }}>
@@ -298,7 +334,9 @@ export default function GroupSummary({
title="点击切换金额/百分比"
>
{isMasked ? (
<span style={{ fontSize: metricSize }}>******</span>
<span className="mask-text" style={{ fontSize: metricSize }}>
******
</span>
) : (
<>
<span style={{ marginRight: 1 }}>

View File

@@ -1,47 +1,44 @@
'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"
className="button secondary"
onClick={() => onAction('history')}
style={{
marginLeft: 8,
padding: '4px 8px',
padding: '4px 10px',
fontSize: '12px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '4px',
color: 'var(--text)',
cursor: 'pointer',
height: '28px',
display: 'flex',
alignItems: 'center',
gap: 4
gap: 4,
}}
title="查看交易记录"
>
@@ -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>
);
}

View File

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

View File

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

View File

@@ -24,17 +24,10 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { throttle } from 'lodash';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import FitText from './FitText';
import FundCard from './FundCard';
import MobileFundCardDrawer from './MobileFundCardDrawer';
import MobileSettingModal from './MobileSettingModal';
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
const MOBILE_NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent',
@@ -108,6 +101,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 = [],
@@ -126,6 +120,7 @@ export default function MobileFundTable({
getFundCardProps,
blockDrawerClose = false,
closeDrawerRef,
masked = false,
}) {
const [isNameSortMode, setIsNameSortMode] = useState(false);
@@ -559,7 +554,7 @@ export default function MobileFundTable({
}
}}
>
{holdingAmountDisplay}
{masked ? <span className="mask-text">******</span> : holdingAmountDisplay}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>}
</span>
@@ -665,6 +660,7 @@ export default function MobileFundTable({
cell: (info) => {
const original = info.row.original || {};
const date = original.latestNavDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
@@ -672,7 +668,7 @@ export default function MobileFundTable({
{info.getValue() ?? '—'}
</FitText>
</span>
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
</div>
);
},
@@ -685,15 +681,19 @@ export default function MobileFundTable({
const original = info.row.original || {};
const date = original.estimateNavDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
const estimateNav = info.getValue();
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
{estimateNav ?? '—'}
</FitText>
</span>
{hasEstimateNav && displayDate && displayDate !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
) : null}
</div>
);
},
@@ -706,13 +706,14 @@ export default function MobileFundTable({
const original = info.row.original || {};
const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-';
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
</span>
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
</div>
);
},
@@ -728,12 +729,16 @@ export default function MobileFundTable({
const time = original.estimateTime ?? '-';
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
const text = info.getValue();
const hasText = text != null && text !== '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
{text ?? '—'}
</span>
{hasText && displayTime && displayTime !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
) : null}
</div>
);
},
@@ -754,10 +759,10 @@ export default function MobileFundTable({
<div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText>
</span>
{percentStr ? (
{hasProfit && percentStr && !masked ? (
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -784,10 +789,10 @@ export default function MobileFundTable({
<div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText>
</span>
{percentStr && !isUpdated ? (
{percentStr && !isUpdated && !masked ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -813,10 +818,10 @@ export default function MobileFundTable({
<div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
</FitText>
</span>
{percentStr ? (
{percentStr && !masked ? (
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -1080,51 +1085,14 @@ export default function MobileFundTable({
/>
)}
<Drawer
<MobileFundCardDrawer
open={!!(cardSheetRow && getFundCardProps)}
onOpenChange={(open) => {
if (!open) {
if (ignoreNextDrawerCloseRef.current) {
ignoreNextDrawerCloseRef.current = false;
return;
}
if (!blockDrawerClose) setCardSheetRow(null);
}
}}
>
<DrawerContent
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
onPointerDownOutside={(e) => {
if (blockDrawerClose) return;
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
ignoreNextDrawerCloseRef.current = true;
return;
}
setCardSheetRow(null);
}}
>
<DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
>
{cardSheetRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardSheetRow)} />
) : null}
</div>
</DrawerContent>
</Drawer>
onOpenChange={(open) => { if (!open) setCardSheetRow(null); }}
blockDrawerClose={blockDrawerClose}
ignoreNextDrawerCloseRef={ignoreNextDrawerCloseRef}
cardSheetRow={cardSheetRow}
getFundCardProps={getFundCardProps}
/>
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
</div>

View File

@@ -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, {
@@ -568,7 +570,8 @@ export default function PcFundTable({
minSize: 80,
cell: (info) => {
const original = info.row.original || {};
const date = original.latestNavDate ?? '-';
const rawDate = original.latestNavDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
@@ -592,15 +595,20 @@ export default function PcFundTable({
minSize: 80,
cell: (info) => {
const original = info.row.original || {};
const date = original.estimateNavDate ?? '-';
const rawDate = original.estimateNavDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
const estimateNav = info.getValue();
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'}
{estimateNav ?? '—'}
</FitText>
{hasEstimateNav && date && date !== '-' ? (
<span className="muted" style={{ fontSize: '11px' }}>
{date}
</span>
) : null}
</div>
);
},
@@ -617,7 +625,8 @@ export default function PcFundTable({
cell: (info) => {
const original = info.row.original || {};
const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-';
const rawDate = original.yesterdayDate ?? '-';
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
@@ -644,16 +653,21 @@ export default function PcFundTable({
const original = info.row.original || {};
const value = original.estimateChangeValue;
const isMuted = original.estimateChangeMuted;
const time = original.estimateTime ?? '-';
const rawTime = original.estimateTime ?? '-';
const time = typeof rawTime === 'string' && rawTime.length > 5 ? rawTime.slice(5) : rawTime;
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
const text = info.getValue();
const hasText = text != null && text !== '—';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'}
{text ?? '—'}
</FitText>
{hasText && time && time !== '-' ? (
<span className="muted" style={{ fontSize: '11px' }}>
{time}
</span>
) : null}
</div>
);
},
@@ -678,9 +692,9 @@ export default function PcFundTable({
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText>
{percentStr ? (
{hasProfit && percentStr && !masked ? (
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -736,7 +750,7 @@ export default function PcFundTable({
>
<div style={{ flex: '1 1 0', minWidth: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
{masked ? <span className="mask-text">******</span> : (info.getValue() ?? '—')}
</FitText>
</div>
<button
@@ -774,9 +788,9 @@ export default function PcFundTable({
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
</FitText>
{percentStr && !isUpdated ? (
{percentStr && !isUpdated && !masked ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -806,9 +820,9 @@ export default function PcFundTable({
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
</FitText>
{percentStr ? (
{percentStr && !masked ? (
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
@@ -881,7 +895,7 @@ export default function PcFundTable({
},
},
],
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps],
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked],
);
const table = useReactTable({
@@ -1165,22 +1179,12 @@ export default function PcFundTable({
>
<DialogContent
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
showCloseButton={false}
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-6 pb-4 pt-6 text-left border-b border-[var(--border)]">
<DialogTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DialogTitle>
<button
type="button"
className="icon-button rounded-lg"
aria-label="关闭"
onClick={() => setCardDialogRow(null)}
style={{ padding: 4, borderColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</DialogHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"

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

View 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;
}

View 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;
}

View File

@@ -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' }}
>
&lt; 返回
</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>
);
}

View File

@@ -1,9 +1,15 @@
'use client';
import { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CloseIcon } from './Icons';
import ConfirmModal from './ConfirmModal';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
export default function TransactionHistoryModal({
fund,
@@ -12,7 +18,7 @@ export default function TransactionHistoryModal({
onClose,
onDeleteTransaction,
onDeletePending,
onAddHistory
onAddHistory,
}) {
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
@@ -39,31 +45,46 @@ export default function TransactionHistoryModal({
setDeleteConfirm(null);
};
const handleCloseClick = (event) => {
// 只关闭交易记录弹框,避免事件冒泡影响到其他弹框(例如 HoldingActionModal
event.stopPropagation();
onClose?.();
};
const handleOpenChange = (open) => {
if (!open) {
onClose?.();
}
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 1100 }} // Higher than TradeModal if stacked, but usually TradeModal closes or this opens on top
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="glass card modal tx-history-modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
overlayClassName="modal-overlay"
overlayStyle={{ zIndex: 998 }}
style={{
maxWidth: '480px',
width: '90vw',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
zIndex: 999, // 保持原有层级,确保在其他弹框之上
}}
>
<DialogTitle className="sr-only">交易记录</DialogTitle>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '20px' }}>📜</span>
<span>交易记录</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<button
className="icon-button"
onClick={handleCloseClick}
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
@@ -76,7 +97,7 @@ export default function TransactionHistoryModal({
<button
className="button primary"
onClick={onAddHistory}
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto', width: '80px' }}
>
添加记录
</button>
@@ -108,13 +129,16 @@ export default function TransactionHistoryModal({
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span className="tx-history-pending-status">等待净值更新...</span>
<button
className="button secondary tx-history-action-btn"
<Button
type="button"
size="xs"
variant="destructive"
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => handleDeleteClick(item, 'pending')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
style={{ paddingInline: 10 }}
>
撤销
</button>
</Button>
</div>
</div>
))}
@@ -158,13 +182,16 @@ export default function TransactionHistoryModal({
)}
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span className="muted"></span>
<button
className="button secondary tx-history-action-btn"
<Button
type="button"
size="xs"
variant="destructive"
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => handleDeleteClick(item, 'history')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
style={{ paddingInline: 10 }}
>
删除记录
</button>
</Button>
</div>
</div>
))
@@ -172,22 +199,21 @@ export default function TransactionHistoryModal({
</div>
</div>
</motion.div>
<AnimatePresence>
{deleteConfirm && (
<ConfirmModal
key="delete-confirm"
title={deleteConfirm.type === 'pending' ? "撤销交易" : "删除记录"}
title={deleteConfirm.type === 'pending' ? '撤销交易' : '删除记录'}
message={deleteConfirm.type === 'pending'
? "确定要撤销这笔待处理交易吗?"
: "确定要删除这条交易记录吗?\n注意删除记录不会恢复已变更的持仓数据。"}
? '确定要撤销这笔待处理交易吗?'
: '确定要删除这条交易记录吗?\n注意删除记录不会恢复已变更的持仓数据。'}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteConfirm(null)}
confirmText="确认删除"
/>
)}
</AnimatePresence>
</motion.div>
</DialogContent>
</Dialog>
);
}

View File

@@ -36,7 +36,6 @@ export default function WeChatModal({ onClose }) {
</div>
<div
className="trade-pending-alert"
onClick={() => setShowPendingList(true)}
>
<span> 入群须知禁止讨论和基金买卖以及投资的有关内容可反馈软件相关需求和问题</span>
</div>

View File

@@ -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;
@@ -106,8 +106,10 @@
html,
body {
overscroll-behavior-y: none;
height: 100%;
overflow-x: clip;
will-change: auto; /* 或者移除任何 will-change: transform */
}
body {
@@ -1023,6 +1025,12 @@ input[type="number"] {
color: var(--success);
}
.mask-text,
.up .mask-text,
.down .mask-text {
color: var(--text) !important;
}
.list {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -2076,6 +2084,21 @@ input[type="number"] {
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
}
/* Drawer 内容玻璃拟态:与 Dialog 统一的毛玻璃效果(更通透) */
.drawer-content-theme {
background: rgba(15, 23, 42, 0);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-color: rgba(148, 163, 184, 0.45);
}
[data-theme="light"] .drawer-content-theme {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-color: rgba(148, 163, 184, 0.6);
}
/* shadcn Dialog符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
[data-slot="dialog-content"] {
backdrop-filter: blur(8px);
@@ -2270,6 +2293,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;
@@ -2310,6 +2337,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);
@@ -3449,7 +3483,7 @@ input[type="number"] {
--accent-foreground: #f8fafc;
--destructive: #f87171;
--border: #1f2937;
--input: #1e293b;
--input: #0b1220;
--ring: #22d3ee;
--chart-1: #22d3ee;
--chart-2: #60a5fa;

View File

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

View File

@@ -1,6 +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 = {
@@ -19,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
@@ -28,6 +33,8 @@ export default function RootLayout({ children }) {
/>
</head>
<body>
<ThemeColorSync />
<PwaRegister />
<AnalyticsGate GA_ID={GA_ID} />
{children}
<Toaster />

View File

@@ -185,6 +185,8 @@ export default function HomePage() {
// 视图模式
const [viewMode, setViewMode] = useState('card'); // card, list
// 全局隐藏金额状态(影响分组汇总、列表和卡片)
const [maskAmounts, setMaskAmounts] = useState(false);
// 用户认证状态
const [user, setUser] = useState(null);
@@ -1430,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) => {
@@ -2081,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();
@@ -3354,13 +3356,13 @@ export default function HomePage() {
isScanImporting;
if (isAnyModalOpen) {
document.body.style.overflow = 'hidden';
containerRef.current.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
containerRef.current.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
containerRef.current.style.overflow = '';
};
}, [
settingsOpen,
@@ -3714,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={() => {
@@ -3926,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' && (
@@ -4020,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 {};
@@ -4048,6 +4073,7 @@ export default function HomePage() {
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
onToggleCollapse: toggleCollapse,
onToggleTrendCollapse: toggleTrendCollapse,
masked: maskAmounts,
layoutMode: 'drawer',
};
}}
@@ -4123,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">
@@ -4166,6 +4194,7 @@ export default function HomePage() {
}
onToggleCollapse={toggleCollapse}
onToggleTrendCollapse={toggleTrendCollapse}
masked={maskAmounts}
/>
</motion.div>
))}
@@ -4291,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
View 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 }

View File

@@ -5,11 +5,39 @@ import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import {CloseIcon} from "@/app/components/Icons";
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock";
function Dialog({
open: openProp,
defaultOpen,
onOpenChange,
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isControlled = openProp !== undefined;
const currentOpen = isControlled ? openProp : uncontrolledOpen;
// 使用全局 hook 统一处理 body 滚动锁定 & 恢复,避免弹窗打开时页面跳到顶部
useBodyScrollLock(currentOpen);
const handleOpenChange = React.useCallback(
(next) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return (
<DialogPrimitive.Root
data-slot="dialog"
open={isControlled ? openProp : undefined}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
{...props}
/>
);
}
function DialogTrigger({
@@ -50,15 +78,17 @@ function DialogContent({
children,
showCloseButton = true,
overlayClassName,
overlayStyle,
...props
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogOverlay className={overlayClassName} style={overlayStyle} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"mobile-dialog-glass",
className
)}
{...props}>
@@ -67,7 +97,7 @@ function DialogContent({
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<CloseIcon width="20" height="20" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}

View File

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

245
components/ui/field.jsx Normal file
View 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
View 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 }

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

BIN
doc/weChatGroupDevelop.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

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

84
package-lock.json generated
View File

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

View File

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

View 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
View 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' });
})
);
});