Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71759153f | ||
|
|
a4a881860b | ||
|
|
95514eb52f | ||
|
|
9516a4f874 | ||
|
|
750e72823b | ||
|
|
c3515c7011 | ||
|
|
f39f152efa | ||
|
|
d4255fc1c8 | ||
|
|
480abbcf47 | ||
|
|
3ed129afb2 | ||
|
|
5f909cc669 | ||
|
|
f379c9fef5 | ||
|
|
412b22ec1c | ||
|
|
a4e33d23cb | ||
|
|
63e7f000df | ||
|
|
152059b199 | ||
|
|
6c685c61e0 | ||
|
|
a176e7d013 | ||
|
|
d5df393723 | ||
|
|
7f3dfb31cf | ||
|
|
e97de8744a | ||
|
|
354936c9af | ||
|
|
a8a24605d4 | ||
|
|
b20fd42eec | ||
|
|
a3719c58fb | ||
|
|
6d2cf60d21 | ||
|
|
89d938a6c3 | ||
|
|
86e479c21a | ||
|
|
1f3c0bbbc9 | ||
|
|
24eb21fd29 | ||
|
|
56e20211e4 | ||
|
|
e5e2e472aa | ||
|
|
dab3ba3142 | ||
|
|
5b86a1c84a | ||
|
|
e5858df592 | ||
|
|
f20b852e98 | ||
|
|
792986dd79 | ||
|
|
1f9a4ff97a | ||
|
|
baea6f5107 | ||
|
|
f0b469fc93 | ||
|
|
d9364ce504 | ||
|
|
99ec356fbb | ||
|
|
44dfb944c7 | ||
|
|
aac5c5003a | ||
|
|
6580658f55 |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
@@ -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_v11';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v14';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -16,6 +16,16 @@ export default function Announcement() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 清理历史 ANNOUNCEMENT_KEY
|
||||||
|
const keysToRemove = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith('hasClosedAnnouncement_v') && key !== ANNOUNCEMENT_KEY) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||||
|
|
||||||
localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
|
localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
};
|
};
|
||||||
@@ -65,13 +75,11 @@ 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.1 版本更新内容如下:</p>
|
<p>v0.2.3 版本更新内容如下:</p>
|
||||||
<p>1. 改进拍照识别基金准确度。</p>
|
<p>1. 二次确认弹框层级问题修复。</p>
|
||||||
<p>2. 拍照导入支持识别持仓金额、持仓收益。</p>
|
<p>2. 净值列新增日期。</p>
|
||||||
以下功能将会在下一个版本上线:
|
<p>3. 重发新用户支持群二维码(底部提交反馈处)。</p>
|
||||||
<p>1. 列表页查看基金详情。</p>
|
<p>注:用户支持群禁止讨论基金及金融买卖相关内容。</p>
|
||||||
<p>2. 大盘走势数据。</p>
|
|
||||||
<p>3. 关联板块。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||||
|
|||||||
@@ -1,59 +1,67 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import {
|
||||||
import { createPortal } from 'react-dom';
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { TrashIcon } from './Icons';
|
import { TrashIcon } from './Icons';
|
||||||
|
|
||||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
export default function ConfirmModal({
|
||||||
const content = (
|
title,
|
||||||
<motion.div
|
message,
|
||||||
className="modal-overlay"
|
onConfirm,
|
||||||
role="dialog"
|
onCancel,
|
||||||
aria-modal="true"
|
confirmText = '确定删除',
|
||||||
onClick={(e) => {
|
icon,
|
||||||
e.stopPropagation();
|
confirmVariant = 'danger', // 'danger' | 'primary' | 'secondary'
|
||||||
onCancel();
|
}) {
|
||||||
}}
|
const handleOpenChange = (open) => {
|
||||||
initial={{ opacity: 0 }}
|
if (!open) onCancel();
|
||||||
animate={{ opacity: 1 }}
|
};
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
style={{ zIndex: 10002 }}
|
const confirmButtonToneClass =
|
||||||
>
|
confirmVariant === 'primary'
|
||||||
<motion.div
|
? 'button'
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
: confirmVariant === 'secondary'
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
? 'button secondary'
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
: 'button danger';
|
||||||
className="glass card modal"
|
|
||||||
style={{ maxWidth: '400px' }}
|
return (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
overlayClassName="!z-[12000]"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!z-[12010] max-w-[400px] flex flex-col gap-5 p-6"
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
<DialogHeader className="flex flex-row items-center gap-3 text-left">
|
||||||
<TrashIcon width="20" height="20" className="danger" />
|
{icon || (
|
||||||
<span>{title}</span>
|
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||||
</div>
|
)}
|
||||||
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
|
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</DialogDescription>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
type="button"
|
||||||
|
className="button secondary min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button danger"
|
type="button"
|
||||||
|
className={`${confirmButtonToneClass} min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
style={{ flex: 1 }}
|
|
||||||
autoFocus
|
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
return createPortal(content, document.body);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,156 +187,176 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
|||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="glass card modal dca-modal"
|
className="glass card modal dca-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ maxWidth: '420px' }}
|
style={{
|
||||||
|
maxWidth: '420px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
className="scrollbar-y-styled"
|
||||||
<span style={{ fontSize: '20px' }}>🔁</span>
|
style={{
|
||||||
<span>定投</span>
|
overflowY: 'auto',
|
||||||
</div>
|
paddingRight: 4,
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
flex: 1,
|
||||||
<CloseIcon width="20" height="20" />
|
}}
|
||||||
</button>
|
>
|
||||||
</div>
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<span style={{ fontSize: '20px' }}>🔁</span>
|
||||||
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
<span>定投</span>
|
||||||
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group" style={{ marginBottom: 8 }}>
|
|
||||||
<label className="muted" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '14px' }}>
|
|
||||||
<span>是否启用定投</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEnabled(v => !v)}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={`dca-toggle-track ${enabled ? 'enabled' : ''}`}>
|
|
||||||
<span className="dca-toggle-thumb" style={{ left: enabled ? 16 : 2 }} />
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}>
|
|
||||||
{enabled ? '已启用' : '未启用'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
|
||||||
定投金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
|
||||||
</label>
|
|
||||||
<div style={{ border: (!amount || parseFloat(amount) <= 0) ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
|
||||||
<NumericInput
|
|
||||||
value={amount}
|
|
||||||
onChange={setAmount}
|
|
||||||
step={100}
|
|
||||||
min={0}
|
|
||||||
placeholder="请输入每次定投金额"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
|
||||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 8 }}>
|
||||||
|
<label className="muted" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||||
|
<span>是否启用定投</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnabled(v => !v)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`dca-toggle-track ${enabled ? 'enabled' : ''}`}>
|
||||||
|
<span className="dca-toggle-thumb" style={{ left: enabled ? 16 : 2 }} />
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}>
|
||||||
|
{enabled ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ border: feeRate === '' ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
定投金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ border: (!amount || parseFloat(amount) <= 0) ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
<NumericInput
|
<NumericInput
|
||||||
value={feeRate}
|
value={amount}
|
||||||
onChange={setFeeRate}
|
onChange={setAmount}
|
||||||
step={0.01}
|
step={100}
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="0.12"
|
placeholder="请输入每次定投金额"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
|
||||||
定投周期 <span style={{ color: 'var(--danger)' }}>*</span>
|
|
||||||
</label>
|
|
||||||
<div className="dca-option-group row" style={{ gap: 4 }}>
|
|
||||||
{CYCLES.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
|
|
||||||
onClick={() => setCycle(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(cycle === 'weekly' || cycle === 'biweekly') && (
|
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
扣款星期 <span style={{ color: 'var(--danger)' }}>*</span>
|
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="dca-option-group row" style={{ gap: 4 }}>
|
<div style={{ border: feeRate === '' ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
{WEEKDAY_OPTIONS.map((opt) => (
|
<NumericInput
|
||||||
<button
|
value={feeRate}
|
||||||
key={opt.value}
|
onChange={setFeeRate}
|
||||||
type="button"
|
step={0.01}
|
||||||
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
|
min={0}
|
||||||
onClick={() => setWeeklyDay(opt.value)}
|
placeholder="0.12"
|
||||||
>
|
/>
|
||||||
{opt.label}
|
</div>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
)}
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
定投周期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
{cycle === 'monthly' && (
|
</label>
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
{CYCLES.map((opt) => (
|
||||||
扣款日 <span style={{ color: 'var(--danger)' }}>*</span>
|
|
||||||
</label>
|
|
||||||
<div className="dca-monthly-day-group scrollbar-y-styled">
|
|
||||||
{Array.from({ length: 28 }).map((_, idx) => {
|
|
||||||
const day = idx + 1;
|
|
||||||
const active = monthlyDay === day;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={day}
|
key={opt.value}
|
||||||
ref={active ? monthlyDayRef : null}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
|
||||||
onClick={() => setMonthlyDay(day)}
|
onClick={() => setCycle(opt.value)}
|
||||||
>
|
>
|
||||||
{day}日
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
{(cycle === 'weekly' || cycle === 'biweekly') && (
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
首次扣款日期
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
</label>
|
扣款星期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
<div className="dca-first-date-display">
|
</label>
|
||||||
{firstDate}
|
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||||
</div>
|
{WEEKDAY_OPTIONS.map((opt) => (
|
||||||
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
<button
|
||||||
* 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。
|
key={opt.value}
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setWeeklyDay(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
{cycle === 'monthly' && (
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
扣款日 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<div className="dca-monthly-day-group scrollbar-y-styled">
|
||||||
|
{Array.from({ length: 28 }).map((_, idx) => {
|
||||||
|
const day = idx + 1;
|
||||||
|
const active = monthlyDay === day;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
ref={active ? monthlyDayRef : null}
|
||||||
|
type="button"
|
||||||
|
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => setMonthlyDay(day)}
|
||||||
|
>
|
||||||
|
{day}日
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
|
||||||
|
首次扣款日期
|
||||||
|
</label>
|
||||||
|
<div className="dca-first-date-display">
|
||||||
|
{firstDate}
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||||
|
* 基于当前日期和所选周期/扣款日自动计算:每日=当天;每周/每两周=从今天起最近的所选工作日;每月=从今天起最近的所选日期(1-28日)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary dca-cancel-btn"
|
className="button secondary dca-cancel-btn"
|
||||||
@@ -346,15 +366,16 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
className="button"
|
className="button"
|
||||||
disabled={!isValid()}
|
disabled={!isValid()}
|
||||||
|
onClick={handleSubmit}
|
||||||
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
style={{ flex: 1, opacity: isValid() ? 1 : 0.6 }}
|
||||||
>
|
>
|
||||||
保存定投
|
保存定投
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
33
app/components/EmptyStateCard.jsx
Normal file
33
app/components/EmptyStateCard.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function EmptyStateCard({
|
||||||
|
fundsLength = 0,
|
||||||
|
currentTab = 'all',
|
||||||
|
onAddToGroup,
|
||||||
|
}) {
|
||||||
|
const isEmpty = fundsLength === 0;
|
||||||
|
const isGroupTab = currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="glass card empty"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '60px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
|
||||||
|
<div className="muted" style={{ marginBottom: 20 }}>
|
||||||
|
{isEmpty ? '尚未添加基金' : '该分组下暂无数据'}
|
||||||
|
</div>
|
||||||
|
{isGroupTab && fundsLength > 0 && (
|
||||||
|
<button className="button" onClick={onAddToGroup}>
|
||||||
|
添加基金到此分组
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
app/components/FundCard.jsx
Normal file
465
app/components/FundCard.jsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||||
|
import { isNumber, isString } from 'lodash';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Stat } from './Common';
|
||||||
|
import FundTrendChart from './FundTrendChart';
|
||||||
|
import FundIntradayChart from './FundIntradayChart';
|
||||||
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
ExitIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
StarIcon,
|
||||||
|
SwitchIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from './Icons';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
|
||||||
|
const DEFAULT_TZ = 'Asia/Shanghai';
|
||||||
|
const getBrowserTimeZone = () => {
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return tz || DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
return DEFAULT_TZ;
|
||||||
|
};
|
||||||
|
const TZ = getBrowserTimeZone();
|
||||||
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
|
||||||
|
|
||||||
|
export default function FundCard({
|
||||||
|
fund: f,
|
||||||
|
todayStr,
|
||||||
|
currentTab,
|
||||||
|
favorites,
|
||||||
|
dcaPlans,
|
||||||
|
holdings,
|
||||||
|
percentModes,
|
||||||
|
valuationSeries,
|
||||||
|
collapsedCodes,
|
||||||
|
collapsedTrends,
|
||||||
|
transactions,
|
||||||
|
theme,
|
||||||
|
isTradingDay,
|
||||||
|
refreshing,
|
||||||
|
getHoldingProfit,
|
||||||
|
onRemoveFromGroup,
|
||||||
|
onToggleFavorite,
|
||||||
|
onRemoveFund,
|
||||||
|
onHoldingClick,
|
||||||
|
onActionClick,
|
||||||
|
onPercentModeToggle,
|
||||||
|
onToggleCollapse,
|
||||||
|
onToggleTrendCollapse,
|
||||||
|
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||||
|
}) {
|
||||||
|
const holding = holdings[f?.code];
|
||||||
|
const profit = getHoldingProfit?.(f, holding) ?? null;
|
||||||
|
const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0;
|
||||||
|
|
||||||
|
const style = layoutMode === 'drawer' ? {
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="glass card"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ marginBottom: 10 }}>
|
||||||
|
<div className="title">
|
||||||
|
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||||
|
<button
|
||||||
|
className="icon-button fav-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveFromGroup?.(f.code);
|
||||||
|
}}
|
||||||
|
title="从当前分组移除"
|
||||||
|
>
|
||||||
|
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`icon-button fav-button ${favorites?.has(f.code) ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite?.(f.code);
|
||||||
|
}}
|
||||||
|
title={favorites?.has(f.code) ? '取消自选' : '添加自选'}
|
||||||
|
>
|
||||||
|
<StarIcon width="18" height="18" filled={favorites?.has(f.code)} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="title-text">
|
||||||
|
<span
|
||||||
|
className="name-text"
|
||||||
|
title={f.jzrq === todayStr ? '今日净值已更新' : ''}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="muted">
|
||||||
|
#{f.code}
|
||||||
|
{dcaPlans?.[f.code]?.enabled === true && <span className="dca-indicator">定</span>}
|
||||||
|
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<div className="badge-v">
|
||||||
|
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
||||||
|
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="icon-button danger"
|
||||||
|
onClick={() => !refreshing && onRemoveFund?.(f)}
|
||||||
|
title="删除"
|
||||||
|
disabled={refreshing}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
opacity: refreshing ? 0.6 : 1,
|
||||||
|
cursor: refreshing ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
<Stat label="单位净值" value={f.dwjz ?? '—'} />
|
||||||
|
{f.noValuation ? (
|
||||||
|
<Stat
|
||||||
|
label="涨跌幅"
|
||||||
|
value={
|
||||||
|
f.zzl !== undefined && f.zzl !== null
|
||||||
|
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
delta={f.zzl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const hasTodayData = f.jzrq === todayStr;
|
||||||
|
let isYesterdayChange = false;
|
||||||
|
let isPreviousTradingDay = false;
|
||||||
|
if (!hasTodayData && isString(f.jzrq)) {
|
||||||
|
const today = toTz(todayStr).startOf('day');
|
||||||
|
const jzDate = toTz(f.jzrq).startOf('day');
|
||||||
|
const yesterday = today.clone().subtract(1, 'day');
|
||||||
|
if (jzDate.isSame(yesterday, 'day')) {
|
||||||
|
isYesterdayChange = true;
|
||||||
|
} else if (jzDate.isBefore(yesterday, 'day')) {
|
||||||
|
isPreviousTradingDay = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shouldHideChange =
|
||||||
|
isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
|
||||||
|
|
||||||
|
if (shouldHideChange) return null;
|
||||||
|
|
||||||
|
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅';
|
||||||
|
return (
|
||||||
|
<Stat
|
||||||
|
label={changeLabel}
|
||||||
|
value={
|
||||||
|
f.zzl !== undefined
|
||||||
|
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
delta={f.zzl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Stat
|
||||||
|
label="估值净值"
|
||||||
|
value={
|
||||||
|
f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="估值涨幅"
|
||||||
|
value={
|
||||||
|
f.estPricedCoverage > 0.05
|
||||||
|
? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%`
|
||||||
|
: isNumber(f.gszzl)
|
||||||
|
? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%`
|
||||||
|
: f.gszzl ?? '—'
|
||||||
|
}
|
||||||
|
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ marginBottom: 12 }}>
|
||||||
|
{!profit ? (
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
style={{ flexDirection: 'column', gap: 4 }}
|
||||||
|
>
|
||||||
|
<span className="label">持仓金额</span>
|
||||||
|
<div
|
||||||
|
className="value muted"
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
|
||||||
|
>
|
||||||
|
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
|
||||||
|
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
|
||||||
|
</span>
|
||||||
|
<span className="value">¥{profit.amount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span className="label">当日收益</span>
|
||||||
|
<span
|
||||||
|
className={`value ${
|
||||||
|
profit.profitToday != null
|
||||||
|
? profit.profitToday > 0
|
||||||
|
? 'up'
|
||||||
|
: profit.profitToday < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
: 'muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{profit.profitToday != null
|
||||||
|
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{profit.profitTotal !== null && (
|
||||||
|
<div
|
||||||
|
className="stat"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPercentModeToggle?.(f.code);
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}
|
||||||
|
title="点击切换金额/百分比"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
持有收益{percentModes?.[f.code] ? '(%)' : ''}
|
||||||
|
<SwitchIcon />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`value ${
|
||||||
|
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
|
||||||
|
{percentModes?.[f.code]
|
||||||
|
? `${Math.abs(
|
||||||
|
holding?.cost * holding?.share
|
||||||
|
? (profit.profitTotal / (holding.cost * holding.share)) * 100
|
||||||
|
: 0,
|
||||||
|
).toFixed(2)}%`
|
||||||
|
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{f.estPricedCoverage > 0.05 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
marginTop: -8,
|
||||||
|
marginBottom: 10,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const showIntraday =
|
||||||
|
Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2;
|
||||||
|
if (!showIntraday) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.gztime &&
|
||||||
|
toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
f.jzrq &&
|
||||||
|
f.gztime &&
|
||||||
|
toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FundIntradayChart
|
||||||
|
key={`${f.code}-intraday-${theme}`}
|
||||||
|
series={valuationSeries[f.code]}
|
||||||
|
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{layoutMode === 'drawer' ? (
|
||||||
|
<Tabs defaultValue={hasHoldings ? 'holdings' : 'trend'} className="w-full">
|
||||||
|
<TabsList className={`w-full ${hasHoldings ? 'grid grid-cols-2' : ''}`}>
|
||||||
|
{hasHoldings && (
|
||||||
|
<TabsTrigger value="holdings">前10重仓股票</TabsTrigger>
|
||||||
|
)}
|
||||||
|
<TabsTrigger value="trend">业绩走势</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
{hasHoldings && (
|
||||||
|
<TabsContent value="holdings" className="mt-3 outline-none">
|
||||||
|
<div className="list">
|
||||||
|
{f.holdings.map((h, idx) => (
|
||||||
|
<div className="item" key={idx}>
|
||||||
|
<span className="name">{h.name}</span>
|
||||||
|
<div className="values">
|
||||||
|
{isNumber(h.change) && (
|
||||||
|
<span
|
||||||
|
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{h.change > 0 ? '+' : ''}
|
||||||
|
{h.change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="weight">{h.weight}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
<TabsContent value="trend" className="mt-3 outline-none">
|
||||||
|
<FundTrendChart
|
||||||
|
key={`${f.code}-${theme}`}
|
||||||
|
code={f.code}
|
||||||
|
isExpanded
|
||||||
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
|
transactions={transactions?.[f.code] || []}
|
||||||
|
theme={theme}
|
||||||
|
hideHeader
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasHoldings && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
className="title"
|
||||||
|
onClick={() => onToggleCollapse?.(f.code)}
|
||||||
|
>
|
||||||
|
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>前10重仓股票</span>
|
||||||
|
<ChevronIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
transform: collapsedCodes?.has(f.code)
|
||||||
|
? 'rotate(-90deg)'
|
||||||
|
: 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="muted">涨跌幅 / 占比</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!collapsedCodes?.has(f.code) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div className="list">
|
||||||
|
{f.holdings.map((h, idx) => (
|
||||||
|
<div className="item" key={idx}>
|
||||||
|
<span className="name">{h.name}</span>
|
||||||
|
<div className="values">
|
||||||
|
{isNumber(h.change) && (
|
||||||
|
<span
|
||||||
|
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
>
|
||||||
|
{h.change > 0 ? '+' : ''}
|
||||||
|
{h.change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="weight">{h.weight}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FundTrendChart
|
||||||
|
key={`${f.code}-${theme}`}
|
||||||
|
code={f.code}
|
||||||
|
isExpanded={!collapsedTrends?.has(f.code)}
|
||||||
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
|
transactions={transactions?.[f.code] || []}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ function getChartThemeColors(theme) {
|
|||||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) {
|
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||||
const [range, setRange] = useState('1m');
|
const [range, setRange] = useState('1m');
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -490,79 +490,102 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}];
|
}];
|
||||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||||
|
|
||||||
return (
|
const chartBlock = (
|
||||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
<>
|
||||||
<div
|
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
{loading && (
|
||||||
className="title"
|
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||||
onClick={onToggleExpand}
|
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||||
>
|
|
||||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span>业绩走势</span>
|
|
||||||
<ChevronIcon
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
className="muted"
|
|
||||||
style={{
|
|
||||||
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{data.length > 0 && (
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
{!loading && data.length === 0 && (
|
||||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
<div className="chart-overlay">
|
||||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
{data.length > 0 && (
|
||||||
|
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<div className="trend-range-bar">
|
||||||
{isExpanded && (
|
{ranges.map(r => (
|
||||||
<motion.div
|
<button
|
||||||
initial={{ height: 0, opacity: 0 }}
|
key={r.value}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
type="button"
|
||||||
exit={{ height: 0, opacity: 0 }}
|
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
{r.label}
|
||||||
{loading && (
|
</button>
|
||||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
))}
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
);
|
||||||
|
|
||||||
{!loading && data.length === 0 && (
|
return (
|
||||||
<div className="chart-overlay">
|
<div style={{ marginTop: hideHeader ? 0 : 16 }} onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
{!hideHeader && (
|
||||||
</div>
|
<div
|
||||||
)}
|
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
className="title"
|
||||||
{data.length > 0 && (
|
onClick={onToggleExpand}
|
||||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
>
|
||||||
)}
|
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>业绩走势</span>
|
||||||
|
<ChevronIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||||
|
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||||
|
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="trend-range-bar">
|
{hideHeader && data.length > 0 && (
|
||||||
{ranges.map(r => (
|
<div className="row" style={{ marginBottom: 8, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
key={r.value}
|
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||||
type="button"
|
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||||
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
</span>
|
||||||
>
|
</div>
|
||||||
{r.label}
|
</div>
|
||||||
</button>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
{hideHeader ? (
|
||||||
</motion.div>
|
chartBlock
|
||||||
)}
|
) : (
|
||||||
</AnimatePresence>
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{chartBlock}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
332
app/components/GroupSummary.jsx
Normal file
332
app/components/GroupSummary.jsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react';
|
||||||
|
import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons';
|
||||||
|
|
||||||
|
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
||||||
|
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(value);
|
||||||
|
const previousValue = useRef(value);
|
||||||
|
const isFirstChange = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousValue.current === value) return;
|
||||||
|
|
||||||
|
if (isFirstChange.current) {
|
||||||
|
isFirstChange.current = false;
|
||||||
|
previousValue.current = value;
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = previousValue.current;
|
||||||
|
const end = value;
|
||||||
|
const duration = 400;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (currentTime) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const ease = 1 - Math.pow(1 - progress, 4);
|
||||||
|
const current = start + (end - start) * ease;
|
||||||
|
setDisplayValue(current);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
previousValue.current = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{prefix}
|
||||||
|
{Math.abs(displayValue).toFixed(decimals)}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupSummary({
|
||||||
|
funds,
|
||||||
|
holdings,
|
||||||
|
groupName,
|
||||||
|
getProfit,
|
||||||
|
stickyTop,
|
||||||
|
}) {
|
||||||
|
const [showPercent, setShowPercent] = useState(true);
|
||||||
|
const [isMasked, setIsMasked] = useState(false);
|
||||||
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
|
const rowRef = useRef(null);
|
||||||
|
const [assetSize, setAssetSize] = useState(24);
|
||||||
|
const [metricSize, setMetricSize] = useState(18);
|
||||||
|
const [winW, setWinW] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setWinW(window.innerWidth);
|
||||||
|
const onR = () => setWinW(window.innerWidth);
|
||||||
|
window.addEventListener('resize', onR);
|
||||||
|
return () => window.removeEventListener('resize', onR);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
let totalAsset = 0;
|
||||||
|
let totalProfitToday = 0;
|
||||||
|
let totalHoldingReturn = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
let hasHolding = false;
|
||||||
|
let hasAnyTodayData = false;
|
||||||
|
|
||||||
|
funds.forEach((fund) => {
|
||||||
|
const holding = holdings[fund.code];
|
||||||
|
const profit = getProfit(fund, holding);
|
||||||
|
|
||||||
|
if (profit) {
|
||||||
|
hasHolding = true;
|
||||||
|
totalAsset += profit.amount;
|
||||||
|
if (profit.profitToday != null) {
|
||||||
|
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||||
|
hasAnyTodayData = true;
|
||||||
|
}
|
||||||
|
if (profit.profitTotal !== null) {
|
||||||
|
totalHoldingReturn += profit.profitTotal;
|
||||||
|
if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') {
|
||||||
|
totalCost += holding.cost * holding.share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAsset,
|
||||||
|
totalProfitToday,
|
||||||
|
totalHoldingReturn,
|
||||||
|
hasHolding,
|
||||||
|
returnRate,
|
||||||
|
hasAnyTodayData,
|
||||||
|
};
|
||||||
|
}, [funds, holdings, getProfit]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = rowRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const height = el.clientHeight;
|
||||||
|
const tooTall = height > 80;
|
||||||
|
if (tooTall) {
|
||||||
|
setAssetSize((s) => Math.max(16, s - 1));
|
||||||
|
setMetricSize((s) => Math.max(12, s - 1));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
winW,
|
||||||
|
summary.totalAsset,
|
||||||
|
summary.totalProfitToday,
|
||||||
|
summary.totalHoldingReturn,
|
||||||
|
summary.returnRate,
|
||||||
|
showPercent,
|
||||||
|
assetSize,
|
||||||
|
metricSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summary.hasHolding) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isSticky ? 'group-summary-sticky' : ''}
|
||||||
|
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="glass card group-summary-card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="sticky-toggle-btn"
|
||||||
|
onClick={() => setIsSticky(!isSticky)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
padding: 4,
|
||||||
|
opacity: 0.6,
|
||||||
|
zIndex: 10,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSticky ? (
|
||||||
|
<PinIcon width="14" height="14" />
|
||||||
|
) : (
|
||||||
|
<PinOffIcon width="14" height="14" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
className="row"
|
||||||
|
style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
<div className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="fav-button"
|
||||||
|
onClick={() => setIsMasked((value) => !value)}
|
||||||
|
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 2,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<EyeOffIcon width="16" height="16" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon width="16" height="16" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||||
|
{isMasked ? (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||||
|
>
|
||||||
|
******
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: '12px', marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
当日收益
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
summary.hasAnyTodayData
|
||||||
|
? summary.totalProfitToday > 0
|
||||||
|
? 'up'
|
||||||
|
: summary.totalProfitToday < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
: 'muted'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<span style={{ fontSize: metricSize }}>******</span>
|
||||||
|
) : summary.hasAnyTodayData ? (
|
||||||
|
<>
|
||||||
|
<span style={{ marginRight: 1 }}>
|
||||||
|
{summary.totalProfitToday > 0
|
||||||
|
? '+'
|
||||||
|
: summary.totalProfitToday < 0
|
||||||
|
? '-'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.totalProfitToday)}
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: metricSize }}>--</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
marginBottom: 4,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
持有收益{showPercent ? '(%)' : ''}{' '}
|
||||||
|
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
summary.totalHoldingReturn > 0
|
||||||
|
? 'up'
|
||||||
|
: summary.totalHoldingReturn < 0
|
||||||
|
? 'down'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowPercent(!showPercent)}
|
||||||
|
title="点击切换金额/百分比"
|
||||||
|
>
|
||||||
|
{isMasked ? (
|
||||||
|
<span style={{ fontSize: metricSize }}>******</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ marginRight: 1 }}>
|
||||||
|
{summary.totalHoldingReturn > 0
|
||||||
|
? '+'
|
||||||
|
: summary.totalHoldingReturn < 0
|
||||||
|
? '-'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
{showPercent ? (
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.returnRate)}
|
||||||
|
suffix="%"
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CountUp
|
||||||
|
value={Math.abs(summary.totalHoldingReturn)}
|
||||||
|
style={{ fontSize: metricSize }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -260,3 +260,20 @@ export function MoonIcon(props) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SwitchIcon({ props }) {
|
||||||
|
return (
|
||||||
|
<svg t="1772945896369" className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
p-id="2524" width="13" height="13">
|
||||||
|
<path
|
||||||
|
d="M885.247 477.597H132c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.247c17.673 0 32 14.327 32 32s-14.327 32-32 32z"
|
||||||
|
fill="currentColor" p-id="2525"></path>
|
||||||
|
<path
|
||||||
|
d="M893.366 477.392c-8.189 0-16.379-3.124-22.627-9.373L709.954 307.235c-12.497-12.497-12.497-32.758 0-45.255 12.496-12.497 32.758-12.497 45.254 0l160.785 160.785c12.497 12.497 12.497 32.758 0 45.255-6.248 6.248-14.437 9.372-22.627 9.372zM893.366 609.607H140.119c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.248c17.673 0 32 14.327 32 32s-14.328 32-32.001 32z"
|
||||||
|
fill="currentColor" p-id="2526"></path>
|
||||||
|
<path
|
||||||
|
d="M292.784 770.597c-8.189 0-16.379-3.124-22.627-9.373L109.373 600.439c-12.497-12.496-12.497-32.758 0-45.254 12.497-12.498 32.758-12.498 45.255 0L315.412 715.97c12.497 12.496 12.497 32.758 0 45.254-6.249 6.249-14.438 9.373-22.628 9.373z"
|
||||||
|
fill="currentColor" p-id="2527"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
import { MailIcon } from './Icons';
|
import { MailIcon } from './Icons';
|
||||||
|
|
||||||
export default function LoginModal({
|
export default function LoginModal({
|
||||||
@@ -56,15 +57,21 @@ export default function LoginModal({
|
|||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
|
||||||
请输入邮箱验证码以完成注册/登录
|
请输入邮箱验证码以完成注册/登录
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InputOTP
|
||||||
className="input"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入验证码"
|
|
||||||
value={loginOtp}
|
|
||||||
onChange={(e) => setLoginOtp(e.target.value)}
|
|
||||||
disabled={loginLoading}
|
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
value={loginOtp}
|
||||||
|
onChange={(value) => setLoginOtp(value)}
|
||||||
|
disabled={loginLoading}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginError && (
|
{loginError && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -22,13 +23,23 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} 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 {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
import FitText from './FitText';
|
import FitText from './FitText';
|
||||||
|
import FundCard from './FundCard';
|
||||||
import MobileSettingModal from './MobileSettingModal';
|
import MobileSettingModal from './MobileSettingModal';
|
||||||
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||||
|
|
||||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
|
'totalChangePercent',
|
||||||
'todayProfit',
|
'todayProfit',
|
||||||
'holdingProfit',
|
'holdingProfit',
|
||||||
'latestNav',
|
'latestNav',
|
||||||
@@ -37,12 +48,15 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
|||||||
const MOBILE_COLUMN_HEADERS = {
|
const MOBILE_COLUMN_HEADERS = {
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨跌幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
estimateChangePercent: '估值涨跌幅',
|
estimateChangePercent: '估值涨幅',
|
||||||
|
totalChangePercent: '估算收益',
|
||||||
todayProfit: '当日收益',
|
todayProfit: '当日收益',
|
||||||
holdingProfit: '持有收益',
|
holdingProfit: '持有收益',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RowSortableContext = createContext(null);
|
||||||
|
|
||||||
function SortableRow({ row, children, isTableDragging, disabled }) {
|
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -72,7 +86,9 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
|||||||
style={{ ...style, position: 'relative' }}
|
style={{ ...style, position: 'relative' }}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
<RowSortableContext.Provider value={{ setActivatorNodeRef, listeners }}>
|
||||||
|
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||||
|
</RowSortableContext.Provider>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,6 +107,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
|||||||
* @param {boolean} [props.refreshing] - 是否刷新中
|
* @param {boolean} [props.refreshing] - 是否刷新中
|
||||||
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||||
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||||
|
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用底部弹框展示卡片视图
|
||||||
*/
|
*/
|
||||||
export default function MobileFundTable({
|
export default function MobileFundTable({
|
||||||
data = [],
|
data = [],
|
||||||
@@ -105,20 +122,35 @@ export default function MobileFundTable({
|
|||||||
sortBy = 'default',
|
sortBy = 'default',
|
||||||
onReorder,
|
onReorder,
|
||||||
onCustomSettingsChange,
|
onCustomSettingsChange,
|
||||||
|
stickyTop = 0,
|
||||||
|
getFundCardProps,
|
||||||
|
blockDrawerClose = false,
|
||||||
|
closeDrawerRef,
|
||||||
}) {
|
}) {
|
||||||
|
const [isNameSortMode, setIsNameSortMode] = useState(false);
|
||||||
|
|
||||||
|
// 排序模式下拖拽手柄无需长按,直接拖动即可;非排序模式长按整行触发拖拽
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { delay: 400, tolerance: 5 },
|
activationConstraint: isNameSortMode ? { delay: 0, tolerance: 5 } : { delay: 400, tolerance: 5 },
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor)
|
useSensor(KeyboardSensor)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState(null);
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
const ignoreNextDrawerCloseRef = useRef(false);
|
||||||
|
|
||||||
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||||
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||||
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (closeDrawerRef) {
|
||||||
|
closeDrawerRef.current = () => setCardSheetRow(null);
|
||||||
|
return () => { closeDrawerRef.current = null; };
|
||||||
|
}
|
||||||
|
}, [closeDrawerRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onToggleFavoriteRef.current = onToggleFavorite;
|
onToggleFavoriteRef.current = onToggleFavorite;
|
||||||
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||||
@@ -192,6 +224,7 @@ export default function MobileFundTable({
|
|||||||
return [...valid, ...missing];
|
return [...valid, ...missing];
|
||||||
})() : null,
|
})() : null,
|
||||||
mobileTableColumnVisibility: visibility,
|
mobileTableColumnVisibility: visibility,
|
||||||
|
mobileShowFullFundName: group.mobileShowFullFundName === true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return byGroup;
|
return byGroup;
|
||||||
@@ -200,6 +233,7 @@ export default function MobileFundTable({
|
|||||||
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
|
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
|
||||||
|
|
||||||
const currentGroupMobile = configByGroup[groupKey];
|
const currentGroupMobile = configByGroup[groupKey];
|
||||||
|
const showFullFundName = currentGroupMobile?.mobileShowFullFundName ?? false;
|
||||||
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
||||||
const defaultVisibility = (() => {
|
const defaultVisibility = (() => {
|
||||||
const o = {};
|
const o = {};
|
||||||
@@ -247,9 +281,49 @@ export default function MobileFundTable({
|
|||||||
: nextOrUpdater;
|
: nextOrUpdater;
|
||||||
persistMobileGroupConfig({ mobileTableColumnVisibility: next });
|
persistMobileGroupConfig({ mobileTableColumnVisibility: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistShowFullFundName = (show) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('customSettings');
|
||||||
|
const parsed = raw ? JSON.parse(raw) : {};
|
||||||
|
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
|
||||||
|
group.mobileShowFullFundName = show;
|
||||||
|
parsed[groupKey] = group;
|
||||||
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
|
setConfigByGroup((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[groupKey]: { ...prev[groupKey], mobileShowFullFundName: show }
|
||||||
|
}));
|
||||||
|
onCustomSettingsChange?.();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleShowFullFundName = (show) => {
|
||||||
|
persistShowFullFundName(show);
|
||||||
|
};
|
||||||
|
|
||||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sortBy !== 'default') setIsNameSortMode(false);
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
// 排序模式下,点击页面任意区域(含表格外)退出排序;使用冒泡阶段,避免先于排序按钮处理
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNameSortMode) return;
|
||||||
|
const onDocClick = () => setIsNameSortMode(false);
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
return () => document.removeEventListener('click', onDocClick);
|
||||||
|
}, [isNameSortMode]);
|
||||||
|
|
||||||
|
const [cardSheetRow, setCardSheetRow] = useState(null);
|
||||||
const tableContainerRef = useRef(null);
|
const tableContainerRef = useRef(null);
|
||||||
|
const portalHeaderRef = useRef(null);
|
||||||
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [showPortalHeader, setShowPortalHeader] = useState(false);
|
||||||
|
const [effectiveStickyTop, setEffectiveStickyTop] = useState(stickyTop);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = tableContainerRef.current;
|
const el = tableContainerRef.current;
|
||||||
@@ -261,6 +335,93 @@ export default function MobileFundTable({
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const getEffectiveStickyTop = () => {
|
||||||
|
const stickySummaryCard = document.querySelector('.group-summary-sticky .group-summary-card');
|
||||||
|
if (!stickySummaryCard) return stickyTop;
|
||||||
|
|
||||||
|
const stickySummaryWrapper = stickySummaryCard.closest('.group-summary-sticky');
|
||||||
|
if (!stickySummaryWrapper) return stickyTop;
|
||||||
|
|
||||||
|
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
|
||||||
|
const isSummaryStuck = wrapperRect.top <= stickyTop + 1;
|
||||||
|
|
||||||
|
return isSummaryStuck ? stickyTop + stickySummaryWrapper.offsetHeight : stickyTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVerticalState = () => {
|
||||||
|
const nextStickyTop = getEffectiveStickyTop();
|
||||||
|
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
|
||||||
|
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
const tableRect = tableEl?.getBoundingClientRect();
|
||||||
|
if (!tableRect) {
|
||||||
|
setShowPortalHeader(window.scrollY >= nextStickyTop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||||
|
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||||
|
const hasPassedHeader = (tableRect.top + headerHeight) <= nextStickyTop;
|
||||||
|
const hasTableInView = tableRect.bottom > nextStickyTop;
|
||||||
|
|
||||||
|
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||||
|
};
|
||||||
|
|
||||||
|
const throttledVerticalUpdate = throttle(updateVerticalState, 1000/60, { leading: true, trailing: true });
|
||||||
|
|
||||||
|
updateVerticalState();
|
||||||
|
window.addEventListener('scroll', throttledVerticalUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', throttledVerticalUpdate, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', throttledVerticalUpdate);
|
||||||
|
window.removeEventListener('resize', throttledVerticalUpdate);
|
||||||
|
throttledVerticalUpdate.cancel();
|
||||||
|
};
|
||||||
|
}, [stickyTop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
if (!tableEl) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(tableEl.scrollLeft > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll();
|
||||||
|
tableEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tableEl.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
const portalEl = portalHeaderRef.current;
|
||||||
|
if (!tableEl || !portalEl) return;
|
||||||
|
|
||||||
|
const syncScrollToPortal = () => {
|
||||||
|
portalEl.scrollLeft = tableEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncScrollToTable = () => {
|
||||||
|
tableEl.scrollLeft = portalEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
syncScrollToPortal();
|
||||||
|
|
||||||
|
const handleTableScroll = () => syncScrollToPortal();
|
||||||
|
const handlePortalScroll = () => syncScrollToTable();
|
||||||
|
|
||||||
|
tableEl.addEventListener('scroll', handleTableScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tableEl.removeEventListener('scroll', handleTableScroll);
|
||||||
|
};
|
||||||
|
}, [showPortalHeader]);
|
||||||
|
|
||||||
const NAME_CELL_WIDTH = 140;
|
const NAME_CELL_WIDTH = 140;
|
||||||
const GAP = 12;
|
const GAP = 12;
|
||||||
const LAST_COLUMN_EXTRA = 12;
|
const LAST_COLUMN_EXTRA = 12;
|
||||||
@@ -270,6 +431,7 @@ export default function MobileFundTable({
|
|||||||
estimateNav: 64,
|
estimateNav: 64,
|
||||||
yesterdayChangePercent: 72,
|
yesterdayChangePercent: 72,
|
||||||
estimateChangePercent: 80,
|
estimateChangePercent: 80,
|
||||||
|
totalChangePercent: 80,
|
||||||
todayProfit: 80,
|
todayProfit: 80,
|
||||||
holdingProfit: 80,
|
holdingProfit: 80,
|
||||||
};
|
};
|
||||||
@@ -305,8 +467,9 @@ export default function MobileFundTable({
|
|||||||
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 移动端名称列:无拖拽把手,长按整行触发排序
|
// 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps)
|
||||||
const MobileFundNameCell = ({ info }) => {
|
// 当 isNameSortMode 且 sortBy==='default' 时,左侧显示排序/拖拽图标,可拖动行排序
|
||||||
|
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet, isNameSortMode: nameSortMode, sortBy: currentSortBy }) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const code = original.code;
|
const code = original.code;
|
||||||
const isUpdated = original.isUpdated;
|
const isUpdated = original.isUpdated;
|
||||||
@@ -315,10 +478,23 @@ export default function MobileFundTable({
|
|||||||
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||||
const isFavorites = favorites?.has?.(code);
|
const isFavorites = favorites?.has?.(code);
|
||||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||||
|
const rowSortable = useContext(RowSortableContext);
|
||||||
|
const showDragHandle = nameSortMode && currentSortBy === 'default' && rowSortable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{isGroupTab ? (
|
{showDragHandle ? (
|
||||||
|
<span
|
||||||
|
ref={rowSortable.setActivatorNodeRef}
|
||||||
|
className="icon-button fav-button"
|
||||||
|
title="拖动排序"
|
||||||
|
style={{ backgroundColor: 'transparent', touchAction: 'none', cursor: 'grab', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
{...rowSortable.listeners}
|
||||||
|
>
|
||||||
|
<DragIcon width="18" height="18" />
|
||||||
|
</span>
|
||||||
|
) : isGroupTab ? (
|
||||||
<button
|
<button
|
||||||
className="icon-button fav-button"
|
className="icon-button fav-button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -344,7 +520,25 @@ export default function MobileFundTable({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="title-text">
|
<div className="title-text">
|
||||||
<span className="name-text" title={isUpdated ? '今日净值已更新' : ''}>
|
<span
|
||||||
|
className={`name-text ${showFullFundName ? 'show-full' : ''}`}
|
||||||
|
title={isUpdated ? '今日净值已更新' : onOpenCardSheet ? '点击查看卡片' : ''}
|
||||||
|
role={onOpenCardSheet ? 'button' : undefined}
|
||||||
|
tabIndex={onOpenCardSheet ? 0 : undefined}
|
||||||
|
style={onOpenCardSheet ? { cursor: 'pointer' } : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onOpenCardSheet) {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
onOpenCardSheet(original);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (onOpenCardSheet && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenCardSheet(original);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{info.getValue() ?? '—'}
|
{info.getValue() ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
{holdingAmountDisplay ? (
|
{holdingAmountDisplay ? (
|
||||||
@@ -427,38 +621,87 @@ export default function MobileFundTable({
|
|||||||
>
|
>
|
||||||
<SettingsIcon width="18" height="18" />
|
<SettingsIcon width="18" height="18" />
|
||||||
</button>
|
</button>
|
||||||
|
{sortBy === 'default' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`icon-button ${isNameSortMode ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
setIsNameSortMode((prev) => !prev);
|
||||||
|
}}
|
||||||
|
title={isNameSortMode ? '退出排序' : '拖动排序'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
minWidth: '28px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: isNameSortMode ? 'var(--primary)' : 'var(--text)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortIcon width="18" height="18" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: (info) => <MobileFundNameCell info={info} />,
|
cell: (info) => (
|
||||||
|
<MobileFundNameCell
|
||||||
|
info={info}
|
||||||
|
showFullFundName={showFullFundName}
|
||||||
|
onOpenCardSheet={getFundCardProps ? (row) => setCardSheetRow(row) : undefined}
|
||||||
|
isNameSortMode={isNameSortMode}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
),
|
||||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'latestNav',
|
accessorKey: 'latestNav',
|
||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
cell: (info) => (
|
cell: (info) => {
|
||||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
const original = info.row.original || {};
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
const date = original.latestNavDate ?? '-';
|
||||||
{info.getValue() ?? '—'}
|
return (
|
||||||
</FitText>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
</span>
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
),
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'estimateNav',
|
accessorKey: 'estimateNav',
|
||||||
header: '估算净值',
|
header: '估算净值',
|
||||||
cell: (info) => (
|
cell: (info) => {
|
||||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
const original = info.row.original || {};
|
||||||
<FitText maxFontSize={14} minFontSize={10}>
|
const date = original.estimateNavDate ?? '-';
|
||||||
{info.getValue() ?? '—'}
|
const displayDate = typeof date === 'string' && date.length > 5 ? date.slice(5) : date;
|
||||||
</FitText>
|
|
||||||
</span>
|
return (
|
||||||
),
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: '10px' }}>{displayDate}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'yesterdayChangePercent',
|
accessorKey: 'yesterdayChangePercent',
|
||||||
header: '昨日涨跌幅',
|
header: '昨日涨幅',
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const value = original.yesterdayChangeValue;
|
const value = original.yesterdayChangeValue;
|
||||||
@@ -477,7 +720,7 @@ export default function MobileFundTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'estimateChangePercent',
|
accessorKey: 'estimateChangePercent',
|
||||||
header: '估值涨跌幅',
|
header: '估值涨幅',
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const value = original.estimateChangeValue;
|
const value = original.estimateChangeValue;
|
||||||
@@ -496,6 +739,36 @@ export default function MobileFundTable({
|
|||||||
},
|
},
|
||||||
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
|
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'totalChangePercent',
|
||||||
|
header: '估算收益',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.estimateProfitValue;
|
||||||
|
const hasProfit = value != null;
|
||||||
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasProfit ? (original.estimateProfit ?? '') : '—';
|
||||||
|
const percentStr = original.estimateProfitPercent ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||||
|
<FitText maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'right', cellClassName: 'total-change-cell', width: columnWidthMap.totalChangePercent },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'todayProfit',
|
accessorKey: 'todayProfit',
|
||||||
header: '当日收益',
|
header: '当日收益',
|
||||||
@@ -506,6 +779,7 @@ export default function MobileFundTable({
|
|||||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||||
const percentStr = original.todayProfitPercent ?? '';
|
const percentStr = original.todayProfitPercent ?? '';
|
||||||
|
const isUpdated = original.isUpdated;
|
||||||
return (
|
return (
|
||||||
<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 }}>
|
||||||
@@ -513,7 +787,7 @@ export default function MobileFundTable({
|
|||||||
{amountStr}
|
{amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
</span>
|
</span>
|
||||||
{percentStr ? (
|
{percentStr && !isUpdated ? (
|
||||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
<span className={`${cls} today-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}
|
||||||
@@ -555,7 +829,7 @@ export default function MobileFundTable({
|
|||||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing, columnWidthMap]
|
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy]
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -656,124 +930,210 @@ export default function MobileFundTable({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const getPinClass = (columnId, isHeader) => {
|
const getPinClass = (columnId, isHeader) => {
|
||||||
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
|
if (columnId === 'fundName') {
|
||||||
|
const baseClass = isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
|
||||||
|
const scrolledClass = isScrolled ? 'is-scrolled' : '';
|
||||||
|
return `${baseClass} ${scrolledClass}`.trim();
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAlignClass = (columnId) => {
|
const getAlignClass = (columnId) => {
|
||||||
if (columnId === 'fundName') return '';
|
if (columnId === 'fundName') return '';
|
||||||
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||||
return 'text-right';
|
return 'text-right';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderTableHeader = ()=>{
|
||||||
<div className="mobile-fund-table" ref={tableContainerRef}>
|
if(!headerGroup) return null;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="mobile-fund-table-scroll"
|
className="table-header-row mobile-fund-table-header"
|
||||||
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
||||||
>
|
>
|
||||||
{headerGroup && (
|
{headerGroup.headers.map((header, headerIndex) => {
|
||||||
|
const columnId = header.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, true);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${alignClass} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = (onlyShowHeader) => {
|
||||||
|
if (onlyShowHeader) {
|
||||||
|
return (
|
||||||
|
<div style={{position: 'fixed', top: effectiveStickyTop}} className="mobile-fund-table mobile-fund-table-portal-header" ref={portalHeaderRef}>
|
||||||
<div
|
<div
|
||||||
className="table-header-row mobile-fund-table-header"
|
className="mobile-fund-table-scroll"
|
||||||
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||||
>
|
>
|
||||||
{headerGroup.headers.map((header, headerIndex) => {
|
{renderTableHeader()}
|
||||||
const columnId = header.column.id;
|
</div>
|
||||||
const pinClass = getPinClass(columnId, true);
|
</div>
|
||||||
const alignClass = getAlignClass(columnId);
|
);
|
||||||
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
|
}
|
||||||
return (
|
|
||||||
<div
|
return (
|
||||||
key={header.id}
|
<div className="mobile-fund-table" ref={tableContainerRef}>
|
||||||
className={`table-header-cell ${alignClass} ${pinClass}`}
|
<div
|
||||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
className="mobile-fund-table-scroll"
|
||||||
>
|
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||||
{header.isPlaceholder
|
>
|
||||||
? null
|
{renderTableHeader()}
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</div>
|
{!onlyShowHeader && (
|
||||||
);
|
<DndContext
|
||||||
})}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={data.map((item) => item.code)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<SortableRow
|
||||||
|
key={row.original.code || row.id}
|
||||||
|
row={row}
|
||||||
|
isTableDragging={!!activeId}
|
||||||
|
disabled={sortBy !== 'default'}
|
||||||
|
>
|
||||||
|
{(setActivatorNodeRef, listeners) => (
|
||||||
|
<div
|
||||||
|
ref={sortBy === 'default' && !isNameSortMode ? setActivatorNodeRef : undefined}
|
||||||
|
className="table-row"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||||
|
}}
|
||||||
|
onClick={isNameSortMode ? () => setIsNameSortMode(false) : undefined}
|
||||||
|
{...(sortBy === 'default' && !isNameSortMode ? listeners : {})}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||||
|
const columnId = cell.column.id;
|
||||||
|
const pinClass = getPinClass(columnId, false);
|
||||||
|
const alignClass = getAlignClass(columnId);
|
||||||
|
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
||||||
|
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
||||||
|
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{table.getRowModel().rows.length === 0 && !onlyShowHeader && (
|
||||||
|
<div className="table-row empty-row">
|
||||||
|
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||||
|
<span className="muted">暂无数据</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DndContext
|
{!onlyShowHeader && (
|
||||||
sensors={sensors}
|
<MobileSettingModal
|
||||||
collisionDetection={closestCenter}
|
open={settingModalOpen}
|
||||||
onDragStart={handleDragStart}
|
onClose={() => setSettingModalOpen(false)}
|
||||||
onDragEnd={handleDragEnd}
|
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
|
||||||
onDragCancel={handleDragCancel}
|
columnVisibility={mobileColumnVisibility}
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
onColumnReorder={(newOrder) => {
|
||||||
|
setMobileColumnOrder(newOrder);
|
||||||
|
}}
|
||||||
|
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
|
||||||
|
onResetColumnOrder={handleResetMobileColumnOrder}
|
||||||
|
onResetColumnVisibility={handleResetMobileColumnVisibility}
|
||||||
|
showFullFundName={showFullFundName}
|
||||||
|
onToggleShowFullFundName={handleToggleShowFullFundName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!(cardSheetRow && getFundCardProps)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
if (ignoreNextDrawerCloseRef.current) {
|
||||||
|
ignoreNextDrawerCloseRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!blockDrawerClose) setCardSheetRow(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<DrawerContent
|
||||||
items={data.map((item) => item.code)}
|
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
|
||||||
strategy={verticalListSortingStrategy}
|
onPointerDownOutside={(e) => {
|
||||||
|
if (blockDrawerClose) return;
|
||||||
|
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
||||||
|
ignoreNextDrawerCloseRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCardSheetRow(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="popLayout">
|
<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">
|
||||||
{table.getRowModel().rows.map((row) => (
|
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
|
||||||
<SortableRow
|
基金详情
|
||||||
key={row.original.code || row.id}
|
</DrawerTitle>
|
||||||
row={row}
|
<DrawerClose
|
||||||
isTableDragging={!!activeId}
|
className="icon-button border-none bg-transparent p-1"
|
||||||
disabled={sortBy !== 'default'}
|
title="关闭"
|
||||||
>
|
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
|
||||||
{(setActivatorNodeRef, listeners) => (
|
>
|
||||||
<div
|
<CloseIcon width="20" height="20" />
|
||||||
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
|
</DrawerClose>
|
||||||
className="table-row"
|
</DrawerHeader>
|
||||||
style={{
|
<div
|
||||||
background: 'var(--bg)',
|
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
|
||||||
position: 'relative',
|
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
|
||||||
zIndex: 1,
|
>
|
||||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
{cardSheetRow && getFundCardProps ? (
|
||||||
}}
|
<FundCard {...getFundCardProps(cardSheetRow)} />
|
||||||
{...(sortBy === 'default' ? listeners : {})}
|
) : null}
|
||||||
>
|
</div>
|
||||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
</DrawerContent>
|
||||||
const columnId = cell.column.id;
|
</Drawer>
|
||||||
const pinClass = getPinClass(columnId, false);
|
|
||||||
const alignClass = getAlignClass(columnId);
|
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
||||||
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
|
|
||||||
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cell.id}
|
|
||||||
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
|
|
||||||
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SortableRow>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{table.getRowModel().rows.length === 0 && (
|
return (
|
||||||
<div className="table-row empty-row">
|
<>
|
||||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
{renderContent()}
|
||||||
<span className="muted">暂无数据</span>
|
</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MobileSettingModal
|
|
||||||
open={settingModalOpen}
|
|
||||||
onClose={() => setSettingModalOpen(false)}
|
|
||||||
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
|
|
||||||
columnVisibility={mobileColumnVisibility}
|
|
||||||
onColumnReorder={(newOrder) => {
|
|
||||||
setMobileColumnOrder(newOrder);
|
|
||||||
}}
|
|
||||||
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
|
|
||||||
onResetColumnOrder={handleResetMobileColumnOrder}
|
|
||||||
onResetColumnVisibility={handleResetMobileColumnVisibility}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||||
import { createPortal } from 'react-dom';
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移动端表格个性化设置弹框(底部抽屉)
|
* 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件)
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {boolean} props.open - 是否打开
|
* @param {boolean} props.open - 是否打开
|
||||||
* @param {() => void} props.onClose - 关闭回调
|
* @param {() => void} props.onClose - 关闭回调
|
||||||
@@ -17,6 +24,8 @@ import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
|||||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
||||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||||
|
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||||
|
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||||
*/
|
*/
|
||||||
export default function MobileSettingModal({
|
export default function MobileSettingModal({
|
||||||
open,
|
open,
|
||||||
@@ -27,6 +36,8 @@ export default function MobileSettingModal({
|
|||||||
onToggleColumnVisibility,
|
onToggleColumnVisibility,
|
||||||
onResetColumnOrder,
|
onResetColumnOrder,
|
||||||
onResetColumnVisibility,
|
onResetColumnVisibility,
|
||||||
|
showFullFundName,
|
||||||
|
onToggleShowFullFundName,
|
||||||
}) {
|
}) {
|
||||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
@@ -34,187 +45,171 @@ export default function MobileSettingModal({
|
|||||||
if (!open) setResetConfirmOpen(false);
|
if (!open) setResetConfirmOpen(false);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = prev;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleReorder = (newItems) => {
|
const handleReorder = (newItems) => {
|
||||||
const newOrder = newItems.map((item) => item.id);
|
const newOrder = newItems.map((item) => item.id);
|
||||||
onColumnReorder?.(newOrder);
|
onColumnReorder?.(newOrder);
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
return (
|
||||||
<AnimatePresence>
|
<>
|
||||||
{open && (
|
<Drawer
|
||||||
<motion.div
|
open={open}
|
||||||
key="mobile-setting-overlay"
|
onOpenChange={(v) => {
|
||||||
className="mobile-setting-overlay"
|
if (!v) onClose();
|
||||||
role="dialog"
|
}}
|
||||||
aria-modal="true"
|
direction="bottom"
|
||||||
aria-label="个性化设置"
|
>
|
||||||
initial={{ opacity: 0 }}
|
<DrawerContent
|
||||||
animate={{ opacity: 1 }}
|
className="glass"
|
||||||
exit={{ opacity: 0 }}
|
defaultHeight="77vh"
|
||||||
transition={{ duration: 0.2 }}
|
minHeight="40vh"
|
||||||
onClick={onClose}
|
maxHeight="90vh"
|
||||||
style={{ zIndex: 10001 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<DrawerHeader className="mobile-setting-header flex-row items-center justify-between gap-2 py-5 pt-5 text-base font-semibold">
|
||||||
className="mobile-setting-drawer glass"
|
<DrawerTitle className="flex items-center gap-2.5 text-left">
|
||||||
initial={{ y: '100%' }}
|
<SettingsIcon width="20" height="20" />
|
||||||
animate={{ y: 0 }}
|
<span>个性化设置</span>
|
||||||
exit={{ y: '100%' }}
|
</DrawerTitle>
|
||||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
<DrawerClose
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="icon-button border-none bg-transparent p-1"
|
||||||
>
|
title="关闭"
|
||||||
<div className="mobile-setting-header">
|
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
>
|
||||||
<SettingsIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
<span>个性化设置</span>
|
</DrawerClose>
|
||||||
</div>
|
</DrawerHeader>
|
||||||
<button
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onClose}
|
|
||||||
title="关闭"
|
|
||||||
style={{ border: 'none', background: 'transparent' }}
|
|
||||||
>
|
|
||||||
<CloseIcon width="20" height="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mobile-setting-body">
|
<div className="mobile-setting-body flex flex-1 flex-col overflow-y-auto">
|
||||||
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
{onToggleShowFullFundName && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 12,
|
padding: '12px 0',
|
||||||
gap: 8,
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||||
拖拽调整列顺序
|
<Switch
|
||||||
</p>
|
checked={!!showFullFundName}
|
||||||
{(onResetColumnOrder || onResetColumnVisibility) && (
|
onCheckedChange={(checked) => {
|
||||||
<button
|
onToggleShowFullFundName?.(!!checked);
|
||||||
className="icon-button"
|
}}
|
||||||
onClick={() => setResetConfirmOpen(true)}
|
title={showFullFundName ? '关闭' : '开启'}
|
||||||
title="重置表头设置"
|
/>
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ResetIcon width="16" height="16" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{columns.length === 0 ? (
|
)}
|
||||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||||
暂无可配置列
|
<div
|
||||||
</div>
|
style={{
|
||||||
) : (
|
display: 'flex',
|
||||||
<Reorder.Group
|
alignItems: 'center',
|
||||||
axis="y"
|
justifyContent: 'space-between',
|
||||||
values={columns}
|
marginBottom: 12,
|
||||||
onReorder={handleReorder}
|
gap: 8,
|
||||||
className="mobile-setting-list"
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||||
|
拖拽调整列顺序
|
||||||
|
</p>
|
||||||
|
{(onResetColumnOrder || onResetColumnVisibility) && (
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetConfirmOpen(true)}
|
||||||
|
title="重置表头设置"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="popLayout">
|
<ResetIcon width="16" height="16" />
|
||||||
{columns.map((item, index) => (
|
</button>
|
||||||
<Reorder.Item
|
|
||||||
key={item.id || `col-${index}`}
|
|
||||||
value={item}
|
|
||||||
className="mobile-setting-item glass"
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 500,
|
|
||||||
damping: 35,
|
|
||||||
mass: 1,
|
|
||||||
layout: { duration: 0.2 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="drag-handle"
|
|
||||||
style={{
|
|
||||||
cursor: 'grab',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0 8px',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DragIcon width="18" height="18" />
|
|
||||||
</div>
|
|
||||||
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
|
||||||
{onToggleColumnVisibility && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button pc-table-column-switch"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
|
|
||||||
}}
|
|
||||||
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
padding: '0 4px',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
|
|
||||||
<span
|
|
||||||
className="dca-toggle-thumb"
|
|
||||||
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Reorder.Item>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Reorder.Group>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
{columns.length === 0 ? (
|
||||||
</motion.div>
|
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||||
)}
|
暂无可配置列
|
||||||
{resetConfirmOpen && (
|
</div>
|
||||||
<ConfirmModal
|
) : (
|
||||||
key="mobile-reset-confirm"
|
<Reorder.Group
|
||||||
title="重置表头设置"
|
axis="y"
|
||||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
values={columns}
|
||||||
onConfirm={() => {
|
onReorder={handleReorder}
|
||||||
onResetColumnOrder?.();
|
className="mobile-setting-list"
|
||||||
onResetColumnVisibility?.();
|
>
|
||||||
setResetConfirmOpen(false);
|
<AnimatePresence mode="popLayout">
|
||||||
}}
|
{columns.map((item, index) => (
|
||||||
onCancel={() => setResetConfirmOpen(false)}
|
<Reorder.Item
|
||||||
confirmText="重置"
|
key={item.id || `col-${index}`}
|
||||||
/>
|
value={item}
|
||||||
)}
|
className="mobile-setting-item glass"
|
||||||
</AnimatePresence>
|
layout
|
||||||
);
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 35,
|
||||||
|
mass: 1,
|
||||||
|
layout: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drag-handle"
|
||||||
|
style={{
|
||||||
|
cursor: 'grab',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 8px',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragIcon width="18" height="18" />
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
||||||
|
{onToggleColumnVisibility && (
|
||||||
|
<Switch
|
||||||
|
checked={columnVisibility?.[item.id] !== false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onToggleColumnVisibility(item.id, !!checked);
|
||||||
|
}}
|
||||||
|
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
if (typeof document === 'undefined') return null;
|
<AnimatePresence>
|
||||||
return createPortal(content, document.body);
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
key="mobile-reset-confirm"
|
||||||
|
title="重置表头设置"
|
||||||
|
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetColumnOrder?.();
|
||||||
|
onResetColumnVisibility?.();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
confirmText="重置"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -25,11 +27,19 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import FitText from './FitText';
|
import FitText from './FitText';
|
||||||
import PcTableSettingModal from './PcTableSettingModal';
|
import PcTableSettingModal from './PcTableSettingModal';
|
||||||
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
import FundCard from './FundCard';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||||
|
|
||||||
const NON_FROZEN_COLUMN_IDS = [
|
const NON_FROZEN_COLUMN_IDS = [
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
|
'totalChangePercent',
|
||||||
'holdingAmount',
|
'holdingAmount',
|
||||||
'todayProfit',
|
'todayProfit',
|
||||||
'holdingProfit',
|
'holdingProfit',
|
||||||
@@ -39,8 +49,9 @@ const NON_FROZEN_COLUMN_IDS = [
|
|||||||
const COLUMN_HEADERS = {
|
const COLUMN_HEADERS = {
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨跌幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
estimateChangePercent: '估值涨跌幅',
|
estimateChangePercent: '估值涨幅',
|
||||||
|
totalChangePercent: '估算收益',
|
||||||
holdingAmount: '持仓金额',
|
holdingAmount: '持仓金额',
|
||||||
todayProfit: '当日收益',
|
todayProfit: '当日收益',
|
||||||
holdingProfit: '持有收益',
|
holdingProfit: '持有收益',
|
||||||
@@ -103,8 +114,8 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
|||||||
* code?: string; // 基金代码(可选,只用于展示在名称下方)
|
* code?: string; // 基金代码(可选,只用于展示在名称下方)
|
||||||
* latestNav: string|number; // 最新净值
|
* latestNav: string|number; // 最新净值
|
||||||
* estimateNav: string|number; // 估算净值
|
* estimateNav: string|number; // 估算净值
|
||||||
* yesterdayChangePercent: string|number; // 昨日涨跌幅
|
* yesterdayChangePercent: string|number; // 昨日涨幅
|
||||||
* estimateChangePercent: string|number; // 估值涨跌幅
|
* estimateChangePercent: string|number; // 估值涨幅
|
||||||
* holdingAmount: string|number; // 持仓金额
|
* holdingAmount: string|number; // 持仓金额
|
||||||
* todayProfit: string|number; // 当日收益
|
* todayProfit: string|number; // 当日收益
|
||||||
* holdingProfit: string|number; // 持有收益
|
* holdingProfit: string|number; // 持有收益
|
||||||
@@ -116,6 +127,10 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
|||||||
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
||||||
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
||||||
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
|
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
|
||||||
|
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用弹框展示卡片详情
|
||||||
|
* @param {React.MutableRefObject<(() => void) | null>} [props.closeDialogRef] - 注入关闭弹框的方法,用于确认删除时关闭
|
||||||
|
* @param {boolean} [props.blockDialogClose] - 为 true 时阻止点击遮罩关闭弹框(如删除确认弹框打开时)
|
||||||
|
* @param {number} [props.stickyTop] - 表头固定时的 top 偏移(与 MobileFundTable 一致,用于适配导航栏、筛选栏等)
|
||||||
*/
|
*/
|
||||||
export default function PcFundTable({
|
export default function PcFundTable({
|
||||||
data = [],
|
data = [],
|
||||||
@@ -130,6 +145,10 @@ export default function PcFundTable({
|
|||||||
sortBy = 'default',
|
sortBy = 'default',
|
||||||
onReorder,
|
onReorder,
|
||||||
onCustomSettingsChange,
|
onCustomSettingsChange,
|
||||||
|
getFundCardProps,
|
||||||
|
closeDialogRef,
|
||||||
|
blockDialogClose = false,
|
||||||
|
stickyTop = 0,
|
||||||
}) {
|
}) {
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -141,6 +160,12 @@ export default function PcFundTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState(null);
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
const [cardDialogRow, setCardDialogRow] = useState(null);
|
||||||
|
const tableContainerRef = useRef(null);
|
||||||
|
const portalHeaderRef = useRef(null);
|
||||||
|
const [showPortalHeader, setShowPortalHeader] = useState(false);
|
||||||
|
const [effectiveStickyTop, setEffectiveStickyTop] = useState(stickyTop);
|
||||||
|
const [portalHorizontal, setPortalHorizontal] = useState({ left: 0, right: 0 });
|
||||||
|
|
||||||
const handleDragStart = (event) => {
|
const handleDragStart = (event) => {
|
||||||
setActiveId(event.active.id);
|
setActiveId(event.active.id);
|
||||||
@@ -234,6 +259,7 @@ export default function PcFundTable({
|
|||||||
})() : null,
|
})() : null,
|
||||||
pcTableColumnVisibility: pc.visibility,
|
pcTableColumnVisibility: pc.visibility,
|
||||||
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
|
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
|
||||||
|
pcShowFullFundName: group.pcShowFullFundName === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -243,6 +269,7 @@ export default function PcFundTable({
|
|||||||
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
|
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
|
||||||
|
|
||||||
const currentGroupPc = configByGroup[groupKey];
|
const currentGroupPc = configByGroup[groupKey];
|
||||||
|
const showFullFundName = currentGroupPc?.pcShowFullFundName ?? false;
|
||||||
const defaultPc = getDefaultPcGroupConfig();
|
const defaultPc = getDefaultPcGroupConfig();
|
||||||
const columnOrder = (() => {
|
const columnOrder = (() => {
|
||||||
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
|
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
|
||||||
@@ -280,6 +307,7 @@ export default function PcFundTable({
|
|||||||
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
|
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
|
||||||
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
|
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
|
||||||
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
|
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
|
||||||
|
if (updates.pcShowFullFundName !== undefined) group.pcShowFullFundName = updates.pcShowFullFundName;
|
||||||
parsed[groupKey] = group;
|
parsed[groupKey] = group;
|
||||||
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||||
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
||||||
@@ -287,6 +315,10 @@ export default function PcFundTable({
|
|||||||
} catch { }
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleShowFullFundName = (show) => {
|
||||||
|
persistPcGroupConfig({ pcShowFullFundName: show });
|
||||||
|
};
|
||||||
|
|
||||||
const setColumnOrder = (nextOrderOrUpdater) => {
|
const setColumnOrder = (nextOrderOrUpdater) => {
|
||||||
const next = typeof nextOrderOrUpdater === 'function'
|
const next = typeof nextOrderOrUpdater === 'function'
|
||||||
? nextOrderOrUpdater(columnOrder)
|
? nextOrderOrUpdater(columnOrder)
|
||||||
@@ -332,6 +364,13 @@ export default function PcFundTable({
|
|||||||
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||||
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (closeDialogRef) {
|
||||||
|
closeDialogRef.current = () => setCardDialogRow(null);
|
||||||
|
return () => { closeDialogRef.current = null; };
|
||||||
|
}
|
||||||
|
}, [closeDialogRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onRemoveFundRef.current = onRemoveFund;
|
onRemoveFundRef.current = onRemoveFund;
|
||||||
onToggleFavoriteRef.current = onToggleFavorite;
|
onToggleFavoriteRef.current = onToggleFavorite;
|
||||||
@@ -344,7 +383,93 @@ export default function PcFundTable({
|
|||||||
onHoldingAmountClick,
|
onHoldingAmountClick,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const FundNameCell = ({ info }) => {
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const getEffectiveStickyTop = () => {
|
||||||
|
const stickySummaryCard = document.querySelector('.group-summary-sticky .group-summary-card');
|
||||||
|
if (!stickySummaryCard) return stickyTop;
|
||||||
|
|
||||||
|
const stickySummaryWrapper = stickySummaryCard.closest('.group-summary-sticky');
|
||||||
|
if (!stickySummaryWrapper) return stickyTop;
|
||||||
|
|
||||||
|
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
|
||||||
|
const isSummaryStuck = wrapperRect.top <= stickyTop + 1;
|
||||||
|
|
||||||
|
return isSummaryStuck ? stickyTop + stickySummaryWrapper.offsetHeight : stickyTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVerticalState = () => {
|
||||||
|
const nextStickyTop = getEffectiveStickyTop();
|
||||||
|
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
|
||||||
|
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
const scrollEl = tableEl?.closest('.table-scroll-area');
|
||||||
|
const targetEl = scrollEl || tableEl;
|
||||||
|
const rect = targetEl?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!rect) {
|
||||||
|
setShowPortalHeader(window.scrollY >= nextStickyTop);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerEl = tableEl?.querySelector('.table-header-row');
|
||||||
|
const headerHeight = headerEl?.getBoundingClientRect?.().height ?? 0;
|
||||||
|
const hasPassedHeader = (rect.top + headerHeight) <= nextStickyTop;
|
||||||
|
const hasTableInView = rect.bottom > nextStickyTop;
|
||||||
|
|
||||||
|
setShowPortalHeader(hasPassedHeader && hasTableInView);
|
||||||
|
|
||||||
|
setPortalHorizontal((prev) => {
|
||||||
|
const next = {
|
||||||
|
left: rect.left,
|
||||||
|
right: typeof window !== 'undefined' ? Math.max(0, window.innerWidth - rect.right) : 0,
|
||||||
|
};
|
||||||
|
if (prev.left === next.left && prev.right === next.right) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const throttledVerticalUpdate = throttle(updateVerticalState, 1000 / 60, { leading: true, trailing: true });
|
||||||
|
|
||||||
|
updateVerticalState();
|
||||||
|
window.addEventListener('scroll', throttledVerticalUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', throttledVerticalUpdate, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', throttledVerticalUpdate);
|
||||||
|
window.removeEventListener('resize', throttledVerticalUpdate);
|
||||||
|
throttledVerticalUpdate.cancel();
|
||||||
|
};
|
||||||
|
}, [stickyTop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableEl = tableContainerRef.current;
|
||||||
|
const portalEl = portalHeaderRef.current;
|
||||||
|
const scrollEl = tableEl?.closest('.table-scroll-area');
|
||||||
|
if (!scrollEl || !portalEl) return;
|
||||||
|
|
||||||
|
const syncScrollToPortal = () => {
|
||||||
|
portalEl.scrollLeft = scrollEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncScrollToTable = () => {
|
||||||
|
scrollEl.scrollLeft = portalEl.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
syncScrollToPortal();
|
||||||
|
|
||||||
|
const handleTableScroll = () => syncScrollToPortal();
|
||||||
|
const handlePortalScroll = () => syncScrollToTable();
|
||||||
|
|
||||||
|
scrollEl.addEventListener('scroll', handleTableScroll, { passive: true });
|
||||||
|
portalEl.addEventListener('scroll', handlePortalScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollEl.removeEventListener('scroll', handleTableScroll);
|
||||||
|
portalEl.removeEventListener('scroll', handlePortalScroll);
|
||||||
|
};
|
||||||
|
}, [showPortalHeader]);
|
||||||
|
|
||||||
|
const FundNameCell = ({ info, showFullFundName, onOpenCardDialog }) => {
|
||||||
const original = info.row.original || {};
|
const original = info.row.original || {};
|
||||||
const code = original.code;
|
const code = original.code;
|
||||||
const isUpdated = original.isUpdated;
|
const isUpdated = original.isUpdated;
|
||||||
@@ -354,13 +479,13 @@ export default function PcFundTable({
|
|||||||
const rowContext = useContext(SortableRowContext);
|
const rowContext = useContext(SortableRowContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 8 }}>
|
||||||
{sortBy === 'default' && (
|
{sortBy === 'default' && (
|
||||||
<button
|
<button
|
||||||
className="icon-button drag-handle"
|
className="icon-button drag-handle"
|
||||||
ref={rowContext?.setActivatorNodeRef}
|
ref={rowContext?.setActivatorNodeRef}
|
||||||
{...rowContext?.listeners}
|
{...rowContext?.listeners}
|
||||||
style={{ cursor: 'grab', padding: 2, margin: '-2px -4px -2px 0', color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ cursor: 'grab', width: 20, height: 20, padding: 2, margin: '0', flexShrink: 0, color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
title="拖拽排序"
|
title="拖拽排序"
|
||||||
onClick={(e) => e.stopPropagation?.()}
|
onClick={(e) => e.stopPropagation?.()}
|
||||||
>
|
>
|
||||||
@@ -391,9 +516,17 @@ export default function PcFundTable({
|
|||||||
<StarIcon width="18" height="18" filled={isFavorites} />
|
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="title-text">
|
<div
|
||||||
|
className="title-text"
|
||||||
|
role={onOpenCardDialog ? 'button' : undefined}
|
||||||
|
tabIndex={onOpenCardDialog ? 0 : undefined}
|
||||||
|
onClick={onOpenCardDialog ? (e) => { e.stopPropagation?.(); onOpenCardDialog(original); } : undefined}
|
||||||
|
onKeyDown={onOpenCardDialog ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenCardDialog(original); } } : undefined}
|
||||||
|
style={onOpenCardDialog ? { cursor: 'pointer' } : undefined}
|
||||||
|
title={onOpenCardDialog ? '查看基金详情' : (original.isUpdated ? '今日净值已更新' : undefined)}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`name-text`}
|
className={`name-text ${showFullFundName ? 'show-full' : ''}`}
|
||||||
title={isUpdated ? '今日净值已更新' : ''}
|
title={isUpdated ? '今日净值已更新' : ''}
|
||||||
>
|
>
|
||||||
{info.getValue() ?? '—'}
|
{info.getValue() ?? '—'}
|
||||||
@@ -416,7 +549,13 @@ export default function PcFundTable({
|
|||||||
size: 265,
|
size: 265,
|
||||||
minSize: 140,
|
minSize: 140,
|
||||||
enablePinning: true,
|
enablePinning: true,
|
||||||
cell: (info) => <FundNameCell info={info} />,
|
cell: (info) => (
|
||||||
|
<FundNameCell
|
||||||
|
info={info}
|
||||||
|
showFullFundName={showFullFundName}
|
||||||
|
onOpenCardDialog={getFundCardProps ? (row) => setCardDialogRow(row) : undefined}
|
||||||
|
/>
|
||||||
|
),
|
||||||
meta: {
|
meta: {
|
||||||
align: 'left',
|
align: 'left',
|
||||||
cellClassName: 'name-cell',
|
cellClassName: 'name-cell',
|
||||||
@@ -427,11 +566,20 @@ export default function PcFundTable({
|
|||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
size: 100,
|
size: 100,
|
||||||
minSize: 80,
|
minSize: 80,
|
||||||
cell: (info) => (
|
cell: (info) => {
|
||||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
const original = info.row.original || {};
|
||||||
{info.getValue() ?? '—'}
|
const date = original.latestNavDate ?? '-';
|
||||||
</FitText>
|
return (
|
||||||
),
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cellClassName: 'value-cell',
|
cellClassName: 'value-cell',
|
||||||
@@ -442,11 +590,20 @@ export default function PcFundTable({
|
|||||||
header: '估算净值',
|
header: '估算净值',
|
||||||
size: 100,
|
size: 100,
|
||||||
minSize: 80,
|
minSize: 80,
|
||||||
cell: (info) => (
|
cell: (info) => {
|
||||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
const original = info.row.original || {};
|
||||||
{info.getValue() ?? '—'}
|
const date = original.estimateNavDate ?? '-';
|
||||||
</FitText>
|
return (
|
||||||
),
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||||
|
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||||
|
{info.getValue() ?? '—'}
|
||||||
|
</FitText>
|
||||||
|
<span className="muted" style={{ fontSize: '11px' }}>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cellClassName: 'value-cell',
|
cellClassName: 'value-cell',
|
||||||
@@ -454,7 +611,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'yesterdayChangePercent',
|
accessorKey: 'yesterdayChangePercent',
|
||||||
header: '昨日涨跌幅',
|
header: '昨日涨幅',
|
||||||
size: 135,
|
size: 135,
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
@@ -480,7 +637,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'estimateChangePercent',
|
accessorKey: 'estimateChangePercent',
|
||||||
header: '估值涨跌幅',
|
header: '估值涨幅',
|
||||||
size: 135,
|
size: 135,
|
||||||
minSize: 100,
|
minSize: 100,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
@@ -505,6 +662,39 @@ export default function PcFundTable({
|
|||||||
cellClassName: 'est-change-cell',
|
cellClassName: 'est-change-cell',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'totalChangePercent',
|
||||||
|
header: '估算收益',
|
||||||
|
size: 135,
|
||||||
|
minSize: 100,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const value = original.estimateProfitValue;
|
||||||
|
const hasProfit = value != null;
|
||||||
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
|
const amountStr = hasProfit ? (original.estimateProfit ?? '') : '—';
|
||||||
|
const percentStr = original.estimateProfitPercent ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||||
|
{amountStr}
|
||||||
|
</FitText>
|
||||||
|
{percentStr ? (
|
||||||
|
<span className={`${cls} estimate-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||||
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
|
{percentStr}
|
||||||
|
</FitText>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'total-change-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'holdingAmount',
|
accessorKey: 'holdingAmount',
|
||||||
header: '持仓金额',
|
header: '持仓金额',
|
||||||
@@ -580,12 +770,13 @@ export default function PcFundTable({
|
|||||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||||
const percentStr = original.todayProfitPercent ?? '';
|
const percentStr = original.todayProfitPercent ?? '';
|
||||||
|
const isUpdated = original.isUpdated;
|
||||||
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}>
|
||||||
{amountStr}
|
{amountStr}
|
||||||
</FitText>
|
</FitText>
|
||||||
{percentStr ? (
|
{percentStr && !isUpdated ? (
|
||||||
<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 }}>
|
||||||
<FitText maxFontSize={11} minFontSize={9}>
|
<FitText maxFontSize={11} minFontSize={9}>
|
||||||
{percentStr}
|
{percentStr}
|
||||||
@@ -670,7 +861,7 @@ export default function PcFundTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row" style={{ justifyContent: 'center', gap: 4 }}>
|
<div className="row" style={{ justifyContent: 'center', gap: 4, padding: '8px 0' }}>
|
||||||
<button
|
<button
|
||||||
className="icon-button danger"
|
className="icon-button danger"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -690,7 +881,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing, sortBy],
|
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -756,8 +947,47 @@ export default function PcFundTable({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTableHeader = (forPortal = false) => {
|
||||||
|
if (!headerGroup) return null;
|
||||||
|
return (
|
||||||
|
<div className="table-header-row table-header-row-scroll">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const style = getCommonPinningStyles(header.column, true);
|
||||||
|
const isNameColumn =
|
||||||
|
header.column.id === 'fundName' ||
|
||||||
|
header.column.columnDef?.accessorKey === 'fundName';
|
||||||
|
const align = isNameColumn ? '' : 'text-center';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${align}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
{!forPortal && (
|
||||||
|
<div
|
||||||
|
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||||
|
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||||
|
className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
|
||||||
|
} ${header.column.getCanResize() ? '' : 'disabled'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHeaderWidth = headerGroup?.headers?.reduce((acc, h) => acc + h.column.getSize(), 0) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pc-fund-table">
|
<div className="pc-fund-table" ref={tableContainerRef}>
|
||||||
<style>{`
|
<style>{`
|
||||||
.table-row-scroll {
|
.table-row-scroll {
|
||||||
--row-bg: var(--bg);
|
--row-bg: var(--bg);
|
||||||
@@ -832,37 +1062,7 @@ export default function PcFundTable({
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{/* 表头 */}
|
{/* 表头 */}
|
||||||
{headerGroup && (
|
{renderTableHeader(false)}
|
||||||
<div className="table-header-row table-header-row-scroll">
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const style = getCommonPinningStyles(header.column, true);
|
|
||||||
const isNameColumn =
|
|
||||||
header.column.id === 'fundName' ||
|
|
||||||
header.column.columnDef?.accessorKey === 'fundName';
|
|
||||||
const align = isNameColumn ? '' : 'text-center';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={header.id}
|
|
||||||
className={`table-header-cell ${align}`}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
|
||||||
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
|
||||||
className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
|
|
||||||
} ${header.column.getCanResize() ? '' : 'disabled'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 表体 */}
|
{/* 表体 */}
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -891,6 +1091,7 @@ export default function PcFundTable({
|
|||||||
'estimateNav',
|
'estimateNav',
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
|
'totalChangePercent',
|
||||||
'holdingAmount',
|
'holdingAmount',
|
||||||
'todayProfit',
|
'todayProfit',
|
||||||
'holdingProfit',
|
'holdingProfit',
|
||||||
@@ -934,6 +1135,8 @@ export default function PcFundTable({
|
|||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="重置列宽"
|
title="重置列宽"
|
||||||
message="是否重置表格列宽为默认值?"
|
message="是否重置表格列宽为默认值?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
onConfirm={handleResetSizing}
|
onConfirm={handleResetSizing}
|
||||||
onCancel={() => setResetConfirmOpen(false)}
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
confirmText="重置"
|
confirmText="重置"
|
||||||
@@ -951,7 +1154,87 @@ export default function PcFundTable({
|
|||||||
onResetColumnOrder={handleResetColumnOrder}
|
onResetColumnOrder={handleResetColumnOrder}
|
||||||
onResetColumnVisibility={handleResetColumnVisibility}
|
onResetColumnVisibility={handleResetColumnVisibility}
|
||||||
onResetSizing={() => setResetConfirmOpen(true)}
|
onResetSizing={() => setResetConfirmOpen(true)}
|
||||||
|
showFullFundName={showFullFundName}
|
||||||
|
onToggleShowFullFundName={handleToggleShowFullFundName}
|
||||||
/>
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={!!(cardDialogRow && getFundCardProps)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !blockDialogClose) setCardDialogRow(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
|
||||||
|
showCloseButton={false}
|
||||||
|
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-6 pb-4 pt-6 text-left border-b border-[var(--border)]">
|
||||||
|
<DialogTitle className="text-base font-semibold text-[var(--text)]">
|
||||||
|
基金详情
|
||||||
|
</DialogTitle>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button rounded-lg"
|
||||||
|
aria-label="关闭"
|
||||||
|
onClick={() => setCardDialogRow(null)}
|
||||||
|
style={{ padding: 4, borderColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</button>
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
|
||||||
|
>
|
||||||
|
{cardDialogRow && getFundCardProps ? (
|
||||||
|
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{showPortalHeader && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className="pc-fund-table pc-fund-table-portal-header"
|
||||||
|
ref={portalHeaderRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: effectiveStickyTop,
|
||||||
|
left: portalHorizontal.left,
|
||||||
|
right: portalHorizontal.right,
|
||||||
|
zIndex: 10,
|
||||||
|
overflowX: 'auto',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="table-header-row table-header-row-scroll"
|
||||||
|
style={{ minWidth: totalHeaderWidth, width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{headerGroup?.headers.map((header) => {
|
||||||
|
const style = getCommonPinningStyles(header.column, true);
|
||||||
|
const isNameColumn =
|
||||||
|
header.column.id === 'fundName' ||
|
||||||
|
header.column.columnDef?.accessorKey === 'fundName';
|
||||||
|
const align = isNameColumn ? '' : 'text-center';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={header.id}
|
||||||
|
className={`table-header-cell ${align}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
|||||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
|
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
|
||||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||||
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
|
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
|
||||||
|
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
|
||||||
|
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
|
||||||
*/
|
*/
|
||||||
export default function PcTableSettingModal({
|
export default function PcTableSettingModal({
|
||||||
open,
|
open,
|
||||||
@@ -29,6 +31,8 @@ export default function PcTableSettingModal({
|
|||||||
onResetColumnOrder,
|
onResetColumnOrder,
|
||||||
onResetColumnVisibility,
|
onResetColumnVisibility,
|
||||||
onResetSizing,
|
onResetSizing,
|
||||||
|
showFullFundName,
|
||||||
|
onToggleShowFullFundName,
|
||||||
}) {
|
}) {
|
||||||
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
|
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
|
||||||
|
|
||||||
@@ -91,6 +95,45 @@ export default function PcTableSettingModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pc-table-setting-body">
|
<div className="pc-table-setting-body">
|
||||||
|
{onToggleShowFullFundName && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button pc-table-column-switch"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleShowFullFundName(!showFullFundName);
|
||||||
|
}}
|
||||||
|
title={showFullFundName ? '关闭' : '开启'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '0 4px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`dca-toggle-track ${showFullFundName ? 'enabled' : ''}`}>
|
||||||
|
<span
|
||||||
|
className="dca-toggle-thumb"
|
||||||
|
style={{ left: showFullFundName ? 16 : 2 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h3 className="pc-table-setting-subtitle">表头设置</h3>
|
<h3 className="pc-table-setting-subtitle">表头设置</h3>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -224,6 +267,8 @@ export default function PcTableSettingModal({
|
|||||||
key="reset-order-confirm"
|
key="reset-order-confirm"
|
||||||
title="重置表头设置"
|
title="重置表头设置"
|
||||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
onResetColumnOrder?.();
|
onResetColumnOrder?.();
|
||||||
onResetColumnVisibility?.();
|
onResetColumnVisibility?.();
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
export default function ScanImportConfirmModal({
|
export default function ScanImportConfirmModal({
|
||||||
scannedFunds,
|
scannedFunds,
|
||||||
@@ -121,18 +128,18 @@ export default function ScanImportConfirmModal({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组:</span>
|
||||||
<select
|
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>
|
||||||
className="select"
|
<SelectTrigger className="flex-1">
|
||||||
value={selectedGroupId}
|
<SelectValue placeholder="选择分组" />
|
||||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
</SelectTrigger>
|
||||||
style={{ flex: 1 }}
|
<SelectContent>
|
||||||
>
|
<SelectItem value="all">全部</SelectItem>
|
||||||
<option value="all">全部</option>
|
<SelectItem value="fav">自选</SelectItem>
|
||||||
<option value="fav">自选</option>
|
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
|
||||||
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
|
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
||||||
<option key={g.id} value={g.id}>{g.name}</option>
|
))}
|
||||||
))}
|
</SelectContent>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { ResetIcon, SettingsIcon } from './Icons';
|
import { ResetIcon, SettingsIcon } from './Icons';
|
||||||
|
|
||||||
@@ -20,6 +22,22 @@ export default function SettingsModal({
|
|||||||
}) {
|
}) {
|
||||||
const [sliderDragging, setSliderDragging] = useState(false);
|
const [sliderDragging, setSliderDragging] = useState(false);
|
||||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||||
|
const [localSeconds, setLocalSeconds] = useState(tempSeconds);
|
||||||
|
const pageWidthTrackRef = useRef(null);
|
||||||
|
|
||||||
|
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||||
|
const pageWidthPercent = ((clampedWidth - 600) / (2000 - 600)) * 100;
|
||||||
|
|
||||||
|
const updateWidthByClientX = (clientX) => {
|
||||||
|
if (!pageWidthTrackRef.current || !setContainerWidth) return;
|
||||||
|
const rect = pageWidthTrackRef.current.getBoundingClientRect();
|
||||||
|
if (!rect.width) return;
|
||||||
|
const ratio = (clientX - rect.left) / rect.width;
|
||||||
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
|
const rawWidth = 600 + clampedRatio * (2000 - 600);
|
||||||
|
const snapped = Math.round(rawWidth / 10) * 10;
|
||||||
|
setContainerWidth(snapped);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sliderDragging) return;
|
if (!sliderDragging) return;
|
||||||
@@ -32,129 +50,158 @@ export default function SettingsModal({
|
|||||||
};
|
};
|
||||||
}, [sliderDragging]);
|
}, [sliderDragging]);
|
||||||
|
|
||||||
|
// 外部的 tempSeconds 变更时,同步到本地显示,但不立即生效
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSeconds(tempSeconds);
|
||||||
|
}, [tempSeconds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
|
open
|
||||||
role="dialog"
|
onOpenChange={(open) => {
|
||||||
aria-modal="true"
|
if (!open) onClose?.();
|
||||||
aria-label="设置"
|
}}
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
<DialogContent
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
overlayClassName={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''} z-[9999]`}
|
||||||
<SettingsIcon width="20" height="20" />
|
className="!p-0 z-[10000]"
|
||||||
<span>设置</span>
|
showCloseButton={false}
|
||||||
</div>
|
>
|
||||||
|
<div className="glass card modal">
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="title" style={{ marginBottom: 12 }}>
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
<SettingsIcon width="20" height="20" />
|
||||||
<div className="chips" style={{ marginBottom: 12 }}>
|
<DialogTitle asChild>
|
||||||
{[30, 60, 120, 300].map((s) => (
|
<span>设置</span>
|
||||||
<button
|
</DialogTitle>
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
className={`chip ${tempSeconds === s ? 'active' : ''}`}
|
|
||||||
onClick={() => setTempSeconds(s)}
|
|
||||||
aria-pressed={tempSeconds === s}
|
|
||||||
>
|
|
||||||
{s} 秒
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min="30"
|
|
||||||
step="5"
|
|
||||||
value={tempSeconds}
|
|
||||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
|
||||||
placeholder="自定义秒数"
|
|
||||||
/>
|
|
||||||
{tempSeconds < 30 && (
|
|
||||||
<div className="error-text" style={{ marginTop: 8 }}>
|
|
||||||
最小 30 秒
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMobile && setContainerWidth && (
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||||
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
|
<div className="chips" style={{ marginBottom: 12 }}>
|
||||||
{onResetContainerWidth && (
|
{[30, 60, 120, 300].map((s) => (
|
||||||
<button
|
<button
|
||||||
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className={`chip ${localSeconds === s ? 'active' : ''}`}
|
||||||
onClick={() => setResetWidthConfirmOpen(true)}
|
onClick={() => setLocalSeconds(s)}
|
||||||
title="重置页面宽度"
|
aria-pressed={localSeconds === s}
|
||||||
style={{
|
>
|
||||||
border: 'none',
|
{s} 秒
|
||||||
width: '24px',
|
</button>
|
||||||
height: '24px',
|
))}
|
||||||
padding: 0,
|
</div>
|
||||||
backgroundColor: 'transparent',
|
<input
|
||||||
color: 'var(--muted)',
|
className="input"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min="30"
|
||||||
|
step="5"
|
||||||
|
value={localSeconds}
|
||||||
|
onChange={(e) => setLocalSeconds(Number(e.target.value))}
|
||||||
|
placeholder="自定义秒数"
|
||||||
|
/>
|
||||||
|
{localSeconds < 30 && (
|
||||||
|
<div className="error-text" style={{ marginTop: 8 }}>
|
||||||
|
最小 30 秒
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && setContainerWidth && (
|
||||||
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
|
||||||
|
{onResetContainerWidth && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetWidthConfirmOpen(true)}
|
||||||
|
title="重置页面宽度"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResetIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
ref={pageWidthTrackRef}
|
||||||
|
className="group relative"
|
||||||
|
style={{ flex: 1, height: 14, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
setSliderDragging(true);
|
||||||
|
updateWidthByClientX(e.clientX);
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!sliderDragging) return;
|
||||||
|
updateWidthByClientX(e.clientX);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResetIcon width="14" height="14" />
|
<Progress value={pageWidthPercent} />
|
||||||
</button>
|
<div
|
||||||
)}
|
className="pointer-events-none absolute top-1/2 -translate-y-1/2"
|
||||||
</div>
|
style={{ left: `${pageWidthPercent}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
>
|
||||||
<input
|
<div
|
||||||
type="range"
|
className="h-3 w-3 rounded-full bg-primary shadow-md shadow-primary/40"
|
||||||
min={600}
|
/>
|
||||||
max={2000}
|
</div>
|
||||||
step={10}
|
</div>
|
||||||
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
|
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
||||||
onChange={(e) => setContainerWidth(Number(e.target.value))}
|
{clampedWidth}px
|
||||||
onPointerDown={() => setSliderDragging(true)}
|
</span>
|
||||||
className="page-width-slider"
|
</div>
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 6,
|
|
||||||
accentColor: 'var(--primary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
|
||||||
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
|
||||||
<div className="row" style={{ gap: 8 }}>
|
|
||||||
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
|
|
||||||
</div>
|
|
||||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
|
|
||||||
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
|
||||||
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={importFileRef}
|
|
||||||
type="file"
|
|
||||||
accept="application/json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleImportFileChange}
|
|
||||||
/>
|
|
||||||
{importMsg && (
|
|
||||||
<div className="muted" style={{ marginTop: 8 }}>
|
|
||||||
{importMsg}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
|
||||||
|
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={importFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImportFileChange}
|
||||||
|
/>
|
||||||
|
{importMsg && (
|
||||||
|
<div className="muted" style={{ marginTop: 8 }}>
|
||||||
|
{importMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={(e) => saveSettings(e, localSeconds)}
|
||||||
|
disabled={localSeconds < 30}
|
||||||
|
>
|
||||||
|
保存并关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
{resetWidthConfirmOpen && onResetContainerWidth && (
|
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="重置页面宽度"
|
title="重置页面宽度"
|
||||||
message="是否重置页面宽度为默认值 1200px?"
|
message="是否重置页面宽度为默认值 1200px?"
|
||||||
|
icon={<ResetIcon width="20" height="20" className="shrink-0 text-[var(--primary)]" />}
|
||||||
|
confirmVariant="primary"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
onResetContainerWidth();
|
onResetContainerWidth();
|
||||||
setResetWidthConfirmOpen(false);
|
setResetWidthConfirmOpen(false);
|
||||||
@@ -163,6 +210,6 @@ export default function SettingsModal({
|
|||||||
confirmText="重置"
|
confirmText="重置"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export default function WeChatModal({ onClose }) {
|
|||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="trade-pending-alert"
|
||||||
|
onClick={() => setShowPendingList(true)}
|
||||||
|
>
|
||||||
|
<span>⚠️ 入群须知:禁止讨论和基金买卖以及投资的有关内容,可反馈软件相关需求和问题。</span>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={weChatGroupImg}
|
src={weChatGroupImg}
|
||||||
|
|||||||
316
app/globals.css
316
app/globals.css
@@ -1,3 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f172a;
|
--bg: #0f172a;
|
||||||
--card: #111827;
|
--card: #111827;
|
||||||
@@ -10,6 +16,40 @@
|
|||||||
--border: #1f2937;
|
--border: #1f2937;
|
||||||
--table-pinned-header-bg: #2a394b;
|
--table-pinned-header-bg: #2a394b;
|
||||||
--table-row-hover-bg: #2a394b;
|
--table-row-hover-bg: #2a394b;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: #0f172a;
|
||||||
|
--foreground: #e5e7eb;
|
||||||
|
--card-foreground: #e5e7eb;
|
||||||
|
--popover: #111827;
|
||||||
|
--popover-foreground: #e5e7eb;
|
||||||
|
--primary-foreground: #0f172a;
|
||||||
|
--secondary: #1f2937;
|
||||||
|
--secondary-foreground: #e5e7eb;
|
||||||
|
--muted-foreground: #9ca3af;
|
||||||
|
--accent-foreground: #e5e7eb;
|
||||||
|
--destructive: #f87171;
|
||||||
|
--input: #1f2937;
|
||||||
|
--ring: #22d3ee;
|
||||||
|
--chart-1: #22d3ee;
|
||||||
|
--chart-2: #60a5fa;
|
||||||
|
--chart-3: #34d399;
|
||||||
|
--chart-4: #f472b6;
|
||||||
|
--chart-5: #fbbf24;
|
||||||
|
--sidebar: #111827;
|
||||||
|
--sidebar-foreground: #e5e7eb;
|
||||||
|
--sidebar-primary: #22d3ee;
|
||||||
|
--sidebar-primary-foreground: #0f172a;
|
||||||
|
--sidebar-accent: #1f2937;
|
||||||
|
--sidebar-accent-foreground: #e5e7eb;
|
||||||
|
--sidebar-border: #1f2937;
|
||||||
|
--sidebar-ring: #22d3ee;
|
||||||
|
--drawer-overlay: rgba(2, 6, 23, 0.5);
|
||||||
|
--dialog-overlay: rgba(2, 6, 23, 0.6);
|
||||||
|
--tabs-list-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
--tabs-list-border: transparent;
|
||||||
|
--tabs-trigger-active-bg: rgba(34, 211, 238, 0.12);
|
||||||
|
--tabs-trigger-active-text: var(--primary);
|
||||||
|
--switch-thumb: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 亮色主题:ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */
|
/* 亮色主题:ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */
|
||||||
@@ -25,6 +65,39 @@
|
|||||||
--border: #e2e8f0;
|
--border: #e2e8f0;
|
||||||
--table-pinned-header-bg: #e2e8f0;
|
--table-pinned-header-bg: #e2e8f0;
|
||||||
--table-row-hover-bg: #e2e8f0;
|
--table-row-hover-bg: #e2e8f0;
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #0f172a;
|
||||||
|
--card-foreground: #0f172a;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #0f172a;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #f1f5f9;
|
||||||
|
--secondary-foreground: #0f172a;
|
||||||
|
--muted-foreground: #475569;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--input: #e2e8f0;
|
||||||
|
--ring: #0891b2;
|
||||||
|
--chart-1: #0891b2;
|
||||||
|
--chart-2: #2563eb;
|
||||||
|
--chart-3: #059669;
|
||||||
|
--chart-4: #db2777;
|
||||||
|
--chart-5: #ca8a04;
|
||||||
|
--sidebar: #f8fafc;
|
||||||
|
--sidebar-foreground: #0f172a;
|
||||||
|
--sidebar-primary: #0891b2;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: #f1f5f9;
|
||||||
|
--sidebar-accent-foreground: #0f172a;
|
||||||
|
--sidebar-border: #e2e8f0;
|
||||||
|
--sidebar-ring: #0891b2;
|
||||||
|
--drawer-overlay: rgba(15, 23, 42, 0.25);
|
||||||
|
--dialog-overlay: rgba(15, 23, 42, 0.35);
|
||||||
|
--tabs-list-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--tabs-list-border: var(--border);
|
||||||
|
--tabs-trigger-active-bg: rgba(8, 145, 178, 0.15);
|
||||||
|
--tabs-trigger-active-text: var(--primary);
|
||||||
|
--switch-thumb: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -34,6 +107,7 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -697,7 +771,6 @@ body::before {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.navbar-input-field {
|
.navbar-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
@@ -725,6 +798,7 @@ body::before {
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.content {
|
.content {
|
||||||
padding-top: 140px;
|
padding-top: 140px;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
@@ -890,6 +964,7 @@ input[type="number"] {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -1197,6 +1272,16 @@ input[type="number"] {
|
|||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PC 表头固定时的 portal 容器:与 table-scroll-area 横向滚动同步,但隐藏自身滚动条 */
|
||||||
|
.pc-fund-table-portal-header {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE / Edge legacy */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-fund-table-portal-header::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome / Safari */
|
||||||
|
}
|
||||||
|
|
||||||
/* 纵向滚动条通用样式(与项目整体规范一致,供弹窗、列表等使用) */
|
/* 纵向滚动条通用样式(与项目整体规范一致,供弹窗、列表等使用) */
|
||||||
.scrollbar-y-styled {
|
.scrollbar-y-styled {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -1231,7 +1316,7 @@ input[type="number"] {
|
|||||||
min-width: 900px;
|
min-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 基金名称 净值 涨跌幅 估值涨跌幅 估值时间 持仓金额 当日收益 持有收益(三列同宽) */
|
/* 基金名称 净值 涨跌幅 估值涨幅 估值时间 持仓金额 当日收益 持有收益(三列同宽) */
|
||||||
.table-header-row-scroll,
|
.table-header-row-scroll,
|
||||||
.table-row-scroll {
|
.table-row-scroll {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1273,9 +1358,30 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-row-scroll .name-cell .name-text {
|
.table-row-scroll .name-cell .name-text {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-scroll .name-cell .name-cell-content {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-scroll .name-cell .title-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-scroll .name-cell .name-text.show-full {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row-scroll {
|
.table-row-scroll {
|
||||||
@@ -1453,6 +1559,22 @@ input[type="number"] {
|
|||||||
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
|
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-fund-table-portal-header {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
left: 13px;
|
||||||
|
right: 13px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mobile-fund-table-portal-header::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile-fund-table-portal-header .mobile-fund-table-scroll {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-fund-table .table-header-row {
|
.mobile-fund-table .table-header-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
|
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
|
||||||
@@ -1504,13 +1626,17 @@ input[type="number"] {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.12);
|
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .mobile-fund-table .table-header-cell-pin-left,
|
.mobile-fund-table .is-scrolled,
|
||||||
[data-theme="light"] .mobile-fund-table .table-cell-pin-left {
|
.mobile-fund-table .is-scrolled {
|
||||||
|
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.12);
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .mobile-fund-table .is-scrolled,
|
||||||
|
[data-theme="light"] .mobile-fund-table .is-scrolled {
|
||||||
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.08);
|
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.08);
|
||||||
border-right-color: rgba(0, 0, 0, 0.06);
|
border-right-color: rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1695,11 @@ input[type="number"] {
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 基金名称表头排序按钮在排序模式下的高亮 */
|
||||||
|
.mobile-fund-table .mobile-fund-table-header .icon-button.active {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-fund-table .table-row .name-cell .name-cell-content {
|
.mobile-fund-table .table-row .name-cell .name-cell-content {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
@@ -1577,6 +1708,17 @@ input[type="number"] {
|
|||||||
.mobile-fund-table .table-row .name-cell .name-text {
|
.mobile-fund-table .table-row .name-cell .name-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fund-table .table-row .name-cell .name-text.show-full {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-fund-table .table-row .name-cell .code-text {
|
.mobile-fund-table .table-row .name-cell .code-text {
|
||||||
@@ -1920,6 +2062,64 @@ input[type="number"] {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* shadcn Drawer:符合项目规范,适配亮/暗主题 */
|
||||||
|
.drawer-shadow-bottom {
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.drawer-shadow-top {
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .drawer-shadow-bottom {
|
||||||
|
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .drawer-shadow-top {
|
||||||
|
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
|
||||||
|
[data-slot="dialog-content"] {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] [data-slot="dialog-content"] {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content-shadow {
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .dialog-content-shadow {
|
||||||
|
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-content"] [data-slot="dialog-close"] {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
transition: color 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-content"] [data-slot="dialog-close"]:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-content"] [data-slot="dialog-close"]:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-title"] {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dialog-description"] {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-setting-drawer {
|
.mobile-setting-drawer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
@@ -3079,6 +3279,26 @@ input[type="number"] {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
|
background: linear-gradient(180deg, #ef4444, #f87171);
|
||||||
|
color: #2b0b0b;
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger:hover {
|
||||||
|
box-shadow: 0 10px 20px rgba(248, 113, 113, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .button.danger {
|
||||||
|
background: linear-gradient(180deg, #ef4444, #dc2626);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(220, 38, 38, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .button.danger:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 移动端响应式 ========== */
|
/* ========== 移动端响应式 ========== */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|
||||||
@@ -3170,3 +3390,87 @@ input[type="number"] {
|
|||||||
background: rgba(8, 145, 178, 0.12);
|
background: rgba(8, 145, 178, 0.12);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #020617;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--card: #0f172a;
|
||||||
|
--card-foreground: #f8fafc;
|
||||||
|
--popover: #0f172a;
|
||||||
|
--popover-foreground: #f8fafc;
|
||||||
|
--primary: #22d3ee;
|
||||||
|
--primary-foreground: #0f172a;
|
||||||
|
--secondary: #1e293b;
|
||||||
|
--secondary-foreground: #f8fafc;
|
||||||
|
--muted: #1e293b;
|
||||||
|
--muted-foreground: #94a3af;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
--accent-foreground: #f8fafc;
|
||||||
|
--destructive: #f87171;
|
||||||
|
--border: #1f2937;
|
||||||
|
--input: #1e293b;
|
||||||
|
--ring: #22d3ee;
|
||||||
|
--chart-1: #22d3ee;
|
||||||
|
--chart-2: #60a5fa;
|
||||||
|
--chart-3: #34d399;
|
||||||
|
--chart-4: #f472b6;
|
||||||
|
--chart-5: #fbbf24;
|
||||||
|
--sidebar: #0f172a;
|
||||||
|
--sidebar-foreground: #f8fafc;
|
||||||
|
--sidebar-primary: #22d3ee;
|
||||||
|
--sidebar-primary-foreground: #0f172a;
|
||||||
|
--sidebar-accent: #1e293b;
|
||||||
|
--sidebar-accent-foreground: #f8fafc;
|
||||||
|
--sidebar-border: #1f2937;
|
||||||
|
--sidebar-ring: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import AnalyticsGate from './components/AnalyticsGate';
|
import AnalyticsGate from './components/AnalyticsGate';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
@@ -27,8 +28,9 @@ export default function RootLayout({ children }) {
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<AnalyticsGate GA_ID={GA_ID} />
|
<AnalyticsGate GA_ID={GA_ID} />
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
729
app/page.jsx
729
app/page.jsx
@@ -13,10 +13,28 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|||||||
import { isNumber, isString, isPlainObject } from 'lodash';
|
import { isNumber, isString, isPlainObject } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import Announcement from "./components/Announcement";
|
import Announcement from "./components/Announcement";
|
||||||
import { Stat } from "./components/Common";
|
import EmptyStateCard from "./components/EmptyStateCard";
|
||||||
import FundTrendChart from "./components/FundTrendChart";
|
import FundCard from "./components/FundCard";
|
||||||
import FundIntradayChart from "./components/FundIntradayChart";
|
import GroupSummary from "./components/GroupSummary";
|
||||||
import { ChevronIcon, CloseIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MoonIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, SunIcon, TrashIcon, UpdateIcon, UserIcon, CameraIcon } from "./components/Icons";
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeOffIcon,
|
||||||
|
GridIcon,
|
||||||
|
ListIcon,
|
||||||
|
LoginIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
MoonIcon,
|
||||||
|
PinIcon,
|
||||||
|
PinOffIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SortIcon,
|
||||||
|
SunIcon,
|
||||||
|
UpdateIcon,
|
||||||
|
UserIcon,
|
||||||
|
CameraIcon,
|
||||||
|
} from "./components/Icons";
|
||||||
import AddFundToGroupModal from "./components/AddFundToGroupModal";
|
import AddFundToGroupModal from "./components/AddFundToGroupModal";
|
||||||
import AddResultModal from "./components/AddResultModal";
|
import AddResultModal from "./components/AddResultModal";
|
||||||
import CloudConfigModal from "./components/CloudConfigModal";
|
import CloudConfigModal from "./components/CloudConfigModal";
|
||||||
@@ -43,6 +61,7 @@ import WeChatModal from "./components/WeChatModal";
|
|||||||
import DcaModal from "./components/DcaModal";
|
import DcaModal from "./components/DcaModal";
|
||||||
import githubImg from "./assets/github.svg";
|
import githubImg from "./assets/github.svg";
|
||||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||||
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar';
|
||||||
import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
|
import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
|
||||||
@@ -93,214 +112,6 @@ function ScanButton({ onClick, disabled }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
|
||||||
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
|
||||||
const [displayValue, setDisplayValue] = useState(value);
|
|
||||||
const previousValue = useRef(value);
|
|
||||||
const isFirstChange = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previousValue.current === value) return;
|
|
||||||
|
|
||||||
// 首次数值变化(包括从 0/默认值变为实际数据)不做动画,直接跳到目标值
|
|
||||||
if (isFirstChange.current) {
|
|
||||||
isFirstChange.current = false;
|
|
||||||
previousValue.current = value;
|
|
||||||
setDisplayValue(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = previousValue.current;
|
|
||||||
const end = value;
|
|
||||||
const duration = 400; // 0.4秒动画
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
const animate = (currentTime) => {
|
|
||||||
const elapsed = currentTime - startTime;
|
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
|
||||||
|
|
||||||
// easeOutQuart
|
|
||||||
const ease = 1 - Math.pow(1 - progress, 4);
|
|
||||||
|
|
||||||
const current = start + (end - start) * ease;
|
|
||||||
setDisplayValue(current);
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
previousValue.current = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={className} style={style}>
|
|
||||||
{prefix}{Math.abs(displayValue).toFixed(decimals)}{suffix}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupSummary({ funds, holdings, groupName, getProfit, stickyTop }) {
|
|
||||||
const [showPercent, setShowPercent] = useState(true);
|
|
||||||
const [isMasked, setIsMasked] = useState(false);
|
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
|
||||||
const rowRef = useRef(null);
|
|
||||||
const [assetSize, setAssetSize] = useState(24);
|
|
||||||
const [metricSize, setMetricSize] = useState(18);
|
|
||||||
const [winW, setWinW] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
setWinW(window.innerWidth);
|
|
||||||
const onR = () => setWinW(window.innerWidth);
|
|
||||||
window.addEventListener('resize', onR);
|
|
||||||
return () => window.removeEventListener('resize', onR);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
let totalAsset = 0;
|
|
||||||
let totalProfitToday = 0;
|
|
||||||
let totalHoldingReturn = 0;
|
|
||||||
let totalCost = 0;
|
|
||||||
let hasHolding = false;
|
|
||||||
let hasAnyTodayData = false;
|
|
||||||
|
|
||||||
funds.forEach(fund => {
|
|
||||||
const holding = holdings[fund.code];
|
|
||||||
const profit = getProfit(fund, holding);
|
|
||||||
|
|
||||||
if (profit) {
|
|
||||||
hasHolding = true;
|
|
||||||
totalAsset += profit.amount;
|
|
||||||
if (profit.profitToday != null) {
|
|
||||||
// 与卡片展示口径一致:先按“分”四舍五入再汇总,避免浮点误差/逐项舍入差导致不一致
|
|
||||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
|
||||||
hasAnyTodayData = true;
|
|
||||||
}
|
|
||||||
if (profit.profitTotal !== null) {
|
|
||||||
totalHoldingReturn += profit.profitTotal;
|
|
||||||
if (holding && isNumber(holding.cost) && isNumber(holding.share)) {
|
|
||||||
totalCost += holding.cost * holding.share;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
|
||||||
|
|
||||||
return { totalAsset, totalProfitToday, totalHoldingReturn, hasHolding, returnRate, hasAnyTodayData };
|
|
||||||
}, [funds, holdings, getProfit]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = rowRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const height = el.clientHeight;
|
|
||||||
// 使用 80px 作为更严格的阈值,因为 margin/padding 可能导致实际占用更高
|
|
||||||
const tooTall = height > 80;
|
|
||||||
if (tooTall) {
|
|
||||||
setAssetSize(s => Math.max(16, s - 1));
|
|
||||||
setMetricSize(s => Math.max(12, s - 1));
|
|
||||||
} else {
|
|
||||||
// 如果高度正常,尝试适当恢复字体大小,但不要超过初始值
|
|
||||||
// 这里的逻辑可以优化:如果当前远小于阈值,可以尝试增大,但为了稳定性,主要处理缩小的场景
|
|
||||||
// 或者:如果高度非常小(例如远小于80),可以尝试+1,但要小心死循环
|
|
||||||
}
|
|
||||||
}, [winW, summary.totalAsset, summary.totalProfitToday, summary.totalHoldingReturn, summary.returnRate, showPercent, assetSize, metricSize]); // 添加 assetSize, metricSize 到依赖,确保逐步缩小生效
|
|
||||||
|
|
||||||
if (!summary.hasHolding) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={isSticky ? "group-summary-sticky" : ""} style={isSticky && stickyTop ? { top: stickyTop } : {}}>
|
|
||||||
<div className="glass card group-summary-card" style={{ marginBottom: 8, padding: '16px 20px', background: 'rgba(255, 255, 255, 0.03)', position: 'relative' }}>
|
|
||||||
<span
|
|
||||||
className="sticky-toggle-btn"
|
|
||||||
onClick={() => setIsSticky(!isSticky)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
padding: 4,
|
|
||||||
opacity: 0.6,
|
|
||||||
zIndex: 10,
|
|
||||||
color: 'var(--muted)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSticky ? <PinIcon width="14" height="14" /> : <PinOffIcon width="14" height="14" />}
|
|
||||||
</span>
|
|
||||||
<div ref={rowRef} className="row" style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
||||||
<div className="muted" style={{ fontSize: '12px' }}>{groupName}</div>
|
|
||||||
<button
|
|
||||||
className="fav-button"
|
|
||||||
onClick={() => setIsMasked(value => !value)}
|
|
||||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
|
||||||
style={{ margin: 0, padding: 2, display: 'inline-flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
{isMasked ? <EyeOffIcon width="16" height="16" /> : <EyeIcon width="16" height="16" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '24px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}>
|
|
||||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
|
||||||
{isMasked ? (
|
|
||||||
<span style={{ fontSize: assetSize, position: 'relative', top: 4 }}>******</span>
|
|
||||||
) : (
|
|
||||||
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 24 }}>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>当日收益</div>
|
|
||||||
<div
|
|
||||||
className={summary.hasAnyTodayData ? (summary.totalProfitToday > 0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : '') : 'muted'}
|
|
||||||
style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}
|
|
||||||
>
|
|
||||||
{isMasked ? (
|
|
||||||
<span style={{ fontSize: metricSize }}>******</span>
|
|
||||||
) : summary.hasAnyTodayData ? (
|
|
||||||
<>
|
|
||||||
<span style={{ marginRight: 1 }}>{summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''}</span>
|
|
||||||
<CountUp value={Math.abs(summary.totalProfitToday)} style={{ fontSize: metricSize }} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: metricSize }}>--</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有收益{showPercent ? '(%)' : ''}</div>
|
|
||||||
<div
|
|
||||||
className={summary.totalHoldingReturn > 0 ? 'up' : summary.totalHoldingReturn < 0 ? 'down' : ''}
|
|
||||||
style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)', cursor: 'pointer' }}
|
|
||||||
onClick={() => setShowPercent(!showPercent)}
|
|
||||||
title="点击切换金额/百分比"
|
|
||||||
>
|
|
||||||
{isMasked ? (
|
|
||||||
<span style={{ fontSize: metricSize }}>******</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span style={{ marginRight: 1 }}>{summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''}</span>
|
|
||||||
{showPercent ? (
|
|
||||||
<CountUp value={Math.abs(summary.returnRate)} suffix="%" style={{ fontSize: metricSize }} />
|
|
||||||
) : (
|
|
||||||
<CountUp value={Math.abs(summary.totalHoldingReturn)} style={{ fontSize: metricSize }} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [funds, setFunds] = useState([]);
|
const [funds, setFunds] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -427,6 +238,7 @@ export default function HomePage() {
|
|||||||
// 动态计算 Navbar 和 FilterBar 高度
|
// 动态计算 Navbar 和 FilterBar 高度
|
||||||
const navbarRef = useRef(null);
|
const navbarRef = useRef(null);
|
||||||
const filterBarRef = useRef(null);
|
const filterBarRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||||
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
||||||
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
||||||
@@ -507,6 +319,8 @@ export default function HomePage() {
|
|||||||
const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正
|
const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正
|
||||||
const tabsRef = useRef(null);
|
const tabsRef = useRef(null);
|
||||||
const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name }
|
const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name }
|
||||||
|
const fundDetailDrawerCloseRef = useRef(null); // 由 MobileFundTable 注入,用于确认删除时关闭基金详情 Drawer
|
||||||
|
const fundDetailDialogCloseRef = useRef(null); // 由 PcFundTable 注入,用于确认删除时关闭基金详情 Dialog
|
||||||
|
|
||||||
const todayStr = formatDate();
|
const todayStr = formatDate();
|
||||||
|
|
||||||
@@ -677,7 +491,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
if (canCalcTodayProfit) {
|
if (canCalcTodayProfit) {
|
||||||
const amount = holding.share * currentNav;
|
const amount = holding.share * currentNav;
|
||||||
// 估值涨跌幅
|
// 估值涨幅
|
||||||
const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0);
|
const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0);
|
||||||
profitToday = amount - (amount / (1 + gzChange / 100));
|
profitToday = amount - (amount / (1 + gzChange / 100));
|
||||||
} else {
|
} else {
|
||||||
@@ -780,6 +594,7 @@ export default function HomePage() {
|
|||||||
? (isNumber(f.estGszzl) ? Number(f.estGszzl) : null)
|
? (isNumber(f.estGszzl) ? Number(f.estGszzl) : null)
|
||||||
: (isNumber(f.gszzl) ? Number(f.gszzl) : null));
|
: (isNumber(f.gszzl) ? Number(f.gszzl) : null));
|
||||||
const estimateTime = f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-');
|
const estimateTime = f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-');
|
||||||
|
const hasTodayEstimate = !f.noValuation && isString(f.gztime) && f.gztime.startsWith(todayStr);
|
||||||
|
|
||||||
const holding = holdings[f.code];
|
const holding = holdings[f.code];
|
||||||
const profit = getHoldingProfit(f, holding);
|
const profit = getHoldingProfit(f, holding);
|
||||||
@@ -814,6 +629,30 @@ export default function HomePage() {
|
|||||||
: '';
|
: '';
|
||||||
const holdingProfitValue = total;
|
const holdingProfitValue = total;
|
||||||
|
|
||||||
|
const holdingProfitPercentValue =
|
||||||
|
total != null && principal > 0 ? (total / principal) * 100 : null;
|
||||||
|
const hasEstimatePercent = hasTodayEstimate && estimateChangeValue != null;
|
||||||
|
const hasHoldingPercent = holdingProfitPercentValue != null;
|
||||||
|
const fallbackEstimateProfitPercentValue = hasEstimatePercent || hasHoldingPercent
|
||||||
|
? (hasEstimatePercent ? estimateChangeValue : 0) + (hasHoldingPercent ? holdingProfitPercentValue : 0)
|
||||||
|
: null;
|
||||||
|
const estimateProfitPercentValue = hasTodayData
|
||||||
|
? holdingProfitPercentValue
|
||||||
|
: fallbackEstimateProfitPercentValue;
|
||||||
|
const estimateProfitValue = hasTodayData
|
||||||
|
? total
|
||||||
|
: (estimateProfitPercentValue != null && principal > 0
|
||||||
|
? principal * (estimateProfitPercentValue / 100)
|
||||||
|
: null);
|
||||||
|
const estimateProfit =
|
||||||
|
estimateProfitValue == null
|
||||||
|
? ''
|
||||||
|
: `${estimateProfitValue > 0 ? '+' : estimateProfitValue < 0 ? '-' : ''}¥${Math.abs(estimateProfitValue).toFixed(2)}`;
|
||||||
|
const estimateProfitPercent =
|
||||||
|
estimateProfitPercentValue == null
|
||||||
|
? ''
|
||||||
|
: `${estimateProfitPercentValue > 0 ? '+' : ''}${estimateProfitPercentValue.toFixed(2)}%`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rawFund: f,
|
rawFund: f,
|
||||||
code: f.code,
|
code: f.code,
|
||||||
@@ -821,7 +660,9 @@ export default function HomePage() {
|
|||||||
isUpdated: f.jzrq === todayStr,
|
isUpdated: f.jzrq === todayStr,
|
||||||
hasDca: dcaPlans[f.code]?.enabled === true,
|
hasDca: dcaPlans[f.code]?.enabled === true,
|
||||||
latestNav,
|
latestNav,
|
||||||
|
latestNavDate: yesterdayDate,
|
||||||
estimateNav,
|
estimateNav,
|
||||||
|
estimateNavDate: estimateTime,
|
||||||
yesterdayChangePercent,
|
yesterdayChangePercent,
|
||||||
yesterdayChangeValue,
|
yesterdayChangeValue,
|
||||||
yesterdayDate,
|
yesterdayDate,
|
||||||
@@ -829,6 +670,11 @@ export default function HomePage() {
|
|||||||
estimateChangeValue,
|
estimateChangeValue,
|
||||||
estimateChangeMuted: f.noValuation,
|
estimateChangeMuted: f.noValuation,
|
||||||
estimateTime,
|
estimateTime,
|
||||||
|
hasTodayEstimate,
|
||||||
|
totalChangePercent: estimateProfitPercent,
|
||||||
|
estimateProfit,
|
||||||
|
estimateProfitValue,
|
||||||
|
estimateProfitPercent,
|
||||||
holdingAmount,
|
holdingAmount,
|
||||||
holdingAmountValue,
|
holdingAmountValue,
|
||||||
todayProfit,
|
todayProfit,
|
||||||
@@ -1584,6 +1430,7 @@ export default function HomePage() {
|
|||||||
const fields = Array.from(new Set([
|
const fields = Array.from(new Set([
|
||||||
'jzrq',
|
'jzrq',
|
||||||
'dwjz',
|
'dwjz',
|
||||||
|
'gsz',
|
||||||
...(Array.isArray(extraFields) ? extraFields : [])
|
...(Array.isArray(extraFields) ? extraFields : [])
|
||||||
]));
|
]));
|
||||||
const items = list.map((item) => {
|
const items = list.map((item) => {
|
||||||
@@ -1715,9 +1562,12 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const applyViewMode = useCallback((mode) => {
|
const applyViewMode = useCallback((mode) => {
|
||||||
if (mode !== 'card' && mode !== 'list') return;
|
if (mode !== 'card' && mode !== 'list') return;
|
||||||
|
if (mode !== viewMode) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
storageHelper.setItem('viewMode', mode);
|
storageHelper.setItem('viewMode', mode);
|
||||||
}, [storageHelper]);
|
}, [storageHelper, viewMode]);
|
||||||
|
|
||||||
const toggleFavorite = (code) => {
|
const toggleFavorite = (code) => {
|
||||||
setFavorites(prev => {
|
setFavorites(prev => {
|
||||||
@@ -2602,6 +2452,8 @@ export default function HomePage() {
|
|||||||
if (hasHolding) {
|
if (hasHolding) {
|
||||||
setFundDeleteConfirm({ code: fund.code, name: fund.name });
|
setFundDeleteConfirm({ code: fund.code, name: fund.name });
|
||||||
} else {
|
} else {
|
||||||
|
fundDetailDrawerCloseRef.current?.();
|
||||||
|
fundDetailDialogCloseRef.current?.();
|
||||||
removeFund(fund.code);
|
removeFund(fund.code);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2736,9 +2588,11 @@ export default function HomePage() {
|
|||||||
await refreshAll(codes);
|
await refreshAll(codes);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = (e) => {
|
const saveSettings = (e, secondsOverride) => {
|
||||||
e?.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
const ms = Math.max(30, Number(tempSeconds)) * 1000;
|
const seconds = secondsOverride ?? tempSeconds;
|
||||||
|
const ms = Math.max(30, Number(seconds)) * 1000;
|
||||||
|
setTempSeconds(Math.round(ms / 1000));
|
||||||
setRefreshMs(ms);
|
setRefreshMs(ms);
|
||||||
storageHelper.setItem('refreshMs', String(ms));
|
storageHelper.setItem('refreshMs', String(ms));
|
||||||
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
|
||||||
@@ -3148,13 +3002,15 @@ export default function HomePage() {
|
|||||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data: meta, error: metaError } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.select('id, data, updated_at')
|
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (error) throw error;
|
|
||||||
if (!data?.id) {
|
if (metaError) throw metaError;
|
||||||
|
|
||||||
|
if (!meta?.id) {
|
||||||
const { error: insertError } = await supabase
|
const { error: insertError } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.insert({ user_id: userId });
|
.insert({ user_id: userId });
|
||||||
@@ -3162,6 +3018,24 @@ export default function HomePage() {
|
|||||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (checkConflict) {
|
||||||
|
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
|
||||||
|
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_configs')
|
||||||
|
.select('id, data, updated_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
||||||
const localPayload = collectLocalPayload();
|
const localPayload = collectLocalPayload();
|
||||||
const localComparable = getComparablePayload(localPayload);
|
const localComparable = getComparablePayload(localPayload);
|
||||||
@@ -3198,9 +3072,6 @@ export default function HomePage() {
|
|||||||
const dataToSync = payload || collectLocalPayload(); // Fallback to full sync if no payload
|
const dataToSync = payload || collectLocalPayload(); // Fallback to full sync if no payload
|
||||||
const now = nowInTz().toISOString();
|
const now = nowInTz().toISOString();
|
||||||
|
|
||||||
let upsertData = null;
|
|
||||||
let updateError = null;
|
|
||||||
|
|
||||||
if (isPartial) {
|
if (isPartial) {
|
||||||
// 增量更新:使用 RPC 调用
|
// 增量更新:使用 RPC 调用
|
||||||
const { error: rpcError } = await supabase.rpc('update_user_config_partial', {
|
const { error: rpcError } = await supabase.rpc('update_user_config_partial', {
|
||||||
@@ -3211,7 +3082,7 @@ export default function HomePage() {
|
|||||||
console.error('增量同步失败,尝试全量同步', rpcError);
|
console.error('增量同步失败,尝试全量同步', rpcError);
|
||||||
// RPC 失败回退到全量更新
|
// RPC 失败回退到全量更新
|
||||||
const fullPayload = collectLocalPayload();
|
const fullPayload = collectLocalPayload();
|
||||||
const { data, error } = await supabase
|
const { error } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
@@ -3220,17 +3091,12 @@ export default function HomePage() {
|
|||||||
updated_at: now
|
updated_at: now
|
||||||
},
|
},
|
||||||
{ onConflict: 'user_id' }
|
{ onConflict: 'user_id' }
|
||||||
)
|
);
|
||||||
.select();
|
if (error) throw error;
|
||||||
upsertData = data;
|
|
||||||
updateError = error;
|
|
||||||
} else {
|
|
||||||
// RPC 成功,模拟 upsertData 格式以便后续逻辑通过
|
|
||||||
upsertData = [{ id: 'rpc_success' }];
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 全量更新
|
// 全量更新
|
||||||
const { data, error } = await supabase
|
const { error } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
@@ -3239,15 +3105,8 @@ export default function HomePage() {
|
|||||||
updated_at: now
|
updated_at: now
|
||||||
},
|
},
|
||||||
{ onConflict: 'user_id' }
|
{ onConflict: 'user_id' }
|
||||||
)
|
);
|
||||||
.select();
|
if (error) throw error;
|
||||||
upsertData = data;
|
|
||||||
updateError = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
if (!upsertData || upsertData.length === 0) {
|
|
||||||
throw new Error('同步失败:未写入任何数据,请检查账号状态或重新登录');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storageHelper.setItem('localUpdatedAt', now);
|
storageHelper.setItem('localUpdatedAt', now);
|
||||||
@@ -3547,7 +3406,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container content" style={{ width: containerWidth }}>
|
<div ref={containerRef} className="container content" style={{ width: containerWidth }}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showThemeTransition && (
|
{showThemeTransition && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -3906,7 +3765,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<div ref={filterBarRef} className="filter-bar" style={{ top: isMobile ? undefined : navbarHeight , marginTop: navbarHeight, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
<div ref={filterBarRef} className="filter-bar" style={{ ...(isMobile ? {} : { top: navbarHeight }), marginTop: navbarHeight, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||||
<div className="tabs-container">
|
<div className="tabs-container">
|
||||||
<div
|
<div
|
||||||
className="tabs-scroll-area"
|
className="tabs-scroll-area"
|
||||||
@@ -4054,15 +3913,11 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayFunds.length === 0 ? (
|
{displayFunds.length === 0 ? (
|
||||||
<div className="glass card empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 20px' }}>
|
<EmptyStateCard
|
||||||
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
|
fundsLength={funds.length}
|
||||||
<div className="muted" style={{ marginBottom: 20 }}>{funds.length === 0 ? '尚未添加基金' : '该分组下暂无数据'}</div>
|
currentTab={currentTab}
|
||||||
{currentTab !== 'all' && currentTab !== 'fav' && funds.length > 0 && (
|
onAddToGroup={() => setAddFundToGroupOpen(true)}
|
||||||
<button className="button" onClick={() => setAddFundToGroupOpen(true)}>
|
/>
|
||||||
添加基金到此分组
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GroupSummary
|
<GroupSummary
|
||||||
@@ -4128,6 +3983,7 @@ export default function HomePage() {
|
|||||||
<div className="table-scroll-area">
|
<div className="table-scroll-area">
|
||||||
<div className="table-scroll-area-inner">
|
<div className="table-scroll-area-inner">
|
||||||
<PcFundTable
|
<PcFundTable
|
||||||
|
stickyTop={navbarHeight + filterBarHeight}
|
||||||
data={pcFundTableData}
|
data={pcFundTableData}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
@@ -4162,6 +4018,39 @@ export default function HomePage() {
|
|||||||
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
|
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||||
}}
|
}}
|
||||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||||
|
closeDialogRef={fundDetailDialogCloseRef}
|
||||||
|
blockDialogClose={!!fundDeleteConfirm}
|
||||||
|
getFundCardProps={(row) => {
|
||||||
|
const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null);
|
||||||
|
if (!fund) return {};
|
||||||
|
return {
|
||||||
|
fund,
|
||||||
|
todayStr,
|
||||||
|
currentTab,
|
||||||
|
favorites,
|
||||||
|
dcaPlans,
|
||||||
|
holdings,
|
||||||
|
percentModes,
|
||||||
|
valuationSeries,
|
||||||
|
collapsedCodes,
|
||||||
|
collapsedTrends,
|
||||||
|
transactions,
|
||||||
|
theme,
|
||||||
|
isTradingDay,
|
||||||
|
refreshing,
|
||||||
|
getHoldingProfit,
|
||||||
|
onRemoveFromGroup: removeFundFromCurrentGroup,
|
||||||
|
onToggleFavorite: toggleFavorite,
|
||||||
|
onRemoveFund: requestRemoveFund,
|
||||||
|
onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }),
|
||||||
|
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||||
|
onPercentModeToggle: (code) =>
|
||||||
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
|
onToggleCollapse: toggleCollapse,
|
||||||
|
onToggleTrendCollapse: toggleTrendCollapse,
|
||||||
|
layoutMode: 'drawer',
|
||||||
|
};
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4174,6 +4063,9 @@ export default function HomePage() {
|
|||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
favorites={favorites}
|
favorites={favorites}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
|
stickyTop={navbarHeight + filterBarHeight - 14}
|
||||||
|
blockDrawerClose={!!fundDeleteConfirm}
|
||||||
|
closeDrawerRef={fundDetailDrawerCloseRef}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
onRemoveFund={(row) => {
|
onRemoveFund={(row) => {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
@@ -4203,6 +4095,37 @@ export default function HomePage() {
|
|||||||
setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] }));
|
setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||||
}}
|
}}
|
||||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||||
|
getFundCardProps={(row) => {
|
||||||
|
const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null);
|
||||||
|
if (!fund) return {};
|
||||||
|
return {
|
||||||
|
fund,
|
||||||
|
todayStr,
|
||||||
|
currentTab,
|
||||||
|
favorites,
|
||||||
|
dcaPlans,
|
||||||
|
holdings,
|
||||||
|
percentModes,
|
||||||
|
valuationSeries,
|
||||||
|
collapsedCodes,
|
||||||
|
collapsedTrends,
|
||||||
|
transactions,
|
||||||
|
theme,
|
||||||
|
isTradingDay,
|
||||||
|
refreshing,
|
||||||
|
getHoldingProfit,
|
||||||
|
onRemoveFromGroup: removeFundFromCurrentGroup,
|
||||||
|
onToggleFavorite: toggleFavorite,
|
||||||
|
onRemoveFund: requestRemoveFund,
|
||||||
|
onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }),
|
||||||
|
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||||
|
onPercentModeToggle: (code) =>
|
||||||
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||||
|
onToggleCollapse: toggleCollapse,
|
||||||
|
onToggleTrendCollapse: toggleTrendCollapse,
|
||||||
|
layoutMode: 'drawer',
|
||||||
|
};
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
@@ -4217,274 +4140,33 @@ export default function HomePage() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
style={{ position: 'relative', overflow: 'hidden' }}
|
style={{ position: 'relative', overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<FundCard
|
||||||
className="glass card"
|
fund={f}
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
todayStr={todayStr}
|
||||||
>
|
currentTab={currentTab}
|
||||||
<>
|
favorites={favorites}
|
||||||
<div className="row" style={{ marginBottom: 10 }}>
|
dcaPlans={dcaPlans}
|
||||||
<div className="title">
|
holdings={holdings}
|
||||||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
percentModes={percentModes}
|
||||||
<button
|
valuationSeries={valuationSeries}
|
||||||
className="icon-button fav-button"
|
collapsedCodes={collapsedCodes}
|
||||||
onClick={(e) => {
|
collapsedTrends={collapsedTrends}
|
||||||
e.stopPropagation();
|
transactions={transactions}
|
||||||
removeFundFromCurrentGroup(f.code);
|
theme={theme}
|
||||||
}}
|
isTradingDay={isTradingDay}
|
||||||
title="从当前分组移除"
|
refreshing={refreshing}
|
||||||
>
|
getHoldingProfit={getHoldingProfit}
|
||||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
onRemoveFromGroup={removeFundFromCurrentGroup}
|
||||||
</button>
|
onToggleFavorite={toggleFavorite}
|
||||||
) : (
|
onRemoveFund={requestRemoveFund}
|
||||||
<button
|
onHoldingClick={(fund) => setHoldingModal({ open: true, fund })}
|
||||||
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
|
onActionClick={(fund) => setActionModal({ open: true, fund })}
|
||||||
onClick={(e) => {
|
onPercentModeToggle={(code) =>
|
||||||
e.stopPropagation();
|
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||||
toggleFavorite(f.code);
|
}
|
||||||
}}
|
onToggleCollapse={toggleCollapse}
|
||||||
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
|
onToggleTrendCollapse={toggleTrendCollapse}
|
||||||
>
|
/>
|
||||||
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="title-text">
|
|
||||||
<span
|
|
||||||
className={`name-text`}
|
|
||||||
title={f.jzrq === todayStr ? "今日净值已更新" : ""}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</span>
|
|
||||||
<span className="muted">
|
|
||||||
#{f.code}
|
|
||||||
{dcaPlans[f.code]?.enabled === true && <span className="dca-indicator">定</span>}
|
|
||||||
{f.jzrq === todayStr && <span className="updated-indicator">✓</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="actions">
|
|
||||||
<div className="badge-v">
|
|
||||||
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
|
|
||||||
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 4 }}>
|
|
||||||
<button
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={() => !refreshing && requestRemoveFund(f)}
|
|
||||||
title="删除"
|
|
||||||
disabled={refreshing}
|
|
||||||
style={{ width: '28px', height: '28px', opacity: refreshing ? 0.6 : 1, cursor: refreshing ? 'not-allowed' : 'pointer' }}
|
|
||||||
>
|
|
||||||
<TrashIcon width="14" height="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
|
||||||
<Stat label="单位净值" value={f.dwjz ?? '—'} />
|
|
||||||
{f.noValuation ? (
|
|
||||||
// 无估值数据的基金,直接显示净值涨跌幅,不显示估值相关字段
|
|
||||||
<Stat
|
|
||||||
label="涨跌幅"
|
|
||||||
value={f.zzl !== undefined && f.zzl !== null ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'}
|
|
||||||
delta={f.zzl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
const hasTodayData = f.jzrq === todayStr;
|
|
||||||
let isYesterdayChange = false;
|
|
||||||
let isPreviousTradingDay = false;
|
|
||||||
if (!hasTodayData && isString(f.jzrq)) {
|
|
||||||
const today = toTz(todayStr).startOf('day');
|
|
||||||
const jzDate = toTz(f.jzrq).startOf('day');
|
|
||||||
const yesterday = today.clone().subtract(1, 'day');
|
|
||||||
if (jzDate.isSame(yesterday, 'day')) {
|
|
||||||
isYesterdayChange = true;
|
|
||||||
} else if (jzDate.isBefore(yesterday, 'day')) {
|
|
||||||
isPreviousTradingDay = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const shouldHideChange = isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
|
|
||||||
|
|
||||||
if (shouldHideChange) return null;
|
|
||||||
|
|
||||||
// 不再区分“上一交易日涨跌幅”名称,统一使用“昨日涨跌幅”
|
|
||||||
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨跌幅';
|
|
||||||
return (
|
|
||||||
<Stat
|
|
||||||
label={changeLabel}
|
|
||||||
value={f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
|
|
||||||
delta={f.zzl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<Stat label="估值净值" value={f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} />
|
|
||||||
<Stat
|
|
||||||
label="估值涨跌幅"
|
|
||||||
value={f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
|
|
||||||
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
|
||||||
{(() => {
|
|
||||||
const holding = holdings[f.code];
|
|
||||||
const profit = getHoldingProfit(f, holding);
|
|
||||||
|
|
||||||
if (!profit) {
|
|
||||||
return (
|
|
||||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span className="label">持仓金额</span>
|
|
||||||
<div
|
|
||||||
className="value muted"
|
|
||||||
style={{ fontSize: '14px', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
|
|
||||||
onClick={() => setHoldingModal({ open: true, fund: f })}
|
|
||||||
>
|
|
||||||
未设置 <SettingsIcon width="12" height="12" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="stat"
|
|
||||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
|
||||||
onClick={() => setActionModal({ open: true, fund: f })}
|
|
||||||
>
|
|
||||||
<span className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
|
|
||||||
</span>
|
|
||||||
<span className="value">¥{profit.amount.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span className="label">当日收益</span>
|
|
||||||
<span className={`value ${profit.profitToday != null ? (profit.profitToday > 0 ? 'up' : profit.profitToday < 0 ? 'down' : '') : 'muted'}`}>
|
|
||||||
{profit.profitToday != null
|
|
||||||
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
|
||||||
: '--'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{profit.profitTotal !== null && (
|
|
||||||
<div
|
|
||||||
className="stat"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] }));
|
|
||||||
}}
|
|
||||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
|
||||||
title="点击切换金额/百分比"
|
|
||||||
>
|
|
||||||
<span className="label">持有收益{percentModes[f.code] ? '(%)' : ''}</span>
|
|
||||||
<span className={`value ${profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''}`}>
|
|
||||||
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
|
|
||||||
{percentModes[f.code]
|
|
||||||
? `${Math.abs((holding.cost * holding.share) ? (profit.profitTotal / (holding.cost * holding.share)) * 100 : 0).toFixed(2)}%`
|
|
||||||
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{f.estPricedCoverage > 0.05 && (
|
|
||||||
<div style={{ fontSize: '10px', color: 'var(--muted)', marginTop: -8, marginBottom: 10, textAlign: 'right' }}>
|
|
||||||
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
const showIntraday = Array.isArray(valuationSeries[f.code]) && valuationSeries[f.code].length >= 2;
|
|
||||||
if (!showIntraday) return null;
|
|
||||||
|
|
||||||
// 如果今日日期大于估值日期,说明是历史估值,不显示分时图
|
|
||||||
if (f.gztime && toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果 jzrq 等于估值日期或在此之前(意味着净值已经更新到了估值日期,或者是历史数据),则隐藏实时估值分时
|
|
||||||
if (f.jzrq && f.gztime && toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FundIntradayChart
|
|
||||||
key={`${f.code}-intraday-${theme}`}
|
|
||||||
series={valuationSeries[f.code]}
|
|
||||||
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
|
||||||
className="title"
|
|
||||||
onClick={() => toggleCollapse(f.code)}
|
|
||||||
>
|
|
||||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span>前10重仓股票</span>
|
|
||||||
<ChevronIcon
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
className="muted"
|
|
||||||
style={{
|
|
||||||
transform: collapsedCodes.has(f.code) ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="muted">涨跌幅 / 占比</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AnimatePresence>
|
|
||||||
{!collapsedCodes.has(f.code) && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<div className="list">
|
|
||||||
{f.holdings.map((h, idx) => (
|
|
||||||
<div className="item" key={idx}>
|
|
||||||
<span className="name">{h.name}</span>
|
|
||||||
<div className="values">
|
|
||||||
{isNumber(h.change) && (
|
|
||||||
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
|
|
||||||
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="weight">{h.weight}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<FundTrendChart
|
|
||||||
key={`${f.code}-${theme}`}
|
|
||||||
code={f.code}
|
|
||||||
isExpanded={!collapsedTrends.has(f.code)}
|
|
||||||
onToggleExpand={() => toggleTrendCollapse(f.code)}
|
|
||||||
transactions={transactions[f.code] || []}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -4503,6 +4185,8 @@ export default function HomePage() {
|
|||||||
message={`基金 "${fundDeleteConfirm.name}" 存在持仓记录。删除后将移除该基金及其持仓数据,是否继续?`}
|
message={`基金 "${fundDeleteConfirm.name}" 存在持仓记录。删除后将移除该基金及其持仓数据,是否继续?`}
|
||||||
confirmText="确定删除"
|
confirmText="确定删除"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
fundDetailDrawerCloseRef.current?.();
|
||||||
|
fundDetailDialogCloseRef.current?.();
|
||||||
removeFund(fundDeleteConfirm.code);
|
removeFund(fundDeleteConfirm.code);
|
||||||
setFundDeleteConfirm(null);
|
setFundDeleteConfirm(null);
|
||||||
}}
|
}}
|
||||||
@@ -4516,6 +4200,7 @@ export default function HomePage() {
|
|||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="确认登出"
|
title="确认登出"
|
||||||
message="确定要退出当前账号吗?"
|
message="确定要退出当前账号吗?"
|
||||||
|
icon={<LogoutIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />}
|
||||||
confirmText="确认登出"
|
confirmText="确认登出"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setLogoutConfirmOpen(false);
|
setLogoutConfirmOpen(false);
|
||||||
@@ -4535,6 +4220,10 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
className="link-button"
|
className="link-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!user?.id) {
|
||||||
|
sonnerToast.error('请先登录后再提交反馈');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFeedbackNonce((n) => n + 1);
|
setFeedbackNonce((n) => n + 1);
|
||||||
setFeedbackOpen(true);
|
setFeedbackOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
23
components.json
Normal file
23
components.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
149
components/ui/dialog.jsx
Normal file
149
components/ui/dialog.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-[var(--dialog-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
overlayClassName,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay className={overlayClassName} />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<button type="button" className="button secondary px-4 h-11 rounded-xl cursor-pointer">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold text-[var(--foreground)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
222
components/ui/drawer.jsx
Normal file
222
components/ui/drawer.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function parseVhToPx(vhStr) {
|
||||||
|
if (typeof vhStr === "number") return vhStr
|
||||||
|
const match = String(vhStr).match(/^([\d.]+)\s*vh$/)
|
||||||
|
if (!match) return null
|
||||||
|
return (window.innerHeight * Number(match[1])) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
defaultHeight = "77vh",
|
||||||
|
minHeight = "20vh",
|
||||||
|
maxHeight = "90vh",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [heightPx, setHeightPx] = React.useState(() =>
|
||||||
|
typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null
|
||||||
|
);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const dragRef = React.useRef({ startY: 0, startHeight: 0 });
|
||||||
|
|
||||||
|
const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]);
|
||||||
|
const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const px = parseVhToPx(defaultHeight);
|
||||||
|
if (px != null) setHeightPx(px);
|
||||||
|
}, [defaultHeight]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const max = parseVhToPx(maxHeight);
|
||||||
|
const min = parseVhToPx(minHeight);
|
||||||
|
setHeightPx((prev) => {
|
||||||
|
if (prev == null) return parseVhToPx(defaultHeight);
|
||||||
|
const clamped = Math.min(prev, max ?? prev);
|
||||||
|
return Math.max(clamped, min ?? clamped);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", sync);
|
||||||
|
return () => window.removeEventListener("resize", sync);
|
||||||
|
}, [defaultHeight, minHeight, maxHeight]);
|
||||||
|
|
||||||
|
const handlePointerDown = React.useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 };
|
||||||
|
},
|
||||||
|
[heightPx, defaultHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const move = (e) => {
|
||||||
|
const clientY = e.clientY ?? e.touches?.[0]?.clientY;
|
||||||
|
const { startY, startHeight } = dragRef.current;
|
||||||
|
const delta = startY - clientY;
|
||||||
|
const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta));
|
||||||
|
setHeightPx(next);
|
||||||
|
};
|
||||||
|
const up = () => setIsDragging(false);
|
||||||
|
document.addEventListener("mousemove", move, { passive: true });
|
||||||
|
document.addEventListener("mouseup", up);
|
||||||
|
document.addEventListener("touchmove", move, { passive: true });
|
||||||
|
document.addEventListener("touchend", up);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", move);
|
||||||
|
document.removeEventListener("mouseup", up);
|
||||||
|
document.removeEventListener("touchmove", move);
|
||||||
|
document.removeEventListener("touchend", up);
|
||||||
|
};
|
||||||
|
}, [isDragging, minPx, maxPx]);
|
||||||
|
|
||||||
|
const contentStyle = React.useMemo(() => {
|
||||||
|
if (heightPx == null) return undefined;
|
||||||
|
return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined };
|
||||||
|
}, [heightPx, maxPx]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
style={contentStyle}
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content fixed z-50 flex h-auto flex-col bg-[var(--card)] text-[var(--text)] border-[var(--border)]",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-[var(--radius)] data-[vaul-drawer-direction=top]:border-b drawer-shadow-top",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[88vh] data-[vaul-drawer-direction=bottom]:rounded-t-[20px] data-[vaul-drawer-direction=bottom]:border-t drawer-shadow-bottom",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
"drawer-content-theme",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-label="拖动调整高度"
|
||||||
|
onMouseDown={handlePointerDown}
|
||||||
|
onTouchStart={handlePointerDown}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-[var(--muted)] cursor-n-resize touch-none select-none",
|
||||||
|
"group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
|
||||||
|
"hover:bg-[var(--muted-foreground)/0.4] active:bg-[var(--muted-foreground)/0.6]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 border-b border-[var(--border)] group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
"drawer-header-theme",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("font-semibold text-[var(--text)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-sm text-[var(--muted)]", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
79
components/ui/input-otp.jsx
Normal file
79
components/ui/input-otp.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
|
||||||
|
className={cn("disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-12 w-10 items-center justify-center rounded-md border-2 bg-background text-lg font-semibold shadow-sm transition-all duration-200",
|
||||||
|
"border-input/60 dark:border-input/80",
|
||||||
|
"text-foreground dark:text-foreground",
|
||||||
|
"first:rounded-l-md last:rounded-r-md",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary",
|
||||||
|
"data-[active=true]:border-primary data-[active=true]:ring-2 data-[active=true]:ring-primary/30 dark:data-[active=true]:ring-primary/40",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:text-destructive",
|
||||||
|
"dark:bg-slate-900/50 dark:data-[active=true]:bg-slate-800/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-6 w-px animate-caret-blink bg-primary duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" className="text-muted-foreground dark:text-muted-foreground/50" {...props}>
|
||||||
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
43
components/ui/progress.jsx
Normal file
43
components/ui/progress.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
// 细高条,轻玻璃质感,统一用 CSS 变量
|
||||||
|
"relative w-full overflow-hidden rounded-full",
|
||||||
|
"h-1.5 sm:h-1.5",
|
||||||
|
"bg-[var(--input)]/70 dark:bg-[var(--input)]/40",
|
||||||
|
"border border-[var(--border)]/80 dark:border-[var(--border)]/80",
|
||||||
|
"shadow-[0_0_0_1px_rgba(15,23,42,0.02)] dark:shadow-[0_0_0_1px_rgba(15,23,42,0.6)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full flex-1",
|
||||||
|
// 金融风轻渐变,兼容明暗主题
|
||||||
|
"bg-gradient-to-r from-[var(--primary)] to-[var(--primary)]/80",
|
||||||
|
"dark:from-[var(--primary)] dark:to-[var(--secondary)]/90",
|
||||||
|
// 柔和发光,不喧宾夺主
|
||||||
|
"shadow-[0_0_8px_rgba(245,158,11,0.35)] dark:shadow-[0_0_14px_rgba(245,158,11,0.45)]",
|
||||||
|
// 平滑进度动画
|
||||||
|
"transition-[transform,box-shadow] duration-250 ease-out"
|
||||||
|
)}
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
197
components/ui/select.jsx
Normal file
197
components/ui/select.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium whitespace-nowrap shadow-sm transition-all duration-200 outline-none",
|
||||||
|
"border-input bg-background text-foreground",
|
||||||
|
"hover:border-primary/60 hover:ring-1 hover:ring-primary/30",
|
||||||
|
"focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-input disabled:hover:ring-0",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||||
|
"data-[placeholder]:text-muted-foreground",
|
||||||
|
"data-[size=default]:h-11 data-[size=sm]:h-10",
|
||||||
|
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-60 transition-transform duration-200" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-[100] max-h-(--radix-select-content-available-height) min-w-[var(--radix-select-trigger-width)] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-xl border shadow-2xl",
|
||||||
|
"bg-popover/80 text-popover-foreground dark:bg-popover/70",
|
||||||
|
"backdrop-blur-xl backdrop-saturate-[180%]",
|
||||||
|
"border-border/60",
|
||||||
|
"ring-1 ring-black/5 dark:ring-white/10",
|
||||||
|
"shadow-black/5 dark:shadow-black/60",
|
||||||
|
"animate-in fade-in zoom-in-95 duration-200 ease-out",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150",
|
||||||
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}>
|
||||||
|
<SelectScrollUpButton className="bg-transparent text-muted-foreground/50" />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn("p-1.5", position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton className="bg-transparent text-muted-foreground/50" />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2.5 px-3 text-sm font-medium transition-colors duration-150 outline-none",
|
||||||
|
"text-foreground",
|
||||||
|
"hover:bg-primary/10 dark:hover:bg-primary/20",
|
||||||
|
"focus:bg-primary/10 dark:focus:bg-primary/20",
|
||||||
|
"data-[highlighted]:bg-primary/10 dark:data-[highlighted]:bg-primary/20",
|
||||||
|
"data-[state=checked]:bg-primary/10 dark:data-[state=checked]:bg-primary/20",
|
||||||
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-3 flex size-4 items-center justify-center text-primary">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/60", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
61
components/ui/sonner.jsx
Normal file
61
components/ui/sonner.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme}
|
||||||
|
// 外层容器:固定在页面顶部中间
|
||||||
|
className="toaster pointer-events-none fixed inset-x-0 top-4 z-[70] flex items-start justify-center px-4 sm:top-6"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="h-4 w-4 text-emerald-500" />,
|
||||||
|
info: <InfoIcon className="h-4 w-4 text-sky-500" />,
|
||||||
|
warning: <TriangleAlertIcon className="h-4 w-4 text-amber-500" />,
|
||||||
|
error: <OctagonXIcon className="h-4 w-4 text-destructive" />,
|
||||||
|
loading: <Loader2Icon className="h-4 w-4 animate-spin text-primary" />,
|
||||||
|
}}
|
||||||
|
richColors
|
||||||
|
// 统一 toast 样式,使用 ui-ux-pro-max 建议的明暗主题对比度
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
// 基础:浅色模式下使用高对比白色卡片,暗色模式使用深色卡片
|
||||||
|
"pointer-events-auto relative flex w-full max-w-sm items-start gap-3 rounded-xl border border-slate-200 bg-white/90 text-slate-900 px-4 py-3 shadow-lg shadow-black/10 backdrop-blur-md transition-all duration-200 " +
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top sm:data-[state=open]:slide-in-from-bottom " +
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right " +
|
||||||
|
"data-[swipe=move]:translate-x-[var(--sonner-swipe-move-x)] data-[swipe=move]:transition-none " +
|
||||||
|
"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-transform data-[swipe=end]:translate-x-[var(--sonner-swipe-end-x)] " +
|
||||||
|
"dark:border-slate-800 dark:bg-slate-900/90 dark:text-slate-100",
|
||||||
|
title: "text-sm font-medium",
|
||||||
|
description: "mt-1 text-xs text-slate-600 dark:text-slate-400",
|
||||||
|
closeButton:
|
||||||
|
"cursor-pointer text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
actionButton:
|
||||||
|
"inline-flex h-8 items-center justify-center rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||||
|
cancelButton:
|
||||||
|
"inline-flex h-8 items-center justify-center rounded-full border border-border bg-background px-3 text-xs font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
// 状态色:成功/信息/警告只强化边框,错误使用红色背景,满足你“提示为红色”的需求
|
||||||
|
success: "border-emerald-500/70",
|
||||||
|
info: "border-sky-500/70",
|
||||||
|
warning: "border-amber-500/70",
|
||||||
|
error: "bg-destructive text-destructive-foreground border-destructive/80",
|
||||||
|
loading: "border-primary/60",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
42
components/ui/switch.jsx
Normal file
42
components/ui/switch.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef(({ className, size = "default", ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch inline-flex shrink-0 cursor-pointer items-center rounded-full border shadow-xs outline-none",
|
||||||
|
"border-[var(--border)]",
|
||||||
|
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||||
|
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"hover:data-[state=unchecked]:bg-[var(--input)] hover:data-[state=unchecked]:border-[var(--muted)]",
|
||||||
|
"data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||||
|
"data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--primary)]",
|
||||||
|
"data-[state=unchecked]:bg-[var(--input)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block rounded-full ring-0",
|
||||||
|
"bg-[var(--background)]",
|
||||||
|
"transition-transform duration-200 ease-out",
|
||||||
|
"group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
|
||||||
|
"data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=checked]:bg-[var(--primary-foreground)]",
|
||||||
|
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--switch-thumb)]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
89
components/ui/tabs.jsx
Normal file
89
components/ui/tabs.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius)] p-[3px] text-[var(--muted)] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none border border-[var(--tabs-list-border)]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-[var(--tabs-list-bg)]",
|
||||||
|
line: "gap-1 bg-transparent border-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200",
|
||||||
|
"text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--tabs-list-bg)]",
|
||||||
|
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)]/50 focus-visible:outline-1 focus-visible:outline-[var(--ring)]",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
|
||||||
|
"group-data-[variant=default]/tabs-list:data-[state=active]:bg-[var(--tabs-trigger-active-bg)] group-data-[variant=default]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)] group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
|
||||||
|
"group-data-[variant=line]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)]",
|
||||||
|
"after:absolute after:h-0.5 after:bg-[var(--tabs-trigger-active-text)] after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none text-[var(--text)]", className)}
|
||||||
|
{...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"jsx": "react"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/utils.js
Normal file
6
lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
5901
package-lock.json
generated
5901
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.1",
|
"version": "0.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -20,26 +20,41 @@
|
|||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.1.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"tesseract.js": "^5.1.1",
|
"tesseract.js": "^5.1.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0",
|
||||||
|
"vaul": "^1.1.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^16.1.5",
|
"eslint-config-next": "^16.1.5",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.2.7",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"shadcn": "^3.8.5",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
|
|||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user