15 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
16 changed files with 607 additions and 246 deletions

View File

@@ -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 相关内容查阅官方文档。

View File

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

View File

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

View File

@@ -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,33 +57,27 @@ 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 <DialogContent
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal"
style={{ maxWidth: '500px', width: '90vw' }} overlayClassName="modal-overlay"
onClick={(e) => e.stopPropagation()} style={{ maxWidth: '500px', width: '90vw', zIndex: 99 }}
onOpenAutoFocus={(event) => event.preventDefault()}
> >
<DialogTitle asChild>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}> <div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" /> <SettingsIcon width="20" height="20" />
<span>管理分组</span> <span>管理分组</span>
</div> </div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div> </div>
</DialogTitle>
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}> <div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -178,7 +173,8 @@ export default function GroupManageModal({ groups, onClose, onSave }) {
完成 完成
</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> </>
); );
} }

View File

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

View File

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

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'; } 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>
{hasEstimateNav && displayDate && displayDate !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span> <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>
{hasText && displayTime && displayTime !== '-' ? (
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span> <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>

View File

@@ -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>
{hasEstimateNav && date && date !== '-' ? (
<span className="muted" style={{ fontSize: '11px' }}> <span className="muted" style={{ fontSize: '11px' }}>
{date} {date}
</span> </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>
{hasText && time && time !== '-' ? (
<span className="muted" style={{ fontSize: '11px' }}> <span className="muted" style={{ fontSize: '11px' }}>
{time} {time}
</span> </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"

View File

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

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

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

View File

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

View File

@@ -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>
<div
data-slot="drawer-overlay" data-slot="drawer-overlay"
data-state={open ? "open" : "closed"}
role="button"
tabIndex={-1}
aria-label="关闭"
className={cn( 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 className
)} )}
{...props} /> {...scrollLockProps}
{...props}
/>
</DrawerPrimitive.Close>
); );
} }

78
package-lock.json generated
View File

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

View File

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