Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb9d2a105 | ||
|
|
848226cfbb | ||
|
|
5d46515e63 | ||
|
|
92d22b0bef | ||
|
|
8bcffffaa7 | ||
|
|
0ea310f9b3 | ||
|
|
4fcb076d99 | ||
|
|
e7661e7b38 | ||
|
|
2a406be0b1 | ||
|
|
dd9ec06c65 | ||
|
|
e0260f01ec | ||
|
|
9e743e29f4 | ||
|
|
ad746c0fcd | ||
|
|
5ab0ad45c2 | ||
|
|
1256b807a9 | ||
|
|
37243c5fc0 | ||
|
|
1c2195dd64 | ||
|
|
c3157439c3 | ||
|
|
7236684178 | ||
|
|
dae7576c7a | ||
|
|
67ca3ce81d | ||
|
|
c740999e90 | ||
|
|
e7192987f4 | ||
|
|
510664c4d3 | ||
|
|
bf791949d0 | ||
|
|
8084f96dce |
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v10';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v11';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -67,10 +67,14 @@ export default function Announcement() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
|
||||
<p>v0.1.8 版本更新内容如下:</p>
|
||||
<p>1. 重构PC表格界面的实现。</p>
|
||||
<p>2. 允许对PC表格列宽拖拽并存储拖拽后的列宽。</p>
|
||||
关于部分用户反馈数据丢失问题,建议大家登录账号进行数据同步。不然切换域名或清理浏览器缓存都会造成数据丢失。
|
||||
<p>v0.1.9 版本更新内容如下:</p>
|
||||
<p>1. 新增亮色主题。</p>
|
||||
<p>2. PC、移动表格模式重构,支持自定义布局。</p>
|
||||
<p>3. PC端设置弹框支持修改页面容器宽度。</p>
|
||||
<p>4. 分组下自定义布局数据相互独立(旧数据需重新配置)。</p>
|
||||
<p>5. 更换随机头像风格。</p>
|
||||
感谢以下用户上月对项目赞助支持(排名不分顺序):
|
||||
<p>*业、M*.、S*o、b*g、*落、D*A、*山、匿名、*🍍、*啦、L*.、*洛、大大方块先生、带火星的小木条、F、無芯、广告制作装饰、**中、**礼</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -73,20 +73,8 @@ export function DatePicker({ value, onChange }) {
|
||||
return (
|
||||
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="input-trigger"
|
||||
className="date-picker-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
height: '40px',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<span>{value || '选择日期'}</span>
|
||||
<CalendarIcon width="16" height="16" className="muted" />
|
||||
@@ -98,7 +86,7 @@ export function DatePicker({ value, onChange }) {
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="glass card"
|
||||
className="date-picker-dropdown glass card"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
@@ -106,10 +94,7 @@ export function DatePicker({ value, onChange }) {
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
zIndex: 10,
|
||||
background: 'rgba(30, 41, 59, 0.95)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
@@ -141,26 +126,8 @@ export function DatePicker({ value, onChange }) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`date-picker-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isFuture ? 'future' : ''}`}
|
||||
onClick={(e) => !isFuture && handleSelect(e, d)}
|
||||
style={{
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
cursor: isFuture ? 'not-allowed' : 'pointer',
|
||||
background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
|
||||
fontWeight: isSelected || isToday ? 600 : 400,
|
||||
opacity: isFuture ? 0.3 : 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal dca-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
@@ -220,28 +220,8 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
gap: 6
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 32,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
background: enabled ? 'var(--primary)' : 'rgba(148,163,184,0.6)',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: enabled ? 16 : 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: '#0f172a',
|
||||
transition: 'left 0.2s'
|
||||
}}
|
||||
/>
|
||||
<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 ? '已启用' : '未启用'}
|
||||
@@ -284,23 +264,13 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
定投周期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div className="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}>
|
||||
<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)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: cycle === opt.value ? 'var(--primary)' : 'transparent',
|
||||
color: cycle === opt.value ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 6px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -314,23 +284,13 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
扣款星期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div className="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}>
|
||||
<div className="dca-option-group row" style={{ gap: 4 }}>
|
||||
{WEEKDAY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
|
||||
onClick={() => setWeeklyDay(opt.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: weeklyDay === opt.value ? 'var(--primary)' : 'transparent',
|
||||
color: weeklyDay === opt.value ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
padding: '6px 4px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -344,20 +304,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
扣款日 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div
|
||||
className="scrollbar-y-styled"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
maxHeight: 140,
|
||||
overflowY: 'auto',
|
||||
scrollBehavior: 'smooth'
|
||||
}}
|
||||
>
|
||||
<div className="dca-monthly-day-group scrollbar-y-styled">
|
||||
{Array.from({ length: 28 }).map((_, idx) => {
|
||||
const day = idx + 1;
|
||||
const active = monthlyDay === day;
|
||||
@@ -366,17 +313,8 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
key={day}
|
||||
ref={active ? monthlyDayRef : null}
|
||||
type="button"
|
||||
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
|
||||
onClick={() => setMonthlyDay(day)}
|
||||
style={{
|
||||
flex: '0 0 calc(25% - 4px)',
|
||||
border: 'none',
|
||||
background: active ? 'var(--primary)' : 'transparent',
|
||||
color: active ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0'
|
||||
}}
|
||||
>
|
||||
{day}日
|
||||
</button>
|
||||
@@ -390,15 +328,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
|
||||
首次扣款日期
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
background: 'rgba(15,23,42,0.6)'
|
||||
}}
|
||||
>
|
||||
<div className="dca-first-date-display">
|
||||
{firstDate}
|
||||
</div>
|
||||
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||
@@ -409,9 +339,9 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
className="button secondary dca-cancel-btn"
|
||||
onClick={onClose}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
|
||||
87
app/components/FitText.jsx
Normal file
87
app/components/FitText.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 根据容器宽度动态缩小字体,使内容不溢出。
|
||||
* 使用 ResizeObserver 监听容器宽度,内容超出时按比例缩小 fontSize,不低于 minFontSize。
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 要显示的文本(会单行、不换行)
|
||||
* @param {number} [props.maxFontSize=14] - 最大字号(px)
|
||||
* @param {number} [props.minFontSize=10] - 最小字号(px),再窄也不低于此值
|
||||
* @param {string} [props.className] - 外层容器 className
|
||||
* @param {Object} [props.style] - 外层容器 style(宽度由父级决定,建议父级有明确宽度)
|
||||
* @param {string} [props.as='span'] - 外层容器标签 'span' | 'div'
|
||||
*/
|
||||
export default function FitText({
|
||||
children,
|
||||
maxFontSize = 14,
|
||||
minFontSize = 10,
|
||||
className,
|
||||
style = {},
|
||||
as: Tag = 'span',
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const adjust = () => {
|
||||
const container = containerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content) return;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
if (containerWidth <= 0) return;
|
||||
|
||||
// 先恢复到最大字号再测量,确保在「未缩放」状态下取到真实内容宽度
|
||||
content.style.fontSize = `${maxFontSize}px`;
|
||||
|
||||
const run = () => {
|
||||
const contentWidth = content.scrollWidth;
|
||||
if (contentWidth <= 0) return;
|
||||
let size = maxFontSize;
|
||||
if (contentWidth > containerWidth) {
|
||||
size = (containerWidth / contentWidth) * maxFontSize;
|
||||
size = Math.max(minFontSize, Math.min(maxFontSize, size));
|
||||
}
|
||||
content.style.fontSize = `${size}px`;
|
||||
};
|
||||
|
||||
requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
adjust();
|
||||
const ro = new ResizeObserver(adjust);
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [children, maxFontSize, minFontSize]);
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={contentRef}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'inherit',
|
||||
fontSize: `${maxFontSize}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,41 @@ ChartJS.register(
|
||||
Filler
|
||||
);
|
||||
|
||||
const CHART_COLORS = {
|
||||
dark: {
|
||||
danger: '#f87171',
|
||||
success: '#34d399',
|
||||
primary: '#22d3ee',
|
||||
muted: '#9ca3af',
|
||||
border: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
crosshairText: '#0f172a',
|
||||
},
|
||||
light: {
|
||||
danger: '#dc2626',
|
||||
success: '#059669',
|
||||
primary: '#0891b2',
|
||||
muted: '#475569',
|
||||
border: '#e2e8f0',
|
||||
text: '#0f172a',
|
||||
crosshairText: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
function getChartThemeColors(theme) {
|
||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
|
||||
* series: Array<{ time: string, value: number, date?: string }>
|
||||
* referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
|
||||
* theme: 'light' | 'dark',用于亮色主题下坐标轴与 crosshair 样式
|
||||
*/
|
||||
export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
export default function FundIntradayChart({ series = [], referenceNav, theme = 'dark' }) {
|
||||
const chartRef = useRef(null);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!series.length) return { labels: [], datasets: [] };
|
||||
@@ -40,9 +67,8 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
: values[0];
|
||||
const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0));
|
||||
const lastPct = percentages[percentages.length - 1];
|
||||
const riseColor = '#f87171'; // 涨用红色
|
||||
const fallColor = '#34d399'; // 跌用绿色
|
||||
// 以最新点相对参考净值的涨跌定色:涨(>=0)红,跌(<0)绿
|
||||
const riseColor = chartColors.danger;
|
||||
const fallColor = chartColors.success;
|
||||
const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor;
|
||||
|
||||
return {
|
||||
@@ -68,9 +94,11 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [series, referenceNav]);
|
||||
}, [series, referenceNav, chartColors.danger, chartColors.success]);
|
||||
|
||||
const options = useMemo(() => ({
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors();
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
@@ -88,7 +116,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
display: true,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 6
|
||||
}
|
||||
@@ -96,9 +124,9 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
y: {
|
||||
display: true,
|
||||
position: 'left',
|
||||
grid: { color: '#1f2937', drawBorder: false },
|
||||
grid: { color: colors.border, drawBorder: false },
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
|
||||
}
|
||||
@@ -142,7 +170,8 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -152,7 +181,9 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const plugins = useMemo(() => [{
|
||||
const plugins = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return [{
|
||||
id: 'crosshair',
|
||||
afterDraw: (chart) => {
|
||||
const ctx = chart.ctx;
|
||||
@@ -175,17 +206,15 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
ctx.save();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = '#9ca3af';
|
||||
ctx.strokeStyle = colors.muted;
|
||||
ctx.moveTo(x, topY);
|
||||
ctx.lineTo(x, bottomY);
|
||||
ctx.moveTo(leftX, y);
|
||||
ctx.lineTo(rightX, y);
|
||||
ctx.stroke();
|
||||
|
||||
const prim = typeof document !== 'undefined'
|
||||
? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee')
|
||||
: '#22d3ee';
|
||||
const bgText = '#0f172a';
|
||||
const prim = colors.primary;
|
||||
const textCol = colors.crosshairText;
|
||||
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
@@ -202,7 +231,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
const labelCenterX = labelLeft + tw / 2;
|
||||
ctx.fillStyle = prim;
|
||||
ctx.fillRect(labelLeft, bottomY, tw, 16);
|
||||
ctx.fillStyle = bgText;
|
||||
ctx.fillStyle = textCol;
|
||||
ctx.fillText(timeStr, labelCenterX, bottomY + 8);
|
||||
}
|
||||
if (data && index in data) {
|
||||
@@ -211,12 +240,13 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
|
||||
const vw = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = prim;
|
||||
ctx.fillRect(leftX, y - 8, vw, 16);
|
||||
ctx.fillStyle = bgText;
|
||||
ctx.fillStyle = textCol;
|
||||
ctx.fillText(valueStr, leftX + vw / 2, y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}], []);
|
||||
}];
|
||||
}, [theme]);
|
||||
|
||||
if (series.length < 2) return null;
|
||||
|
||||
|
||||
@@ -29,7 +29,32 @@ ChartJS.register(
|
||||
Filler
|
||||
);
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) {
|
||||
const CHART_COLORS = {
|
||||
dark: {
|
||||
danger: '#f87171',
|
||||
success: '#34d399',
|
||||
primary: '#22d3ee',
|
||||
muted: '#9ca3af',
|
||||
border: '#1f2937',
|
||||
text: '#e5e7eb',
|
||||
crosshairText: '#0f172a',
|
||||
},
|
||||
light: {
|
||||
danger: '#dc2626',
|
||||
success: '#059669',
|
||||
primary: '#0891b2',
|
||||
muted: '#475569',
|
||||
border: '#e2e8f0',
|
||||
text: '#0f172a',
|
||||
crosshairText: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
function getChartThemeColors(theme) {
|
||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||
}
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) {
|
||||
const [range, setRange] = useState('1m');
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -37,6 +62,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const chartRef = useRef(null);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
|
||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
// If collapsed, don't fetch data unless we have no data yet
|
||||
if (!isExpanded && data.length > 0) return;
|
||||
@@ -84,12 +111,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
return ((last - first) / first) * 100;
|
||||
}, [data]);
|
||||
|
||||
// Red for up, Green for down (CN market style)
|
||||
// Hardcoded hex values from globals.css for Chart.js
|
||||
const upColor = '#f87171'; // --danger,与折线图红色一致
|
||||
const downColor = '#34d399'; // --success
|
||||
// Red for up, Green for down (CN market style),随主题使用 CSS 变量
|
||||
const upColor = chartColors.danger;
|
||||
const downColor = chartColors.success;
|
||||
const lineColor = change >= 0 ? upColor : downColor;
|
||||
const primaryColor = typeof document !== 'undefined' ? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee') : '#22d3ee';
|
||||
const primaryColor = chartColors.primary;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Calculate percentage change based on the first data point
|
||||
@@ -165,9 +191,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, lineColor, transactions, primaryColor]);
|
||||
}, [data, transactions, lineColor, primaryColor, upColor]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -190,7 +217,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 4,
|
||||
maxRotation: 0
|
||||
@@ -201,12 +228,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
display: true,
|
||||
position: 'left',
|
||||
grid: {
|
||||
color: '#1f2937',
|
||||
color: colors.border,
|
||||
drawBorder: false,
|
||||
tickLength: 0
|
||||
},
|
||||
ticks: {
|
||||
color: '#9ca3af',
|
||||
color: colors.muted,
|
||||
font: { size: 10 },
|
||||
count: 5,
|
||||
callback: (value) => `${value.toFixed(2)}%`
|
||||
@@ -240,7 +267,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
},
|
||||
onClick: () => {}
|
||||
};
|
||||
}, []);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -250,7 +277,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
};
|
||||
}, []);
|
||||
|
||||
const plugins = useMemo(() => [{
|
||||
const plugins = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
return [{
|
||||
id: 'crosshair',
|
||||
afterEvent: (chart, args) => {
|
||||
const { event, replay } = args || {};
|
||||
@@ -276,7 +305,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
afterDraw: (chart) => {
|
||||
const ctx = chart.ctx;
|
||||
const datasets = chart.data.datasets;
|
||||
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
|
||||
const primaryColor = colors.primary;
|
||||
|
||||
// 绘制圆角矩形(兼容无 roundRect 的环境)
|
||||
const drawRoundRect = (left, top, w, h, r) => {
|
||||
@@ -377,7 +406,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = '#9ca3af';
|
||||
ctx.strokeStyle = colors.muted;
|
||||
|
||||
// Draw vertical line
|
||||
ctx.moveTo(x, topY);
|
||||
@@ -415,7 +444,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const labelCenterX = labelLeft + textWidth / 2;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||
ctx.fillStyle = '#0f172a'; // --background
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||
|
||||
// Y axis label (value)
|
||||
@@ -423,7 +452,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||
ctx.fillStyle = '#0f172a'; // --background
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||
}
|
||||
@@ -442,7 +471,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const label = datasets[dsIndex].label;
|
||||
// Determine background color based on dataset index
|
||||
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||
const bgColor = dsIndex === 1 ? primaryColor : '#f87171';
|
||||
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||
|
||||
// If collision, offset Buy label upwards
|
||||
let yOffset = 0;
|
||||
@@ -457,7 +486,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
|
||||
}];
|
||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -501,19 +531,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
|
||||
}}>
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10
|
||||
}}>
|
||||
<div className="chart-overlay">
|
||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -523,23 +547,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
|
||||
<div className="trend-range-bar">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 0',
|
||||
fontSize: '11px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
fontWeight: range === r.value ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
|
||||
@@ -75,9 +75,9 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
减仓
|
||||
</button>
|
||||
<button
|
||||
className="button col-4"
|
||||
className="button col-4 dca-btn"
|
||||
onClick={() => onAction('dca')}
|
||||
style={{ background: 'rgba(34, 211, 238, 0.12)', border: '1px solid #ffffff', color: '#ffffff', fontSize: 14 }}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
定投
|
||||
</button>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function RefreshIcon(props) {
|
||||
|
||||
export function ResetIcon(props) {
|
||||
return (
|
||||
<svg t="1772152323013" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4796" width="16" height="16"><path fill="currentColor" d="M864 512a352 352 0 0 0-600.96-248.96c-15.744 15.872-40.704 42.88-63.232 67.648H320a32 32 0 1 1 0 64H128a31.872 31.872 0 0 1-32-32v-192a32 32 0 1 1 64 0v108.672c20.544-22.528 42.688-46.4 57.856-61.504a416 416 0 1 1 0 588.288 32 32 0 1 1 45.248-45.248A352 352 0 0 0 864 512z" p-id="4797"></path>
|
||||
<svg t="1772152323013" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4796" width="16" height="16"><path fill="currentColor" d="M864 512a352 352 0 0 0-600.96-248.96c-15.744 15.872-40.704 42.88-63.232 67.648H320a32 32 0 1 1 0 64H128a31.872 31.872 0 0 1-32-32v-192a32 32 0 1 1 64 0v108.672c20.544-22.528 42.688-46.4 57.856-61.504a416 416 0 1 1 0 588.288 32 32 0 1 1 45.248-45.248A352 352 0 0 0 864 512z" p-id="4797"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -243,3 +243,20 @@ export function CameraIcon(props) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
775
app/components/MobileFundTable.jsx
Normal file
775
app/components/MobileFundTable.jsx
Normal file
@@ -0,0 +1,775 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import FitText from './FitText';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
'latestNav',
|
||||
'estimateNav',
|
||||
];
|
||||
const MOBILE_COLUMN_HEADERS = {
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨跌幅',
|
||||
estimateChangePercent: '估值涨跌幅',
|
||||
todayProfit: '当日收益',
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
|
||||
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.original.code, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
className="table-row-wrapper"
|
||||
layout={isTableDragging ? undefined : 'position'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ ...style, position: 'relative' }}
|
||||
{...attributes}
|
||||
>
|
||||
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端基金列表表格组件(基于 @tanstack/react-table,与 PcFundTable 相同数据结构)
|
||||
*
|
||||
* @param {Object} props - 与 PcFundTable 一致
|
||||
* @param {Array<Object>} props.data - 表格数据(与 pcFundTableData 同结构)
|
||||
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金
|
||||
* @param {string} [props.currentTab] - 当前分组
|
||||
* @param {Set<string>} [props.favorites] - 自选集合
|
||||
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
|
||||
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
||||
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
||||
* @param {boolean} [props.refreshing] - 是否刷新中
|
||||
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||
*/
|
||||
export default function MobileFundTable({
|
||||
data = [],
|
||||
onRemoveFund,
|
||||
currentTab,
|
||||
favorites = new Set(),
|
||||
onToggleFavorite,
|
||||
onRemoveFromGroup,
|
||||
onHoldingAmountClick,
|
||||
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
|
||||
refreshing = false,
|
||||
sortBy = 'default',
|
||||
onReorder,
|
||||
onCustomSettingsChange,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { delay: 400, tolerance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
|
||||
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||
|
||||
useEffect(() => {
|
||||
onToggleFavoriteRef.current = onToggleFavorite;
|
||||
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||
onHoldingAmountClickRef.current = onHoldingAmountClick;
|
||||
}, [
|
||||
onToggleFavorite,
|
||||
onRemoveFromGroup,
|
||||
onHoldingAmountClick,
|
||||
]);
|
||||
|
||||
const handleDragStart = (e) => setActiveId(e.active.id);
|
||||
const handleDragCancel = () => setActiveId(null);
|
||||
const handleDragEnd = (e) => {
|
||||
const { active, over } = e;
|
||||
if (active && over && active.id !== over.id && onReorder) {
|
||||
const oldIndex = data.findIndex((item) => item.code === active.id);
|
||||
const newIndex = data.findIndex((item) => item.code === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) onReorder(oldIndex, newIndex);
|
||||
}
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const groupKey = currentTab ?? 'all';
|
||||
|
||||
const getCustomSettingsWithMigration = () => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
|
||||
const all = {
|
||||
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
|
||||
pcTableColumnOrder: parsed.pcTableColumnOrder,
|
||||
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
|
||||
pcTableColumns: parsed.pcTableColumns,
|
||||
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
|
||||
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
|
||||
};
|
||||
delete parsed.pcTableColumnOrder;
|
||||
delete parsed.pcTableColumnVisibility;
|
||||
delete parsed.pcTableColumns;
|
||||
delete parsed.mobileTableColumnOrder;
|
||||
delete parsed.mobileTableColumnVisibility;
|
||||
parsed.all = all;
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialMobileConfigByGroup = () => {
|
||||
const parsed = getCustomSettingsWithMigration();
|
||||
const byGroup = {};
|
||||
Object.keys(parsed).forEach((k) => {
|
||||
if (k === 'pcContainerWidth') return;
|
||||
const group = parsed[k];
|
||||
if (!group || typeof group !== 'object') return;
|
||||
const order = Array.isArray(group.mobileTableColumnOrder) && group.mobileTableColumnOrder.length > 0
|
||||
? group.mobileTableColumnOrder
|
||||
: null;
|
||||
const visibility = group.mobileTableColumnVisibility && typeof group.mobileTableColumnVisibility === 'object'
|
||||
? group.mobileTableColumnVisibility
|
||||
: null;
|
||||
byGroup[k] = {
|
||||
mobileTableColumnOrder: order ? (() => {
|
||||
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
|
||||
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||
return [...valid, ...missing];
|
||||
})() : null,
|
||||
mobileTableColumnVisibility: visibility,
|
||||
};
|
||||
});
|
||||
return byGroup;
|
||||
};
|
||||
|
||||
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
|
||||
|
||||
const currentGroupMobile = configByGroup[groupKey];
|
||||
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
||||
const defaultVisibility = (() => {
|
||||
const o = {};
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||
return o;
|
||||
})();
|
||||
|
||||
const mobileColumnOrder = (() => {
|
||||
const order = currentGroupMobile?.mobileTableColumnOrder ?? defaultOrder;
|
||||
if (!Array.isArray(order) || order.length === 0) return [...MOBILE_NON_FROZEN_COLUMN_IDS];
|
||||
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
|
||||
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||
return [...valid, ...missing];
|
||||
})();
|
||||
const mobileColumnVisibility = (() => {
|
||||
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
return defaultVisibility;
|
||||
})();
|
||||
|
||||
const persistMobileGroupConfig = (updates) => {
|
||||
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] } : {};
|
||||
if (updates.mobileTableColumnOrder !== undefined) group.mobileTableColumnOrder = updates.mobileTableColumnOrder;
|
||||
if (updates.mobileTableColumnVisibility !== undefined) group.mobileTableColumnVisibility = updates.mobileTableColumnVisibility;
|
||||
parsed[groupKey] = group;
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
||||
onCustomSettingsChange?.();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const setMobileColumnOrder = (nextOrderOrUpdater) => {
|
||||
const next = typeof nextOrderOrUpdater === 'function'
|
||||
? nextOrderOrUpdater(mobileColumnOrder)
|
||||
: nextOrderOrUpdater;
|
||||
persistMobileGroupConfig({ mobileTableColumnOrder: next });
|
||||
};
|
||||
const setMobileColumnVisibility = (nextOrUpdater) => {
|
||||
const next = typeof nextOrUpdater === 'function'
|
||||
? nextOrUpdater(mobileColumnVisibility)
|
||||
: nextOrUpdater;
|
||||
persistMobileGroupConfig({ mobileTableColumnVisibility: next });
|
||||
};
|
||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||
const tableContainerRef = useRef(null);
|
||||
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tableContainerRef.current;
|
||||
if (!el) return;
|
||||
const updateWidth = () => setTableContainerWidth(el.clientWidth || 0);
|
||||
updateWidth();
|
||||
const ro = new ResizeObserver(updateWidth);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const NAME_CELL_WIDTH = 140;
|
||||
const GAP = 12;
|
||||
const LAST_COLUMN_EXTRA = 12;
|
||||
const FALLBACK_WIDTHS = {
|
||||
fundName: 140,
|
||||
latestNav: 64,
|
||||
estimateNav: 64,
|
||||
yesterdayChangePercent: 72,
|
||||
estimateChangePercent: 80,
|
||||
todayProfit: 80,
|
||||
holdingProfit: 80,
|
||||
};
|
||||
|
||||
const columnWidthMap = useMemo(() => {
|
||||
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||
const nonNameCount = visibleNonNameIds.length;
|
||||
if (tableContainerWidth > 0 && nonNameCount > 0) {
|
||||
const gapTotal = nonNameCount >= 3 ? 3 * GAP : (nonNameCount) * GAP;
|
||||
const remaining = tableContainerWidth - NAME_CELL_WIDTH - gapTotal - LAST_COLUMN_EXTRA;
|
||||
const divisor = nonNameCount >= 3 ? 3 : nonNameCount;
|
||||
const otherColumnWidth = Math.max(48, Math.floor(remaining / divisor));
|
||||
const map = { fundName: NAME_CELL_WIDTH };
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
map[id] = otherColumnWidth;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
return { ...FALLBACK_WIDTHS };
|
||||
}, [tableContainerWidth, mobileColumnOrder, mobileColumnVisibility]);
|
||||
|
||||
const handleResetMobileColumnOrder = () => {
|
||||
setMobileColumnOrder([...MOBILE_NON_FROZEN_COLUMN_IDS]);
|
||||
};
|
||||
const handleResetMobileColumnVisibility = () => {
|
||||
const allVisible = {};
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
setMobileColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||
};
|
||||
|
||||
// 移动端名称列:无拖拽把手,长按整行触发排序
|
||||
const MobileFundNameCell = ({ info }) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
const hasHoldingAmount = original.holdingAmountValue != null;
|
||||
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
|
||||
return (
|
||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isGroupTab ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onRemoveFromGroupRef.current?.(original);
|
||||
}}
|
||||
title="从当前分组移除"
|
||||
style={{ backgroundColor: 'transparent'}}
|
||||
>
|
||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onToggleFavoriteRef.current?.(original);
|
||||
}}
|
||||
title={isFavorites ? '取消自选' : '添加自选'}
|
||||
style={{ backgroundColor: 'transparent'}}
|
||||
>
|
||||
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||
</button>
|
||||
)}
|
||||
<div className="title-text">
|
||||
<span className="name-text" title={isUpdated ? '今日净值已更新' : ''}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
{holdingAmountDisplay ? (
|
||||
<span
|
||||
className="muted code-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="点击设置持仓"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{holdingAmountDisplay}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : code ? (
|
||||
<span
|
||||
className="muted code-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="设置持仓"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
#{code}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fundName',
|
||||
header: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<span>基金名称</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setSettingModalOpen(true);
|
||||
}}
|
||||
title="个性化设置"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
minWidth: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--text)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SettingsIcon width="18" height="18" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: (info) => <MobileFundNameCell info={info} />,
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||
},
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
cell: (info) => (
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateNav',
|
||||
header: '估算净值',
|
||||
cell: (info) => (
|
||||
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
|
||||
<FitText maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</span>
|
||||
),
|
||||
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
|
||||
},
|
||||
{
|
||||
accessorKey: 'yesterdayChangePercent',
|
||||
header: '昨日涨跌幅',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.yesterdayChangeValue;
|
||||
const date = original.yesterdayDate ?? '-';
|
||||
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'change-cell', width: columnWidthMap.yesterdayChangePercent },
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateChangePercent',
|
||||
header: '估值涨跌幅',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.estimateChangeValue;
|
||||
const isMuted = original.estimateChangeMuted;
|
||||
const time = original.estimateTime ?? '-';
|
||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '10px' }}>{time}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
|
||||
},
|
||||
{
|
||||
accessorKey: 'todayProfit',
|
||||
header: '当日收益',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.todayProfitValue;
|
||||
const hasProfit = value != null;
|
||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||
const percentStr = original.todayProfitPercent ?? '';
|
||||
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} today-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: 'profit-cell', width: columnWidthMap.todayProfit },
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingProfit',
|
||||
header: '持有收益',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.holdingProfitValue;
|
||||
const hasTotal = value != null;
|
||||
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
|
||||
const percentStr = original.holdingProfitPercent ?? '';
|
||||
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} holding-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: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, columnWidthMap]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: {
|
||||
columnOrder: ['fundName', ...mobileColumnOrder],
|
||||
columnVisibility: { fundName: true, ...mobileColumnVisibility },
|
||||
},
|
||||
onColumnOrderChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(['fundName', ...mobileColumnOrder]) : updater;
|
||||
const newNonFrozen = next.filter((id) => id !== 'fundName');
|
||||
if (newNonFrozen.length) {
|
||||
setMobileColumnOrder(newNonFrozen);
|
||||
}
|
||||
},
|
||||
onColumnVisibilityChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater({ fundName: true, ...mobileColumnVisibility }) : updater;
|
||||
const rest = { ...next };
|
||||
delete rest.fundName;
|
||||
setMobileColumnVisibility(rest);
|
||||
},
|
||||
initialState: {
|
||||
columnPinning: {
|
||||
left: ['fundName'],
|
||||
},
|
||||
},
|
||||
defaultColumn: {
|
||||
cell: (info) => info.getValue() ?? '—',
|
||||
},
|
||||
});
|
||||
|
||||
const headerGroup = table.getHeaderGroups()[0];
|
||||
|
||||
const snapPositionsRef = useRef([]);
|
||||
const scrollEndTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerGroup?.headers?.length) {
|
||||
snapPositionsRef.current = [];
|
||||
return;
|
||||
}
|
||||
const gap = 12;
|
||||
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
|
||||
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
|
||||
const positions = [0];
|
||||
let acc = 0;
|
||||
// 从第二列开始累加,因为第一列是固定的,滚动是为了让后续列贴合到第一列右侧
|
||||
// 累加的是"被滚出去"的非固定列的宽度
|
||||
for (let i = 1; i < widths.length - 1; i++) {
|
||||
acc += widths[i] + gap;
|
||||
positions.push(acc);
|
||||
}
|
||||
snapPositionsRef.current = positions;
|
||||
}, [headerGroup?.headers?.length, columnWidthMap, mobileColumnOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tableContainerRef.current;
|
||||
if (!el || snapPositionsRef.current.length === 0) return;
|
||||
|
||||
const snapToNearest = () => {
|
||||
const positions = snapPositionsRef.current;
|
||||
if (positions.length === 0) return;
|
||||
const scrollLeft = el.scrollLeft;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
if (maxScroll <= 0) return;
|
||||
const nearest = positions.reduce((prev, curr) =>
|
||||
Math.abs(curr - scrollLeft) < Math.abs(prev - scrollLeft) ? curr : prev
|
||||
);
|
||||
const clamped = Math.max(0, Math.min(maxScroll, nearest));
|
||||
if (Math.abs(clamped - scrollLeft) > 2) {
|
||||
el.scrollTo({ left: clamped, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
|
||||
scrollEndTimerRef.current = setTimeout(snapToNearest, 120);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', handleScroll);
|
||||
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mobileGridLayout = (() => {
|
||||
if (!headerGroup?.headers?.length) return { gridTemplateColumns: '', minWidth: undefined };
|
||||
const gap = 12;
|
||||
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
|
||||
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
|
||||
return {
|
||||
gridTemplateColumns: widths.map((w) => `${w}px`).join(' '),
|
||||
minWidth: widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * gap,
|
||||
};
|
||||
})();
|
||||
|
||||
const getPinClass = (columnId, isHeader) => {
|
||||
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getAlignClass = (columnId) => {
|
||||
if (columnId === 'fundName') return '';
|
||||
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
|
||||
return 'text-right';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-fund-table" ref={tableContainerRef}>
|
||||
<div
|
||||
className="mobile-fund-table-scroll"
|
||||
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
|
||||
>
|
||||
{headerGroup && (
|
||||
<div
|
||||
className="table-header-row mobile-fund-table-header"
|
||||
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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' ? setActivatorNodeRef : undefined}
|
||||
className="table-row"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
|
||||
}}
|
||||
{...(sortBy === 'default' ? 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 && (
|
||||
<div className="table-row empty-row">
|
||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
220
app/components/MobileSettingModal.jsx
Normal file
220
app/components/MobileSettingModal.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* 移动端表格个性化设置弹框(底部抽屉)
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调
|
||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
|
||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||
*/
|
||||
export default function MobileSettingModal({
|
||||
open,
|
||||
onClose,
|
||||
columns = [],
|
||||
columnVisibility,
|
||||
onColumnReorder,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumnOrder,
|
||||
onResetColumnVisibility,
|
||||
}) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetConfirmOpen(false);
|
||||
}, [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 newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="mobile-setting-overlay"
|
||||
className="mobile-setting-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="个性化设置"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
<motion.div
|
||||
className="mobile-setting-drawer glass"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-setting-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mobile-setting-body">
|
||||
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||
暂无可配置列
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="mobile-setting-list"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
{resetConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="mobile-reset-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
setResetConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -1,14 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||
import FitText from './FitText';
|
||||
import PcTableSettingModal from './PcTableSettingModal';
|
||||
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||
|
||||
const NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'holdingAmount',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
'latestNav',
|
||||
'estimateNav',
|
||||
];
|
||||
const COLUMN_HEADERS = {
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨跌幅',
|
||||
estimateChangePercent: '估值涨跌幅',
|
||||
holdingAmount: '持仓金额',
|
||||
todayProfit: '当日收益',
|
||||
holdingProfit: '持有收益',
|
||||
};
|
||||
|
||||
const SortableRowContext = createContext({
|
||||
setActivatorNodeRef: null,
|
||||
listeners: null,
|
||||
});
|
||||
|
||||
function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.original.code, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
|
||||
};
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ setActivatorNodeRef, listeners }),
|
||||
[setActivatorNodeRef, listeners]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={contextValue}>
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
className="table-row-wrapper"
|
||||
layout={isTableDragging ? undefined : "position"}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ ...style, position: 'relative' }}
|
||||
{...attributes}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</SortableRowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PC 端基金列表表格组件(基于 @tanstack/react-table)
|
||||
@@ -19,7 +101,8 @@ import { ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons'
|
||||
* {
|
||||
* fundName: string; // 基金名称
|
||||
* code?: string; // 基金代码(可选,只用于展示在名称下方)
|
||||
* navOrEstimate: string|number; // 净值/估值
|
||||
* latestNav: string|number; // 最新净值
|
||||
* estimateNav: string|number; // 估算净值
|
||||
* yesterdayChangePercent: string|number; // 昨日涨跌幅
|
||||
* estimateChangePercent: string|number; // 估值涨跌幅
|
||||
* holdingAmount: string|number; // 持仓金额
|
||||
@@ -32,7 +115,6 @@ import { ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons'
|
||||
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
|
||||
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
|
||||
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
|
||||
* @param {(row: any) => void} [props.onHoldingProfitClick] - 点击持有收益
|
||||
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
|
||||
*/
|
||||
export default function PcFundTable({
|
||||
@@ -43,134 +125,325 @@ export default function PcFundTable({
|
||||
onToggleFavorite,
|
||||
onRemoveFromGroup,
|
||||
onHoldingAmountClick,
|
||||
onHoldingProfitClick,
|
||||
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
|
||||
refreshing = false,
|
||||
sortBy = 'default',
|
||||
onReorder,
|
||||
onCustomSettingsChange,
|
||||
}) {
|
||||
const getStoredColumnSizing = () => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
const oldIndex = data.findIndex(item => item.code === active.id);
|
||||
const newIndex = data.findIndex(item => item.code === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && onReorder) {
|
||||
onReorder(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
setActiveId(null);
|
||||
};
|
||||
const groupKey = currentTab ?? 'all';
|
||||
|
||||
const getCustomSettingsWithMigration = () => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
const sizing = parsed?.pcTableColumns;
|
||||
if (!sizing || typeof sizing !== 'object') return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(sizing).filter(([, value]) => Number.isFinite(value)),
|
||||
);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
|
||||
const all = {
|
||||
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
|
||||
pcTableColumnOrder: parsed.pcTableColumnOrder,
|
||||
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
|
||||
pcTableColumns: parsed.pcTableColumns,
|
||||
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
|
||||
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
|
||||
};
|
||||
delete parsed.pcTableColumnOrder;
|
||||
delete parsed.pcTableColumnVisibility;
|
||||
delete parsed.pcTableColumns;
|
||||
delete parsed.mobileTableColumnOrder;
|
||||
delete parsed.mobileTableColumnVisibility;
|
||||
parsed.all = all;
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const persistColumnSizing = (nextSizing) => {
|
||||
const buildPcConfigFromGroup = (group) => {
|
||||
if (!group || typeof group !== 'object') return null;
|
||||
const sizing = group.pcTableColumns;
|
||||
const sizingObj = sizing && typeof sizing === 'object'
|
||||
? Object.fromEntries(Object.entries(sizing).filter(([, v]) => Number.isFinite(v)))
|
||||
: {};
|
||||
if (sizingObj.actions) {
|
||||
const { actions, ...rest } = sizingObj;
|
||||
Object.assign(sizingObj, rest);
|
||||
delete sizingObj.actions;
|
||||
}
|
||||
const order = Array.isArray(group.pcTableColumnOrder) && group.pcTableColumnOrder.length > 0
|
||||
? group.pcTableColumnOrder
|
||||
: null;
|
||||
const visibility = group.pcTableColumnVisibility && typeof group.pcTableColumnVisibility === 'object'
|
||||
? group.pcTableColumnVisibility
|
||||
: null;
|
||||
return { sizing: sizingObj, order, visibility };
|
||||
};
|
||||
|
||||
const getDefaultPcGroupConfig = () => ({
|
||||
order: [...NON_FROZEN_COLUMN_IDS],
|
||||
visibility: null,
|
||||
sizing: {},
|
||||
});
|
||||
|
||||
const getInitialConfigByGroup = () => {
|
||||
const parsed = getCustomSettingsWithMigration();
|
||||
const byGroup = {};
|
||||
Object.keys(parsed).forEach((k) => {
|
||||
if (k === 'pcContainerWidth') return;
|
||||
const group = parsed[k];
|
||||
const pc = buildPcConfigFromGroup(group);
|
||||
if (pc) {
|
||||
byGroup[k] = {
|
||||
pcTableColumnOrder: pc.order ? (() => {
|
||||
const valid = pc.order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
|
||||
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||
return [...valid, ...missing];
|
||||
})() : null,
|
||||
pcTableColumnVisibility: pc.visibility,
|
||||
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
|
||||
};
|
||||
}
|
||||
});
|
||||
return byGroup;
|
||||
};
|
||||
|
||||
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
|
||||
|
||||
const currentGroupPc = configByGroup[groupKey];
|
||||
const defaultPc = getDefaultPcGroupConfig();
|
||||
const columnOrder = (() => {
|
||||
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
|
||||
if (!Array.isArray(order) || order.length === 0) return [...NON_FROZEN_COLUMN_IDS];
|
||||
const valid = order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
|
||||
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
|
||||
return [...valid, ...missing];
|
||||
})();
|
||||
const columnVisibility = (() => {
|
||||
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
const allVisible = {};
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||
return allVisible;
|
||||
})();
|
||||
const columnSizing = (() => {
|
||||
const s = currentGroupPc?.pcTableColumns;
|
||||
if (s && typeof s === 'object') {
|
||||
const out = Object.fromEntries(Object.entries(s).filter(([, v]) => Number.isFinite(v)));
|
||||
if (out.actions) {
|
||||
const { actions, ...rest } = out;
|
||||
return rest;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return {};
|
||||
})();
|
||||
|
||||
const persistPcGroupConfig = (updates) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem('customSettings');
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
const nextSettings =
|
||||
parsed && typeof parsed === 'object'
|
||||
? { ...parsed, pcTableColumns: nextSizing }
|
||||
: { pcTableColumns: nextSizing };
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(nextSettings));
|
||||
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
|
||||
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
|
||||
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
|
||||
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
|
||||
parsed[groupKey] = group;
|
||||
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
|
||||
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
|
||||
onCustomSettingsChange?.();
|
||||
} catch { }
|
||||
};
|
||||
|
||||
const [columnSizing, setColumnSizing] = useState(() => {
|
||||
const stored = getStoredColumnSizing();
|
||||
if (stored.actions) {
|
||||
const { actions, ...rest } = stored;
|
||||
return rest;
|
||||
}
|
||||
return stored;
|
||||
});
|
||||
const setColumnOrder = (nextOrderOrUpdater) => {
|
||||
const next = typeof nextOrderOrUpdater === 'function'
|
||||
? nextOrderOrUpdater(columnOrder)
|
||||
: nextOrderOrUpdater;
|
||||
persistPcGroupConfig({ pcTableColumnOrder: next });
|
||||
};
|
||||
const setColumnVisibility = (nextOrUpdater) => {
|
||||
const next = typeof nextOrUpdater === 'function'
|
||||
? nextOrUpdater(columnVisibility)
|
||||
: nextOrUpdater;
|
||||
persistPcGroupConfig({ pcTableColumnVisibility: next });
|
||||
};
|
||||
const setColumnSizing = (nextOrUpdater) => {
|
||||
const next = typeof nextOrUpdater === 'function'
|
||||
? nextOrUpdater(columnSizing)
|
||||
: nextOrUpdater;
|
||||
const { actions, ...rest } = next || {};
|
||||
persistPcGroupConfig({ pcTableColumns: rest || {} });
|
||||
};
|
||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const handleResetSizing = () => {
|
||||
setColumnSizing({});
|
||||
persistColumnSizing({});
|
||||
setResetConfirmOpen(false);
|
||||
};
|
||||
|
||||
const handleResetColumnOrder = () => {
|
||||
setColumnOrder([...NON_FROZEN_COLUMN_IDS]);
|
||||
};
|
||||
|
||||
const handleResetColumnVisibility = () => {
|
||||
const allVisible = {};
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
setColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||
setColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||
};
|
||||
const onRemoveFundRef = useRef(onRemoveFund);
|
||||
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||
const onHoldingProfitClickRef = useRef(onHoldingProfitClick);
|
||||
|
||||
useEffect(() => {
|
||||
onRemoveFundRef.current = onRemoveFund;
|
||||
onToggleFavoriteRef.current = onToggleFavorite;
|
||||
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||
onHoldingAmountClickRef.current = onHoldingAmountClick;
|
||||
onHoldingProfitClickRef.current = onHoldingProfitClick;
|
||||
}, [
|
||||
onRemoveFund,
|
||||
onToggleFavorite,
|
||||
onRemoveFromGroup,
|
||||
onHoldingAmountClick,
|
||||
onHoldingProfitClick,
|
||||
]);
|
||||
|
||||
const FundNameCell = ({ info }) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
const rowContext = useContext(SortableRowContext);
|
||||
|
||||
return (
|
||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{sortBy === 'default' && (
|
||||
<button
|
||||
className="icon-button drag-handle"
|
||||
ref={rowContext?.setActivatorNodeRef}
|
||||
{...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' }}
|
||||
title="拖拽排序"
|
||||
onClick={(e) => e.stopPropagation?.()}
|
||||
>
|
||||
<DragIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
{isGroupTab ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onRemoveFromGroupRef.current?.(original);
|
||||
}}
|
||||
title="从小分组移除"
|
||||
style={{ backgroundColor: 'transparent'}}
|
||||
>
|
||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onToggleFavoriteRef.current?.(original);
|
||||
}}
|
||||
title={isFavorites ? '取消自选' : '添加自选'}
|
||||
>
|
||||
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||
</button>
|
||||
)}
|
||||
<div className="title-text">
|
||||
<span
|
||||
className={`name-text`}
|
||||
title={isUpdated ? '今日净值已更新' : ''}
|
||||
>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
{code ? <span className="muted code-text">
|
||||
#{code}
|
||||
{isUpdated && <span className="updated-indicator">✓</span>}
|
||||
</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fundName',
|
||||
header: '基金名称',
|
||||
size: 240,
|
||||
minSize: 100,
|
||||
size: 265,
|
||||
minSize: 140,
|
||||
enablePinning: true,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
const isFavorites = favorites?.has?.(code);
|
||||
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
|
||||
return (
|
||||
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isGroupTab ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onRemoveFromGroupRef.current?.(original);
|
||||
}}
|
||||
title="从当前分组移除"
|
||||
>
|
||||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onToggleFavoriteRef.current?.(original);
|
||||
}}
|
||||
title={isFavorites ? '取消自选' : '添加自选'}
|
||||
>
|
||||
<StarIcon width="18" height="18" filled={isFavorites} />
|
||||
</button>
|
||||
)}
|
||||
<div className="title-text">
|
||||
<span
|
||||
className={`name-text ${isUpdated ? 'updated' : ''}`}
|
||||
title={isUpdated ? '今日净值已更新' : ''}
|
||||
>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
{code ? <span className="muted code-text">#{code}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: (info) => <FundNameCell info={info} />,
|
||||
meta: {
|
||||
align: 'left',
|
||||
cellClassName: 'name-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'navOrEstimate',
|
||||
header: '净值/估值',
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<span style={{ fontWeight: 700 }}>{info.getValue() ?? '—'}</span>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateNav',
|
||||
header: '估算净值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
),
|
||||
meta: {
|
||||
align: 'right',
|
||||
@@ -180,17 +453,19 @@ export default function PcFundTable({
|
||||
{
|
||||
accessorKey: 'yesterdayChangePercent',
|
||||
header: '昨日涨跌幅',
|
||||
size: 135,
|
||||
minSize: 100,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.yesterdayChangeValue;
|
||||
const date = original.yesterdayDate ?? '-';
|
||||
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
</FitText>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
@@ -204,8 +479,8 @@ export default function PcFundTable({
|
||||
{
|
||||
accessorKey: 'estimateChangePercent',
|
||||
header: '估值涨跌幅',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
size: 135,
|
||||
minSize: 100,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.estimateChangeValue;
|
||||
@@ -213,11 +488,11 @@ export default function PcFundTable({
|
||||
const time = original.estimateTime ?? '-';
|
||||
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
</FitText>
|
||||
<span className="muted" style={{ fontSize: '11px' }}>
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
@@ -231,6 +506,8 @@ export default function PcFundTable({
|
||||
{
|
||||
accessorKey: 'holdingAmount',
|
||||
header: '持仓金额',
|
||||
size: 135,
|
||||
minSize: 100,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
if (original.holdingAmountValue == null) {
|
||||
@@ -243,12 +520,12 @@ export default function PcFundTable({
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -259,13 +536,17 @@ export default function PcFundTable({
|
||||
return (
|
||||
<div
|
||||
title="点击设置持仓"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', width: '100%', minWidth: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, marginRight: 6 }}>{info.getValue() ?? '—'}</span>
|
||||
<div style={{ flex: '1 1 0', minWidth: 0 }}>
|
||||
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
|
||||
{info.getValue() ?? '—'}
|
||||
</FitText>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button no-hover"
|
||||
onClick={(e) => {
|
||||
@@ -273,7 +554,7 @@ export default function PcFundTable({
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
title="编辑持仓"
|
||||
style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6 }}
|
||||
style={{ border: 'none', width: '28px', height: '28px', marginLeft: 4, flexShrink: 0, backgroundColor: 'transparent' }}
|
||||
>
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
@@ -288,15 +569,28 @@ export default function PcFundTable({
|
||||
{
|
||||
accessorKey: 'todayProfit',
|
||||
header: '当日收益',
|
||||
size: 135,
|
||||
minSize: 100,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.todayProfitValue;
|
||||
const hasProfit = value != null;
|
||||
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
|
||||
const percentStr = original.todayProfitPercent ?? '';
|
||||
return (
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{hasProfit ? (info.getValue() ?? '') : ''}
|
||||
</span>
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
</FitText>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
@@ -307,26 +601,27 @@ export default function PcFundTable({
|
||||
{
|
||||
accessorKey: 'holdingProfit',
|
||||
header: '持有收益',
|
||||
size: 140,
|
||||
size: 135,
|
||||
minSize: 100,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const value = original.holdingProfitValue;
|
||||
const hasTotal = value != null;
|
||||
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
|
||||
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
|
||||
const percentStr = original.holdingProfitPercent ?? '';
|
||||
return (
|
||||
<div
|
||||
title="点击切换金额/百分比"
|
||||
style={{ cursor: hasTotal ? 'pointer' : 'default' }}
|
||||
onClick={(e) => {
|
||||
if (!hasTotal) return;
|
||||
e.stopPropagation?.();
|
||||
onHoldingProfitClickRef.current?.(original);
|
||||
}}
|
||||
>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{hasTotal ? (info.getValue() ?? '') : ''}
|
||||
</span>
|
||||
<div style={{ width: '100%' }}>
|
||||
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
|
||||
{amountStr}
|
||||
</FitText>
|
||||
{percentStr ? (
|
||||
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
|
||||
<FitText maxFontSize={11} minFontSize={9}>
|
||||
{percentStr}
|
||||
</FitText>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -344,12 +639,12 @@ export default function PcFundTable({
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setResetConfirmOpen(true);
|
||||
setSettingModalOpen(true);
|
||||
}}
|
||||
title="重置列宽"
|
||||
title="个性化设置"
|
||||
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
|
||||
>
|
||||
<ResetIcon width="14" height="14" />
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
@@ -373,7 +668,7 @@ export default function PcFundTable({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="row" style={{ justifyContent: 'center', gap: 4 }}>
|
||||
<button
|
||||
className="icon-button danger"
|
||||
onClick={handleClick}
|
||||
@@ -393,7 +688,7 @@ export default function PcFundTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing],
|
||||
[currentTab, favorites, refreshing, sortBy],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -406,12 +701,19 @@ export default function PcFundTable({
|
||||
setColumnSizing((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
const { actions, ...rest } = next || {};
|
||||
persistColumnSizing(rest || {});
|
||||
return rest || {};
|
||||
});
|
||||
},
|
||||
state: {
|
||||
columnSizing,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnOrderChange: (updater) => {
|
||||
setColumnOrder(updater);
|
||||
},
|
||||
onColumnVisibilityChange: (updater) => {
|
||||
setColumnVisibility(updater);
|
||||
},
|
||||
initialState: {
|
||||
columnPinning: {
|
||||
@@ -445,7 +747,7 @@ export default function PcFundTable({
|
||||
left: isLeft ? `${column.getStart('left')}px` : undefined,
|
||||
right: isRight ? `${column.getAfter('right')}px` : undefined,
|
||||
zIndex: isHeader ? 11 : 10,
|
||||
backgroundColor: isHeader ? '#2a394b' : 'var(--row-bg)',
|
||||
backgroundColor: isHeader ? 'var(--table-pinned-header-bg)' : 'var(--row-bg)',
|
||||
boxShadow: 'none',
|
||||
textAlign: isNameColumn ? 'left' : 'center',
|
||||
justifyContent: isNameColumn ? 'flex-start' : 'center',
|
||||
@@ -453,14 +755,14 @@ export default function PcFundTable({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pc-fund-table">
|
||||
<style>{`
|
||||
.table-row-scroll {
|
||||
--row-bg: var(--bg);
|
||||
background-color: var(--row-bg);
|
||||
}
|
||||
.table-row-scroll:hover {
|
||||
--row-bg: #2a394b;
|
||||
--row-bg: var(--table-row-hover-bg);
|
||||
}
|
||||
|
||||
/* 覆盖 grid 布局为 flex 以支持动态列宽 */
|
||||
@@ -545,15 +847,14 @@ export default function PcFundTable({
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
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'}`}
|
||||
className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
|
||||
} ${header.column.getCanResize() ? '' : 'disabled'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -562,56 +863,63 @@ export default function PcFundTable({
|
||||
)}
|
||||
|
||||
{/* 表体 */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<motion.div
|
||||
key={row.original.code || row.id}
|
||||
className="table-row-wrapper"
|
||||
layout="position"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
className="table-row table-row-scroll"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||
const isNameColumn = columnId === 'fundName';
|
||||
const rightAlignedColumns = new Set([
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'holdingAmount',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
]);
|
||||
const align = isNameColumn
|
||||
? ''
|
||||
: rightAlignedColumns.has(columnId)
|
||||
? 'text-right'
|
||||
: 'text-center';
|
||||
const cellClassName =
|
||||
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||
const style = getCommonPinningStyles(cell.column, false);
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${align} ${cellClassName}`}
|
||||
style={style}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<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'}>
|
||||
<div
|
||||
className="table-row table-row-scroll"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
|
||||
const isNameColumn = columnId === 'fundName';
|
||||
const rightAlignedColumns = new Set([
|
||||
'latestNav',
|
||||
'estimateNav',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'holdingAmount',
|
||||
'todayProfit',
|
||||
'holdingProfit',
|
||||
]);
|
||||
const align = isNameColumn
|
||||
? ''
|
||||
: rightAlignedColumns.has(columnId)
|
||||
? 'text-right'
|
||||
: 'text-center';
|
||||
const cellClassName =
|
||||
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
|
||||
const style = getCommonPinningStyles(cell.column, false);
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`table-cell ${align} ${cellClassName}`}
|
||||
style={style}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableRow>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<div className="table-row empty-row">
|
||||
@@ -629,6 +937,19 @@ export default function PcFundTable({
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<PcTableSettingModal
|
||||
open={settingModalOpen}
|
||||
onClose={() => setSettingModalOpen(false)}
|
||||
columns={columnOrder.map((id) => ({ id, header: COLUMN_HEADERS[id] ?? id }))}
|
||||
onColumnReorder={(newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onToggleColumnVisibility={handleToggleColumnVisibility}
|
||||
onResetColumnOrder={handleResetColumnOrder}
|
||||
onResetColumnVisibility={handleResetColumnVisibility}
|
||||
onResetSizing={() => setResetConfirmOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
241
app/components/PcTableSettingModal.jsx
Normal file
241
app/components/PcTableSettingModal.jsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* PC 表格个性化设置侧弹框
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列(id + 表头名称)
|
||||
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射(id => 是否显示)
|
||||
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调,参数为新的列 id 顺序
|
||||
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
|
||||
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
|
||||
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
|
||||
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
|
||||
*/
|
||||
export default function PcTableSettingModal({
|
||||
open,
|
||||
onClose,
|
||||
columns = [],
|
||||
columnVisibility,
|
||||
onColumnReorder,
|
||||
onToggleColumnVisibility,
|
||||
onResetColumnOrder,
|
||||
onResetColumnVisibility,
|
||||
onResetSizing,
|
||||
}) {
|
||||
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setResetOrderConfirmOpen(false);
|
||||
}, [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 newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="drawer"
|
||||
className="pc-table-setting-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="个性化设置"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 10001 }}
|
||||
>
|
||||
<motion.aside
|
||||
className="pc-table-setting-drawer glass"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pc-table-setting-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pc-table-setting-body">
|
||||
<h3 className="pc-table-setting-subtitle">表头设置</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
|
||||
拖拽调整列顺序
|
||||
</p>
|
||||
{onResetColumnOrder && (
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setResetOrderConfirmOpen(true)}
|
||||
title="重置列顺序"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
|
||||
暂无可配置列
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={columns}
|
||||
onReorder={handleReorder}
|
||||
className="pc-table-setting-list"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{columns.map((item, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id || `col-${index}`}
|
||||
value={item}
|
||||
className="pc-table-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>
|
||||
)}
|
||||
{onResetSizing && (
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => {
|
||||
onResetSizing();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<ResetIcon width="16" height="16" />
|
||||
重置列宽
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
)}
|
||||
{resetOrderConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="reset-order-confirm"
|
||||
title="重置表头设置"
|
||||
message="是否重置表头顺序和显示/隐藏为默认值?"
|
||||
onConfirm={() => {
|
||||
onResetColumnOrder?.();
|
||||
onResetColumnVisibility?.();
|
||||
setResetOrderConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetOrderConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
40
app/components/RefreshButton.jsx
Normal file
40
app/components/RefreshButton.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshIcon } from './Icons';
|
||||
|
||||
export default function RefreshButton({ refreshCycleStartRef, refreshMs, manualRefresh, refreshing, fundsLength }) {
|
||||
|
||||
// 刷新周期进度 0~1,用于环形进度条
|
||||
const [refreshProgress, setRefreshProgress] = useState(0);
|
||||
|
||||
// 刷新进度条:每 100ms 更新一次进度
|
||||
useEffect(() => {
|
||||
if (fundsLength === 0 || refreshMs <= 0) return;
|
||||
const t = setInterval(() => {
|
||||
const elapsed = Date.now() - refreshCycleStartRef.current;
|
||||
const p = Math.min(1, elapsed / refreshMs);
|
||||
setRefreshProgress(p);
|
||||
}, 100);
|
||||
return () => clearInterval(t);
|
||||
}, [fundsLength, refreshMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="refresh-btn-wrap"
|
||||
style={{ '--progress': refreshProgress }}
|
||||
title={`刷新周期 ${Math.round(refreshMs / 1000)} 秒`}
|
||||
>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="立即刷新"
|
||||
onClick={manualRefresh}
|
||||
disabled={refreshing || fundsLength === 0}
|
||||
aria-busy={refreshing}
|
||||
title="立即刷新"
|
||||
>
|
||||
<RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,10 +40,6 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
|
||||
marginBottom: 12,
|
||||
padding: '20px 16px',
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${isDragging ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: isDragging
|
||||
? 'rgba(34, 211, 238, 0.08)'
|
||||
: 'rgba(255, 255, 255, 0.02)',
|
||||
transition: 'border-color 0.2s ease, background 0.2s ease',
|
||||
cursor: isScanning ? 'not-allowed' : 'pointer',
|
||||
pointerEvents: isScanning ? 'none' : 'auto',
|
||||
@@ -64,7 +60,7 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal scan-pick-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: 420, maxWidth: '90vw' }}
|
||||
>
|
||||
@@ -75,7 +71,7 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
|
||||
从相册选择一张或多张持仓截图,系统将自动识别其中的<span style={{ color: 'var(--primary)' }}>基金代码(6位数字)</span>,并支持批量导入。
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
className={`scan-pick-dropzone muted ${isDragging ? 'dragging' : ''}`}
|
||||
style={dropZoneStyle}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsIcon } from './Icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
export default function SettingsModal({
|
||||
onClose,
|
||||
@@ -10,21 +12,44 @@ export default function SettingsModal({
|
||||
exportLocalData,
|
||||
importFileRef,
|
||||
handleImportFileChange,
|
||||
importMsg
|
||||
importMsg,
|
||||
isMobile,
|
||||
containerWidth = 1200,
|
||||
setContainerWidth,
|
||||
onResetContainerWidth,
|
||||
}) {
|
||||
const [sliderDragging, setSliderDragging] = useState(false);
|
||||
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sliderDragging) return;
|
||||
const onPointerUp = () => setSliderDragging(false);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [sliderDragging]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}>
|
||||
<div
|
||||
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>设置</span>
|
||||
<span className="muted">配置刷新频率</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
|
||||
<div className="chips" style={{ marginBottom: 12 }}>
|
||||
{[10, 30, 60, 120, 300].map((s) => (
|
||||
{[30, 60, 120, 300].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -40,19 +65,65 @@ export default function SettingsModal({
|
||||
className="input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min="10"
|
||||
min="30"
|
||||
step="5"
|
||||
value={tempSeconds}
|
||||
onChange={(e) => setTempSeconds(Number(e.target.value))}
|
||||
placeholder="自定义秒数"
|
||||
/>
|
||||
{tempSeconds < 10 && (
|
||||
{tempSeconds < 30 && (
|
||||
<div className="error-text" style={{ marginTop: 8 }}>
|
||||
最小 10 秒
|
||||
最小 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 }}>
|
||||
<input
|
||||
type="range"
|
||||
min={600}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
|
||||
onChange={(e) => setContainerWidth(Number(e.target.value))}
|
||||
onPointerDown={() => setSliderDragging(true)}
|
||||
className="page-width-slider"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
accentColor: 'var(--primary)',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
|
||||
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
@@ -77,9 +148,21 @@ export default function SettingsModal({
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 10}>保存并关闭</button>
|
||||
<button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
{resetWidthConfirmOpen && onResetContainerWidth && (
|
||||
<ConfirmModal
|
||||
title="重置页面宽度"
|
||||
message="是否重置页面宽度为默认值 1200px?"
|
||||
onConfirm={() => {
|
||||
onResetContainerWidth();
|
||||
setResetWidthConfirmOpen(false);
|
||||
}}
|
||||
onCancel={() => setResetWidthConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal trade-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '420px' }}
|
||||
>
|
||||
@@ -184,19 +184,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: 'rgba(230, 162, 60, 0.1)',
|
||||
border: '1px solid rgba(230, 162, 60, 0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#e6a23c',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
className="trade-pending-alert"
|
||||
onClick={() => setShowPendingList(true)}
|
||||
>
|
||||
<span>⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易</span>
|
||||
@@ -206,7 +194,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
|
||||
{showPendingList ? (
|
||||
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<div className="pending-list-header" style={{ position: 'sticky', top: 0, zIndex: 1, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(6px)', paddingBottom: 8, marginBottom: 8, borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="pending-list-header trade-pending-header">
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setShowPendingList(false)}
|
||||
@@ -217,7 +205,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
<div className="pending-list-items" style={{ paddingTop: 0 }}>
|
||||
{currentPendingTrades.map((trade, idx) => (
|
||||
<div key={trade.id || idx} style={{ background: 'rgba(255,255,255,0.05)', padding: 12, borderRadius: 8, marginBottom: 8 }}>
|
||||
<div key={trade.id || idx} className="trade-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
|
||||
{trade.type === 'buy' ? '买入' : '卖出'}
|
||||
@@ -231,17 +219,11 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
|
||||
<span className="muted">状态</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
||||
<span className="trade-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary trade-revoke-btn"
|
||||
onClick={() => setRevokeTrade(trade)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
fontSize: '10px',
|
||||
height: 'auto',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'var(--text)'
|
||||
}}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
@@ -263,7 +245,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
{showConfirm ? (
|
||||
isBuy ? (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
||||
<div className="trade-confirm-card">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span className="muted">基金名称</span>
|
||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||
@@ -288,7 +270,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<span className="muted">买入日期</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
||||
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||
<span className="muted">交易时段</span>
|
||||
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
|
||||
</div>
|
||||
@@ -301,7 +283,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||
@@ -310,7 +292,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
</div>
|
||||
{price ? (
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
|
||||
@@ -326,9 +308,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
className="button secondary trade-back-btn"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
@@ -345,7 +327,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
|
||||
<div className="trade-confirm-card">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span className="muted">基金名称</span>
|
||||
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
|
||||
@@ -370,7 +352,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<span className="muted">卖出日期</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
|
||||
<div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
|
||||
<span className="muted">预计回款</span>
|
||||
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
|
||||
</div>
|
||||
@@ -383,7 +365,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
|
||||
@@ -392,7 +374,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</div>
|
||||
</div>
|
||||
{price ? (
|
||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
|
||||
<div className="trade-preview-card" style={{ flex: 1 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 (估)</div>
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
|
||||
@@ -408,9 +390,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
className="button secondary trade-back-btn"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
@@ -472,36 +454,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
@@ -544,17 +508,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
className="trade-amount-btn"
|
||||
onClick={() => handleSetShareFraction(opt.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -563,7 +518,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
)}
|
||||
{holding && (
|
||||
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
||||
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span style={{ color: '#e6a23c', marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
||||
当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && <span className="trade-pending-status" style={{ marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} 份</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -614,36 +569,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: isAfter3pm ? 'var(--primary)' : 'transparent',
|
||||
color: isAfter3pm ? '#05263b' : 'var(--muted)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px'
|
||||
}}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
@@ -663,7 +600,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: 12, marginTop: 12 }}>
|
||||
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||||
<button type="button" className="button secondary trade-cancel-btn" onClick={onClose} style={{ flex: 1 }}>取消</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function TransactionHistoryModal({
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
className="glass card modal tx-history-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
@@ -88,22 +88,14 @@ export default function TransactionHistoryModal({
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
|
||||
{pendingTransactions.map((item) => (
|
||||
<div key={item.id} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
||||
<div key={item.id} className="tx-history-pending-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
{item.type === 'buy' && item.isDca && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
color: '#4ade80'
|
||||
}}
|
||||
>
|
||||
<span className="tx-history-dca-badge">
|
||||
定投
|
||||
</span>
|
||||
)}
|
||||
@@ -115,11 +107,11 @@ export default function TransactionHistoryModal({
|
||||
<span>{item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`}</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
|
||||
<span className="tx-history-pending-status">等待净值更新...</span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary tx-history-action-btn"
|
||||
onClick={() => handleDeleteClick(item, 'pending')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)' }}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
@@ -136,22 +128,14 @@ export default function TransactionHistoryModal({
|
||||
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
|
||||
) : (
|
||||
sortedTransactions.map((item) => (
|
||||
<div key={item.id} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
|
||||
<div key={item.id} className="tx-history-record-item">
|
||||
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
|
||||
{item.type === 'buy' ? '买入' : '卖出'}
|
||||
</span>
|
||||
{item.type === 'buy' && item.isDca && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
color: '#4ade80'
|
||||
}}
|
||||
>
|
||||
<span className="tx-history-dca-badge">
|
||||
定投
|
||||
</span>
|
||||
)}
|
||||
@@ -175,9 +159,9 @@ export default function TransactionHistoryModal({
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
|
||||
<span className="muted"></span>
|
||||
<button
|
||||
className="button secondary"
|
||||
className="button secondary tx-history-action-btn"
|
||||
onClick={() => handleDeleteClick(item, 'history')}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)', color: 'var(--muted)' }}
|
||||
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
|
||||
1239
app/globals.css
1239
app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,15 @@ export default function RootLayout({ children }) {
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
{/* 尽早设置 data-theme,减少首屏主题闪烁;与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AnalyticsGate GA_ID={GA_ID} />
|
||||
|
||||
738
app/page.jsx
738
app/page.jsx
File diff suppressed because it is too large
Load Diff
75
package-lock.json
generated
75
package-lock.json
generated
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -722,6 +726,73 @@
|
||||
"@dicebear/core": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,6 +13,10 @@
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chart.js": "^4.5.1",
|
||||
|
||||
Reference in New Issue
Block a user