Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c332cb89d | ||
|
|
631336097f | ||
|
|
5981440881 | ||
|
|
2816a6c0dd | ||
|
|
e1eb3ea8ca | ||
|
|
15df89a9dd | ||
|
|
8849b547ce | ||
|
|
7953b906a5 | ||
|
|
d00c8cf3eb | ||
|
|
966c853eb5 | ||
|
|
063be7d08e | ||
|
|
613b5f02e8 | ||
|
|
643b23b97c | ||
|
|
32df6fc196 | ||
|
|
8c55e97d9c | ||
|
|
efe61a825a | ||
|
|
5293a32748 |
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
||||||
|
|
||||||
6. 目前项目用到的 sql 语句,查看项目 supabase.sql 文件。
|
6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
|
||||||
|
|
||||||
更多 Supabase 相关内容查阅官方文档。
|
更多 Supabase 相关内容查阅官方文档。
|
||||||
|
|
||||||
|
|||||||
@@ -712,7 +712,7 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const parseFundTextWithLLM = async (text) => {
|
export const parseFundTextWithLLM = async (text) => {
|
||||||
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
|
const apiKey = 'sk-5b03d4e02ec22dd2ba233fb6d2dd549b';
|
||||||
if (!apiKey || !text) return null;
|
if (!apiKey || !text) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v15';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v16';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -75,15 +75,13 @@ export default function Announcement() {
|
|||||||
<span>公告</span>
|
<span>公告</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||||
<p>v0.2.4 版本更新内容如下:</p>
|
<p>v0.2.5 紧急更新:</p>
|
||||||
<p>1. 调整设置持仓相关弹框样式。</p>
|
<p>1. 修复拍照失败 key 过期问题。</p>
|
||||||
<p>2. 基金详情弹框支持设置持仓相关参数。</p>
|
<p>2. 设置持仓输入回滚问题。</p>
|
||||||
<p>3. 添加基金到分组弹框展示持仓金额数据。</p>
|
|
||||||
<p>4. 已登录用户新增手动同步按钮。</p>
|
|
||||||
<br/>
|
<br/>
|
||||||
<p>答疑:</p>
|
<p>下周更新内容:</p>
|
||||||
<p>1. 因估值数据源问题,大部分海外基金估值数据不准或没有,暂时没有解决方案。</p>
|
<p>1. 大盘数据。</p>
|
||||||
<p>2. 因交易日用户人数过多,为控制服务器免费额度上限,暂时减少数据自动同步频率,新增手动同步按钮。</p>
|
<p>2. 关联板块。</p>
|
||||||
<p>如有建议,欢迎进用户支持群反馈。</p>
|
<p>如有建议,欢迎进用户支持群反馈。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon, CloudIcon } from './Icons';
|
import { CloseIcon, CloudIcon } from './Icons';
|
||||||
|
|
||||||
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
|
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
|
||||||
|
const [pendingAction, setPendingAction] = useState(null); // 'local' | 'cloud' | null
|
||||||
const isConflict = type === 'conflict';
|
const isConflict = type === 'conflict';
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (isConflict) {
|
||||||
|
setPendingAction('local');
|
||||||
|
} else {
|
||||||
|
onConfirm?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondaryClick = () => {
|
||||||
|
if (isConflict) {
|
||||||
|
setPendingAction('cloud');
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmModalCancel = () => {
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmModalConfirm = () => {
|
||||||
|
if (pendingAction === 'local') {
|
||||||
|
onConfirm?.();
|
||||||
|
} else if (pendingAction === 'cloud') {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmTitle =
|
||||||
|
pendingAction === 'local'
|
||||||
|
? '确认使用本地配置覆盖云端?'
|
||||||
|
: '确认使用云端配置覆盖本地?';
|
||||||
|
|
||||||
|
const confirmMessage =
|
||||||
|
pendingAction === 'local'
|
||||||
|
? '此操作会将当前本地配置同步到云端,覆盖云端原有配置,且可能无法恢复,请谨慎操作。'
|
||||||
|
: '此操作会使用云端配置覆盖当前本地配置,导致本地修改丢失,且可能无法恢复,请谨慎操作。';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -41,14 +84,25 @@ export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }
|
|||||||
: '是否将本地配置同步到云端?'}
|
: '是否将本地配置同步到云端?'}
|
||||||
</p>
|
</p>
|
||||||
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
|
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
|
||||||
<button className="button" onClick={onConfirm}>
|
<button className="button secondary" onClick={handlePrimaryClick}>
|
||||||
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
|
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
|
||||||
</button>
|
</button>
|
||||||
<button className="button secondary" onClick={onCancel}>
|
<button className="button" onClick={handleSecondaryClick}>
|
||||||
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
|
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
{pendingAction && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={confirmTitle}
|
||||||
|
message={confirmMessage}
|
||||||
|
onConfirm={handleConfirmModalConfirm}
|
||||||
|
onCancel={handleConfirmModalCancel}
|
||||||
|
confirmText="确认覆盖"
|
||||||
|
icon={<CloudIcon width="20" height="20" />}
|
||||||
|
confirmVariant="danger"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ const getBrowserTimeZone = () => {
|
|||||||
const TZ = getBrowserTimeZone();
|
const TZ = getBrowserTimeZone();
|
||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
|
||||||
|
|
||||||
|
const formatDisplayDate = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
|
||||||
|
const d = toTz(value);
|
||||||
|
if (!d.isValid()) return value;
|
||||||
|
|
||||||
|
const hasTime = /[T\s]\d{2}:\d{2}/.test(String(value));
|
||||||
|
|
||||||
|
return hasTime ? d.format('MM-DD HH:mm') : d.format('MM-DD');
|
||||||
|
};
|
||||||
|
|
||||||
export default function FundCard({
|
export default function FundCard({
|
||||||
fund: f,
|
fund: f,
|
||||||
todayStr,
|
todayStr,
|
||||||
@@ -70,7 +81,7 @@ export default function FundCard({
|
|||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
paddingLeft: 0,
|
paddingLeft: 0,
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
background: 'transparent',
|
background: theme === 'light' ? 'rgb(250,250,250)' : 'none',
|
||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,6 +102,7 @@ export default function FundCard({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemoveFromGroup?.(f.code);
|
onRemoveFromGroup?.(f.code);
|
||||||
}}
|
}}
|
||||||
|
style={{backgroundColor: 'transparent'}}
|
||||||
title="从当前分组移除"
|
title="从当前分组移除"
|
||||||
>
|
>
|
||||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||||
@@ -125,7 +137,11 @@ export default function FundCard({
|
|||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="badge-v">
|
<div className="badge-v">
|
||||||
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
||||||
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
|
<strong>
|
||||||
|
{f.noValuation
|
||||||
|
? formatDisplayDate(f.jzrq)
|
||||||
|
: formatDisplayDate(f.gztime || f.time)}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 4 }}>
|
<div className="row" style={{ gap: 4 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '../../components/ui/dialog';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
|
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
|
||||||
|
|
||||||
@@ -56,129 +57,124 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
|||||||
const isAllValid = items.every(it => it.name.trim() !== '');
|
const isAllValid = items.every(it => it.name.trim() !== '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<>
|
||||||
className="modal-overlay"
|
<Dialog
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onOpenChange={(open) => {
|
||||||
aria-label="管理分组"
|
if (!open) onClose();
|
||||||
onClick={onClose}
|
}}
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="glass card modal"
|
|
||||||
style={{ maxWidth: '500px', width: '90vw' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<DialogContent
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
className="glass card modal"
|
||||||
<SettingsIcon width="20" height="20" />
|
overlayClassName="modal-overlay"
|
||||||
<span>管理分组</span>
|
style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
|
||||||
</div>
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
>
|
||||||
<CloseIcon width="20" height="20" />
|
<DialogTitle asChild>
|
||||||
</button>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<SettingsIcon width="20" height="20" />
|
||||||
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
<span>管理分组</span>
|
||||||
{items.length === 0 ? (
|
</div>
|
||||||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
|
||||||
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
|
|
||||||
<p>暂无自定义分组</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</DialogTitle>
|
||||||
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
|
|
||||||
<AnimatePresence mode="popLayout">
|
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||||||
{items.map((item) => (
|
{items.length === 0 ? (
|
||||||
<Reorder.Item
|
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
key={item.id}
|
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
|
||||||
value={item}
|
<p>暂无自定义分组</p>
|
||||||
className="group-manage-item glass"
|
</div>
|
||||||
layout
|
) : (
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<AnimatePresence mode="popLayout">
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
{items.map((item) => (
|
||||||
transition={{
|
<Reorder.Item
|
||||||
type: 'spring',
|
key={item.id}
|
||||||
stiffness: 500,
|
value={item}
|
||||||
damping: 35,
|
className="group-manage-item glass"
|
||||||
mass: 1,
|
layout
|
||||||
layout: { duration: 0.2 }
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
}}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
>
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
|
transition={{
|
||||||
<DragIcon width="18" height="18" className="muted" />
|
type: 'spring',
|
||||||
</div>
|
stiffness: 500,
|
||||||
<input
|
damping: 35,
|
||||||
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
|
mass: 1,
|
||||||
value={item.name}
|
layout: { duration: 0.2 }
|
||||||
onChange={(e) => handleRename(item.id, e.target.value)}
|
|
||||||
placeholder="请输入分组名称..."
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: '36px',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
|
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={() => handleDeleteClick(item.id, item.name)}
|
|
||||||
title="删除分组"
|
|
||||||
style={{ width: '36px', height: '36px', flexShrink: 0 }}
|
|
||||||
>
|
>
|
||||||
<TrashIcon width="16" height="16" />
|
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
|
||||||
</button>
|
<DragIcon width="18" height="18" className="muted" />
|
||||||
</Reorder.Item>
|
</div>
|
||||||
))}
|
<input
|
||||||
</AnimatePresence>
|
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
|
||||||
</Reorder.Group>
|
value={item.name}
|
||||||
)}
|
onChange={(e) => handleRename(item.id, e.target.value)}
|
||||||
<button
|
placeholder="请输入分组名称..."
|
||||||
className="add-group-row-btn"
|
style={{
|
||||||
onClick={handleAddRow}
|
flex: 1,
|
||||||
style={{
|
height: '36px',
|
||||||
width: '100%',
|
background: 'rgba(0,0,0,0.2)',
|
||||||
marginTop: 12,
|
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
|
||||||
padding: '10px',
|
}}
|
||||||
borderRadius: '12px',
|
/>
|
||||||
border: '1px dashed var(--border)',
|
<button
|
||||||
background: 'rgba(255,255,255,0.02)',
|
className="icon-button danger"
|
||||||
color: 'var(--muted)',
|
onClick={() => handleDeleteClick(item.id, item.name)}
|
||||||
fontSize: '14px',
|
title="删除分组"
|
||||||
display: 'flex',
|
style={{ width: '36px', height: '36px', flexShrink: 0 }}
|
||||||
alignItems: 'center',
|
>
|
||||||
justifyContent: 'center',
|
<TrashIcon width="16" height="16" />
|
||||||
gap: '8px',
|
</button>
|
||||||
cursor: 'pointer',
|
</Reorder.Item>
|
||||||
transition: 'all 0.2s ease'
|
))}
|
||||||
}}
|
</AnimatePresence>
|
||||||
>
|
</Reorder.Group>
|
||||||
<PlusIcon width="16" height="16" />
|
)}
|
||||||
<span>新增分组</span>
|
<button
|
||||||
</button>
|
className="add-group-row-btn"
|
||||||
</div>
|
onClick={handleAddRow}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 12,
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon width="16" height="16" />
|
||||||
|
<span>新增分组</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
{!isAllValid && (
|
{!isAllValid && (
|
||||||
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
|
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
|
||||||
所有分组名称均不能为空
|
所有分组名称均不能为空
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!isAllValid}
|
disabled={!isAllValid}
|
||||||
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
|
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
|
||||||
>
|
>
|
||||||
完成
|
完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
@@ -190,6 +186,6 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export default function GroupModal({ onClose, onConfirm }) {
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
overlayClassName="modal-overlay z-[9999]"
|
overlayClassName="modal-overlay z-[9999]"
|
||||||
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
|
className={cn('!p-0 z-[10000] max-w-[280px] sm:max-w-[280px]')}
|
||||||
showCloseButton={false}
|
|
||||||
>
|
>
|
||||||
<div className="glass card modal !max-w-[280px] !w-full">
|
<div className="glass card modal !max-w-[280px] !w-full">
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
@@ -30,16 +29,6 @@ export default function GroupModal({ onClose, onConfirm }) {
|
|||||||
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
|
<span className="text-base font-semibold text-[var(--foreground)]">新增分组</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--secondary)] transition-colors duration-200 cursor-pointer"
|
|
||||||
aria-label="关闭"
|
|
||||||
>
|
|
||||||
<CloseIcon className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field className="mb-5">
|
<Field className="mb-5">
|
||||||
|
|||||||
@@ -76,6 +76,25 @@ export default function GroupSummary({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 根据窗口宽度设置基础字号,保证小屏数字不会撑破布局
|
||||||
|
useEffect(() => {
|
||||||
|
if (!winW) return;
|
||||||
|
|
||||||
|
if (winW <= 360) {
|
||||||
|
setAssetSize(18);
|
||||||
|
setMetricSize(14);
|
||||||
|
} else if (winW <= 414) {
|
||||||
|
setAssetSize(22);
|
||||||
|
setMetricSize(16);
|
||||||
|
} else if (winW <= 768) {
|
||||||
|
setAssetSize(24);
|
||||||
|
setMetricSize(18);
|
||||||
|
} else {
|
||||||
|
setAssetSize(26);
|
||||||
|
setMetricSize(20);
|
||||||
|
}
|
||||||
|
}, [winW]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof masked === 'boolean') {
|
if (typeof masked === 'boolean') {
|
||||||
setIsMasked(masked);
|
setIsMasked(masked);
|
||||||
@@ -225,6 +244,7 @@ export default function GroupSummary({
|
|||||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||||
{isMasked ? (
|
{isMasked ? (
|
||||||
<span
|
<span
|
||||||
|
className="mask-text"
|
||||||
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||||
>
|
>
|
||||||
******
|
******
|
||||||
@@ -259,7 +279,9 @@ export default function GroupSummary({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isMasked ? (
|
{isMasked ? (
|
||||||
<span style={{ fontSize: metricSize }}>******</span>
|
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
) : summary.hasAnyTodayData ? (
|
) : summary.hasAnyTodayData ? (
|
||||||
<>
|
<>
|
||||||
<span style={{ marginRight: 1 }}>
|
<span style={{ marginRight: 1 }}>
|
||||||
@@ -312,7 +334,9 @@ export default function GroupSummary({
|
|||||||
title="点击切换金额/百分比"
|
title="点击切换金额/百分比"
|
||||||
>
|
>
|
||||||
{isMasked ? (
|
{isMasked ? (
|
||||||
<span style={{ fontSize: metricSize }}>******</span>
|
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={{ marginRight: 1 }}>
|
<span style={{ marginRight: 1 }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { CloseIcon, SettingsIcon } from './Icons';
|
import { CloseIcon, SettingsIcon } from './Icons';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -12,12 +12,21 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||||
|
|
||||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||||
|
const dwjzRef = useRef(dwjz);
|
||||||
|
useEffect(() => {
|
||||||
|
dwjzRef.current = dwjz;
|
||||||
|
}, [dwjz]);
|
||||||
|
|
||||||
const [share, setShare] = useState('');
|
const [share, setShare] = useState('');
|
||||||
const [cost, setCost] = useState('');
|
const [cost, setCost] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [profit, setProfit] = useState('');
|
const [profit, setProfit] = useState('');
|
||||||
|
|
||||||
|
const holdingSig = useMemo(() => {
|
||||||
|
if (!holding) return '';
|
||||||
|
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
|
||||||
|
}, [holding]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (holding) {
|
if (holding) {
|
||||||
const s = holding.share || 0;
|
const s = holding.share || 0;
|
||||||
@@ -25,14 +34,17 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
setShare(String(s));
|
setShare(String(s));
|
||||||
setCost(String(c));
|
setCost(String(c));
|
||||||
|
|
||||||
if (dwjz > 0) {
|
const price = dwjzRef.current;
|
||||||
const a = s * dwjz;
|
if (price > 0) {
|
||||||
const p = (dwjz - c) * s;
|
const a = s * price;
|
||||||
|
const p = (price - c) * s;
|
||||||
setAmount(a.toFixed(2));
|
setAmount(a.toFixed(2));
|
||||||
setProfit(p.toFixed(2));
|
setProfit(p.toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [holding, fund, dwjz]);
|
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [holdingSig]);
|
||||||
|
|
||||||
const handleModeChange = (newMode) => {
|
const handleModeChange = (newMode) => {
|
||||||
if (newMode === mode) return;
|
if (newMode === mode) return;
|
||||||
|
|||||||
83
app/components/MobileFundCardDrawer.jsx
Normal file
83
app/components/MobileFundCardDrawer.jsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle, DrawerTrigger,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import FundCard from './FundCard';
|
||||||
|
import { CloseIcon } from './Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端基金详情底部 Drawer 弹框
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - 是否打开
|
||||||
|
* @param {(open: boolean) => void} props.onOpenChange - 打开状态变化回调
|
||||||
|
* @param {boolean} [props.blockDrawerClose] - 是否禁止关闭(如上层有弹框时)
|
||||||
|
* @param {React.MutableRefObject<boolean>} [props.ignoreNextDrawerCloseRef] - 忽略下一次关闭(用于点击到内部 dialog 时)
|
||||||
|
* @param {Object|null} props.cardSheetRow - 当前选中的行数据,用于 getFundCardProps
|
||||||
|
* @param {(row: any) => Object} [props.getFundCardProps] - 根据行数据返回 FundCard 的 props
|
||||||
|
*/
|
||||||
|
export default function MobileFundCardDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
blockDrawerClose = false,
|
||||||
|
ignoreNextDrawerCloseRef,
|
||||||
|
cardSheetRow,
|
||||||
|
getFundCardProps,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
if (ignoreNextDrawerCloseRef?.current) {
|
||||||
|
ignoreNextDrawerCloseRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!blockDrawerClose) onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{children}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent
|
||||||
|
className="h-[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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,17 +24,10 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
} from '@/components/ui/drawer';
|
|
||||||
import FitText from './FitText';
|
import FitText from './FitText';
|
||||||
import FundCard from './FundCard';
|
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
||||||
import MobileSettingModal from './MobileSettingModal';
|
import MobileSettingModal from './MobileSettingModal';
|
||||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||||
|
|
||||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
@@ -561,7 +554,7 @@ export default function MobileFundTable({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{masked ? '******' : holdingAmountDisplay}
|
{masked ? <span className="mask-text">******</span> : holdingAmountDisplay}
|
||||||
{hasDca && <span className="dca-indicator">定</span>}
|
{hasDca && <span className="dca-indicator">定</span>}
|
||||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -667,6 +660,7 @@ export default function MobileFundTable({
|
|||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const date = original.latestNavDate ?? '-';
|
const date = original.latestNavDate ?? '-';
|
||||||
|
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
@@ -674,7 +668,7 @@ export default function MobileFundTable({
|
|||||||
{info.getValue() ?? '—'}
|
{info.getValue() ?? '—'}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -687,15 +681,19 @@ export default function MobileFundTable({
|
|||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const date = original.estimateNavDate ?? '-';
|
const date = original.estimateNavDate ?? '-';
|
||||||
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||||
|
const estimateNav = info.getValue();
|
||||||
|
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
{info.getValue() ?? '—'}
|
{estimateNav ?? '—'}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
{hasEstimateNav && displayDate && displayDate !== '-' ? (
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -708,13 +706,14 @@ export default function MobileFundTable({
|
|||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const value = original.yesterdayChangeValue;
|
const value = original.yesterdayChangeValue;
|
||||||
const date = original.yesterdayDate ?? '-';
|
const date = original.yesterdayDate ?? '-';
|
||||||
|
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||||
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<span className={cls} style={{ fontWeight: 700 }}>
|
<span className={cls} style={{ fontWeight: 700 }}>
|
||||||
{info.getValue() ?? '—'}
|
{info.getValue() ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -730,12 +729,16 @@ export default function MobileFundTable({
|
|||||||
const time = original.estimateTime ?? '-';
|
const time = original.estimateTime ?? '-';
|
||||||
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
|
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
|
||||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
const text = info.getValue();
|
||||||
|
const hasText = text != null && text !== '—';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<span className={cls} style={{ fontWeight: 700 }}>
|
<span className={cls} style={{ fontWeight: 700 }}>
|
||||||
{info.getValue() ?? '—'}
|
{text ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
|
{hasText && displayTime && displayTime !== '-' ? (
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -756,10 +759,10 @@ export default function MobileFundTable({
|
|||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasProfit ? '******' : amountStr}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
{percentStr && !masked ? (
|
{hasProfit && percentStr && !masked ? (
|
||||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
<FitText maxFontSize={11} minFontSize={9}>
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
{percentStr}
|
{percentStr}
|
||||||
@@ -786,7 +789,7 @@ export default function MobileFundTable({
|
|||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasProfit ? '******' : amountStr}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
{percentStr && !isUpdated && !masked ? (
|
{percentStr && !isUpdated && !masked ? (
|
||||||
@@ -815,7 +818,7 @@ export default function MobileFundTable({
|
|||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasTotal ? '******' : amountStr}
|
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
{percentStr && !masked ? (
|
{percentStr && !masked ? (
|
||||||
@@ -1082,51 +1085,14 @@ export default function MobileFundTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Drawer
|
<MobileFundCardDrawer
|
||||||
open={!!(cardSheetRow && getFundCardProps)}
|
open={!!(cardSheetRow && getFundCardProps)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => { if (!open) setCardSheetRow(null); }}
|
||||||
if (!open) {
|
blockDrawerClose={blockDrawerClose}
|
||||||
if (ignoreNextDrawerCloseRef.current) {
|
ignoreNextDrawerCloseRef={ignoreNextDrawerCloseRef}
|
||||||
ignoreNextDrawerCloseRef.current = false;
|
cardSheetRow={cardSheetRow}
|
||||||
return;
|
getFundCardProps={getFundCardProps}
|
||||||
}
|
/>
|
||||||
if (!blockDrawerClose) setCardSheetRow(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerContent
|
|
||||||
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
|
|
||||||
onPointerDownOutside={(e) => {
|
|
||||||
if (blockDrawerClose) return;
|
|
||||||
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
|
||||||
ignoreNextDrawerCloseRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCardSheetRow(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
|
|
||||||
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
|
|
||||||
基金详情
|
|
||||||
</DrawerTitle>
|
|
||||||
<DrawerClose
|
|
||||||
className="icon-button border-none bg-transparent p-1"
|
|
||||||
title="关闭"
|
|
||||||
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
<CloseIcon width="20" height="20" />
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerHeader>
|
|
||||||
<div
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
|
|
||||||
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
|
|
||||||
>
|
|
||||||
{cardSheetRow && getFundCardProps ? (
|
|
||||||
<FundCard {...getFundCardProps(cardSheetRow)} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -570,7 +570,8 @@ export default function PcFundTable({
|
|||||||
minSize: 80,
|
minSize: 80,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const date = original.latestNavDate ?? '-';
|
const rawDate = original.latestNavDate ?? '-';
|
||||||
|
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
@@ -594,15 +595,20 @@ export default function PcFundTable({
|
|||||||
minSize: 80,
|
minSize: 80,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const date = original.estimateNavDate ?? '-';
|
const rawDate = original.estimateNavDate ?? '-';
|
||||||
|
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
|
||||||
|
const estimateNav = info.getValue();
|
||||||
|
const hasEstimateNav = estimateNav != null && estimateNav !== '—';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
{info.getValue() ?? '—'}
|
{estimateNav ?? '—'}
|
||||||
</FitText>
|
</FitText>
|
||||||
<span className="muted" style={{ fontSize: '11px' }}>
|
{hasEstimateNav && date && date !== '-' ? (
|
||||||
{date}
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
</span>
|
{date}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -619,7 +625,8 @@ export default function PcFundTable({
|
|||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const value = original.yesterdayChangeValue;
|
const value = original.yesterdayChangeValue;
|
||||||
const date = original.yesterdayDate ?? '-';
|
const rawDate = original.yesterdayDate ?? '-';
|
||||||
|
const date = typeof rawDate === 'string' && rawDate.length > 5 ? rawDate.slice(5) : rawDate;
|
||||||
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
@@ -646,16 +653,21 @@ export default function PcFundTable({
|
|||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const value = original.estimateChangeValue;
|
const value = original.estimateChangeValue;
|
||||||
const isMuted = original.estimateChangeMuted;
|
const isMuted = original.estimateChangeMuted;
|
||||||
const time = original.estimateTime ?? '-';
|
const rawTime = original.estimateTime ?? '-';
|
||||||
|
const time = typeof rawTime === 'string' && rawTime.length > 5 ? rawTime.slice(5) : rawTime;
|
||||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||||
|
const text = info.getValue();
|
||||||
|
const hasText = text != null && text !== '—';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
{info.getValue() ?? '—'}
|
{text ?? '—'}
|
||||||
</FitText>
|
</FitText>
|
||||||
<span className="muted" style={{ fontSize: '11px' }}>
|
{hasText && time && time !== '-' ? (
|
||||||
{time}
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
</span>
|
{time}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -680,9 +692,9 @@ export default function PcFundTable({
|
|||||||
return (
|
return (
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasProfit ? '******' : amountStr}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
{percentStr && !masked ? (
|
{hasProfit && percentStr && !masked ? (
|
||||||
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
<FitText maxFontSize={11} minFontSize={9}>
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
{percentStr}
|
{percentStr}
|
||||||
@@ -738,7 +750,7 @@ export default function PcFundTable({
|
|||||||
>
|
>
|
||||||
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||||
{masked ? '******' : (info.getValue() ?? '—')}
|
{masked ? <span className="mask-text">******</span> : (info.getValue() ?? '—')}
|
||||||
</FitText>
|
</FitText>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -776,7 +788,7 @@ export default function PcFundTable({
|
|||||||
return (
|
return (
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasProfit ? '******' : amountStr}
|
{masked && hasProfit ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
{percentStr && !isUpdated && !masked ? (
|
{percentStr && !isUpdated && !masked ? (
|
||||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
@@ -808,7 +820,7 @@ export default function PcFundTable({
|
|||||||
return (
|
return (
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
{masked && hasTotal ? '******' : amountStr}
|
{masked && hasTotal ? <span className="mask-text">******</span> : amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
{percentStr && !masked ? (
|
{percentStr && !masked ? (
|
||||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
@@ -883,7 +895,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps],
|
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -1167,22 +1179,12 @@ export default function PcFundTable({
|
|||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
|
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
|
||||||
showCloseButton={false}
|
|
||||||
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
|
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)]">
|
<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 className="text-base font-semibold text-[var(--text)]">
|
||||||
基金详情
|
基金详情
|
||||||
</DialogTitle>
|
</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>
|
</DialogHeader>
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
|
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
|
||||||
|
|||||||
@@ -106,8 +106,10 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
|
will-change: auto; /* 或者移除任何 will-change: transform */
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -1023,6 +1025,12 @@ input[type="number"] {
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mask-text,
|
||||||
|
.up .mask-text,
|
||||||
|
.down .mask-text {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -2076,6 +2084,21 @@ input[type="number"] {
|
|||||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
|
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drawer 内容玻璃拟态:与 Dialog 统一的毛玻璃效果(更通透) */
|
||||||
|
.drawer-content-theme {
|
||||||
|
background: rgba(15, 23, 42, 0);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-color: rgba(148, 163, 184, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .drawer-content-theme {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-color: rgba(148, 163, 184, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
|
/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
|
||||||
[data-slot="dialog-content"] {
|
[data-slot="dialog-content"] {
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
|||||||
60
app/hooks/useBodyScrollLock.js
Normal file
60
app/hooks/useBodyScrollLock.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// 全局状态:支持多个弹框“引用计数”式地共用一个滚动锁
|
||||||
|
let scrollLockCount = 0;
|
||||||
|
let lockedScrollY = 0;
|
||||||
|
let originalBodyPosition = "";
|
||||||
|
let originalBodyTop = "";
|
||||||
|
|
||||||
|
function lockBodyScroll() {
|
||||||
|
scrollLockCount += 1;
|
||||||
|
|
||||||
|
// 只有第一个锁才真正修改 body,避免多弹框互相干扰
|
||||||
|
if (scrollLockCount === 1) {
|
||||||
|
lockedScrollY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
originalBodyPosition = document.body.style.position || "";
|
||||||
|
originalBodyTop = document.body.style.top || "";
|
||||||
|
|
||||||
|
document.body.style.position = "fixed";
|
||||||
|
document.body.style.top = `-${lockedScrollY}px`;
|
||||||
|
document.body.style.width = "100%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockBodyScroll() {
|
||||||
|
if (scrollLockCount === 0) return;
|
||||||
|
|
||||||
|
scrollLockCount -= 1;
|
||||||
|
|
||||||
|
// 只有全部弹框都关闭时才恢复滚动位置
|
||||||
|
if (scrollLockCount === 0) {
|
||||||
|
document.body.style.position = originalBodyPosition;
|
||||||
|
document.body.style.top = originalBodyTop;
|
||||||
|
document.body.style.width = "";
|
||||||
|
|
||||||
|
// 恢复到锁定前的滚动位置,而不是跳到顶部
|
||||||
|
window.scrollTo(0, lockedScrollY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBodyScrollLock(open) {
|
||||||
|
const isLockedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !isLockedRef.current) {
|
||||||
|
lockBodyScroll();
|
||||||
|
isLockedRef.current = true;
|
||||||
|
} else if (!open && isLockedRef.current) {
|
||||||
|
unlockBodyScroll();
|
||||||
|
isLockedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载或依赖变化时兜底释放锁
|
||||||
|
return () => {
|
||||||
|
if (isLockedRef.current) {
|
||||||
|
unlockBodyScroll();
|
||||||
|
isLockedRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
}
|
||||||
@@ -3356,13 +3356,13 @@ export default function HomePage() {
|
|||||||
isScanImporting;
|
isScanImporting;
|
||||||
|
|
||||||
if (isAnyModalOpen) {
|
if (isAnyModalOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
containerRef.current.style.overflow = 'hidden';
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = '';
|
containerRef.current.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = '';
|
containerRef.current.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
settingsOpen,
|
settingsOpen,
|
||||||
|
|||||||
@@ -5,11 +5,39 @@ import { XIcon } from "lucide-react"
|
|||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import {CloseIcon} from "@/app/components/Icons";
|
||||||
|
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
|
open: openProp,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
|
||||||
|
const isControlled = openProp !== undefined;
|
||||||
|
const currentOpen = isControlled ? openProp : uncontrolledOpen;
|
||||||
|
|
||||||
|
// 使用全局 hook 统一处理 body 滚动锁定 & 恢复,避免弹窗打开时页面跳到顶部
|
||||||
|
useBodyScrollLock(currentOpen);
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(next) => {
|
||||||
|
if (!isControlled) setUncontrolledOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
},
|
||||||
|
[isControlled, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root
|
||||||
|
data-slot="dialog"
|
||||||
|
open={isControlled ? openProp : undefined}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({
|
||||||
@@ -60,6 +88,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
"mobile-dialog-glass",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
@@ -68,7 +97,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
<XIcon />
|
<CloseIcon width="20" height="20" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ import * as React from "react"
|
|||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useBodyScrollLock } from "../../app/hooks/useBodyScrollLock"
|
||||||
|
|
||||||
|
const DrawerScrollLockContext = React.createContext(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端滚动锁定:仅将 body 设为 position:fixed,用负值 top 把页面“拉”回当前视口位置,
|
||||||
|
* 既锁定滚动又保留视觉位置;overlay 上 ontouchmove preventDefault 防止背景触摸滚动。
|
||||||
|
*/
|
||||||
|
function useScrollLock(open) {
|
||||||
|
const onOverlayTouchMove = React.useCallback((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 统一使用 app 级 hook 处理 body 滚动锁定 & 恢复,避免多处实现导致位移/跳顶问题
|
||||||
|
useBodyScrollLock(open)
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() => (open ? { onTouchMove: onOverlayTouchMove } : null),
|
||||||
|
[open, onOverlayTouchMove]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function parseVhToPx(vhStr) {
|
function parseVhToPx(vhStr) {
|
||||||
if (typeof vhStr === "number") return vhStr
|
if (typeof vhStr === "number") return vhStr
|
||||||
@@ -12,10 +33,17 @@ function parseVhToPx(vhStr) {
|
|||||||
return (window.innerHeight * Number(match[1])) / 100
|
return (window.innerHeight * Number(match[1])) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
function Drawer({
|
function Drawer({ open, ...props }) {
|
||||||
...props
|
const scrollLock = useScrollLock(open)
|
||||||
}) {
|
const contextValue = React.useMemo(
|
||||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
() => ({ ...scrollLock, open: !!open }),
|
||||||
|
[scrollLock, open]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<DrawerScrollLockContext.Provider value={contextValue}>
|
||||||
|
<DrawerPrimitive.Root modal={false} data-slot="drawer" open={open} {...props} />
|
||||||
|
</DrawerScrollLockContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerTrigger({
|
function DrawerTrigger({
|
||||||
@@ -40,14 +68,26 @@ function DrawerOverlay({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
|
const ctx = React.useContext(DrawerScrollLockContext)
|
||||||
|
const { open = false, ...scrollLockProps } = ctx || {}
|
||||||
|
// modal={false} 时 vaul 不渲染/隐藏 Overlay,用自定义遮罩 div 保证始终有遮罩;点击遮罩关闭
|
||||||
return (
|
return (
|
||||||
<DrawerPrimitive.Overlay
|
<DrawerPrimitive.Close asChild>
|
||||||
data-slot="drawer-overlay"
|
<div
|
||||||
className={cn(
|
data-slot="drawer-overlay"
|
||||||
"fixed inset-0 z-50 bg-[var(--drawer-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
data-state={open ? "open" : "closed"}
|
||||||
className
|
role="button"
|
||||||
)}
|
tabIndex={-1}
|
||||||
{...props} />
|
aria-label="关闭"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 cursor-default bg-[var(--drawer-overlay,rgba(0,0,0,0.45))] backdrop-blur-[6px]",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...scrollLockProps}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DrawerPrimitive.Close>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
package-lock.json
generated
82
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^9.3.1",
|
"@dicebear/core": "^9.3.1",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"ahooks": "^3.9.6",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -504,6 +505,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -4591,6 +4601,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-cookie": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -5227,6 +5243,28 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ahooks": {
|
||||||
|
"version": "3.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz",
|
||||||
|
"integrity": "sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.0",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"dayjs": "^1.9.1",
|
||||||
|
"intersection-observer": "^0.12.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-fast-compare": "^3.2.2",
|
||||||
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
|
"screenfull": "^5.0.0",
|
||||||
|
"tslib": "^2.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -8288,6 +8326,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/intersection-observer": {
|
||||||
|
"version": "0.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz",
|
||||||
|
"integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==",
|
||||||
|
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
@@ -8953,6 +8998,15 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-cookie": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -10868,6 +10922,12 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -11031,6 +11091,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resize-observer-polyfill": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -11250,6 +11316,18 @@
|
|||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/screenfull": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"ahooks": "^3.9.6",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user