Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dbe1c7cbb | ||
|
|
c2f4fec86d | ||
|
|
cbfa9a433a | ||
|
|
b27ab48d27 | ||
|
|
f33c6397c0 | ||
|
|
1146f88466 | ||
|
|
8f2ca3ab23 | ||
|
|
f3adc1c7aa | ||
|
|
d5131b87db | ||
|
|
d8d5e7b100 | ||
|
|
026dbfceeb | ||
|
|
21eb5d7fd7 |
@@ -197,6 +197,10 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
*此处补录的买入/卖出仅作记录展示,不会改变当前持仓金额与份额;实际持仓请在持仓设置中维护。
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button primary full-width"
|
||||
onClick={handleSubmit}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v9';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v10';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -67,11 +67,10 @@ 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.7 版本更新内容如下:</p>
|
||||
<p>1. 实时基金估值折线图(测试版)。</p>
|
||||
<p>2. 定投。</p>
|
||||
以下内容会在近期更新:
|
||||
<p>1. 自定义布局。</p>
|
||||
<p>v0.1.8 版本更新内容如下:</p>
|
||||
<p>1. 重构PC表格界面的实现。</p>
|
||||
<p>2. 允许对PC表格列宽拖拽并存储拖拽后的列宽。</p>
|
||||
关于部分用户反馈数据丢失问题,建议大家登录账号进行数据同步。不然切换域名或清理浏览器缓存都会造成数据丢失。
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TrashIcon } from './Icons';
|
||||
|
||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
||||
return (
|
||||
const content = (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
@@ -40,4 +41,6 @@ export default function ConfirmModal({ title, message, onConfirm, onCancel, conf
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
|
||||
扣款日 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div
|
||||
className="scrollbar-y-styled"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
|
||||
@@ -77,6 +77,13 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon(props) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
|
||||
634
app/components/PcFundTable.jsx
Normal file
634
app/components/PcFundTable.jsx
Normal file
@@ -0,0 +1,634 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { ExitIcon, ResetIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* PC 端基金列表表格组件(基于 @tanstack/react-table)
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.data - 表格数据
|
||||
* 每一行推荐结构(字段命名与 page.jsx 中的数据一致):
|
||||
* {
|
||||
* fundName: string; // 基金名称
|
||||
* code?: string; // 基金代码(可选,只用于展示在名称下方)
|
||||
* navOrEstimate: string|number; // 净值/估值
|
||||
* yesterdayChangePercent: string|number; // 昨日涨跌幅
|
||||
* estimateChangePercent: string|number; // 估值涨跌幅
|
||||
* holdingAmount: string|number; // 持仓金额
|
||||
* todayProfit: string|number; // 当日收益
|
||||
* holdingProfit: string|number; // 持有收益
|
||||
* }
|
||||
* @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 {(row: any) => void} [props.onHoldingProfitClick] - 点击持有收益
|
||||
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
|
||||
*/
|
||||
export default function PcFundTable({
|
||||
data = [],
|
||||
onRemoveFund,
|
||||
currentTab,
|
||||
favorites = new Set(),
|
||||
onToggleFavorite,
|
||||
onRemoveFromGroup,
|
||||
onHoldingAmountClick,
|
||||
onHoldingProfitClick,
|
||||
refreshing = false,
|
||||
}) {
|
||||
const getStoredColumnSizing = () => {
|
||||
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)),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const persistColumnSizing = (nextSizing) => {
|
||||
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));
|
||||
} catch { }
|
||||
};
|
||||
|
||||
const [columnSizing, setColumnSizing] = useState(() => {
|
||||
const stored = getStoredColumnSizing();
|
||||
if (stored.actions) {
|
||||
const { actions, ...rest } = stored;
|
||||
return rest;
|
||||
}
|
||||
return stored;
|
||||
});
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const handleResetSizing = () => {
|
||||
setColumnSizing({});
|
||||
persistColumnSizing({});
|
||||
setResetConfirmOpen(false);
|
||||
};
|
||||
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 columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fundName',
|
||||
header: '基金名称',
|
||||
size: 240,
|
||||
minSize: 100,
|
||||
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>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'left',
|
||||
cellClassName: 'name-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'navOrEstimate',
|
||||
header: '净值/估值',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
cell: (info) => (
|
||||
<span style={{ fontWeight: 700 }}>{info.getValue() ?? '—'}</span>
|
||||
),
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'value-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 2 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'change-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'estimateChangePercent',
|
||||
header: '估值涨跌幅',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
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: 2 }}>
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'est-change-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingAmount',
|
||||
header: '持仓金额',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
if (original.holdingAmountValue == null) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="muted"
|
||||
title="设置持仓"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', 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 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
未设置 <SettingsIcon width="12" height="12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
title="点击设置持仓"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, marginRight: 6 }}>{info.getValue() ?? '—'}</span>
|
||||
<button
|
||||
className="icon-button no-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
|
||||
}}
|
||||
title="编辑持仓"
|
||||
style={{ border: 'none', width: '28px', height: '28px', marginLeft: -6 }}
|
||||
>
|
||||
<SettingsIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'holding-amount-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
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';
|
||||
return (
|
||||
<span className={cls} style={{ fontWeight: 700 }}>
|
||||
{hasProfit ? (info.getValue() ?? '') : ''}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'profit-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'holdingProfit',
|
||||
header: '持有收益',
|
||||
size: 140,
|
||||
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';
|
||||
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>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'holding-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>操作</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
setResetConfirmOpen(true);
|
||||
}}
|
||||
title="重置列宽"
|
||||
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
|
||||
>
|
||||
<ResetIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
size: 80,
|
||||
minSize: 80,
|
||||
maxSize: 80,
|
||||
enableResizing: false,
|
||||
enablePinning: true,
|
||||
meta: {
|
||||
align: 'center',
|
||||
isAction: true,
|
||||
cellClassName: 'action-cell',
|
||||
},
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation?.();
|
||||
if (refreshing) return;
|
||||
onRemoveFundRef.current?.(original);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
className="icon-button danger"
|
||||
onClick={handleClick}
|
||||
title="删除"
|
||||
disabled={refreshing}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
opacity: refreshing ? 0.6 : 1,
|
||||
cursor: refreshing ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<TrashIcon width="14" height="14" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
columnResizeMode: 'onChange',
|
||||
onColumnSizingChange: (updater) => {
|
||||
setColumnSizing((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
const { actions, ...rest } = next || {};
|
||||
persistColumnSizing(rest || {});
|
||||
return rest || {};
|
||||
});
|
||||
},
|
||||
state: {
|
||||
columnSizing,
|
||||
},
|
||||
initialState: {
|
||||
columnPinning: {
|
||||
left: ['fundName'],
|
||||
right: ['actions'],
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
defaultColumn: {
|
||||
cell: (info) => info.getValue() ?? '—',
|
||||
},
|
||||
});
|
||||
|
||||
const headerGroup = table.getHeaderGroups()[0];
|
||||
|
||||
const getCommonPinningStyles = (column, isHeader) => {
|
||||
const isPinned = column.getIsPinned();
|
||||
const isNameColumn =
|
||||
column.id === 'fundName' || column.columnDef?.accessorKey === 'fundName';
|
||||
const style = {
|
||||
width: `${column.getSize()}px`,
|
||||
};
|
||||
if (!isPinned) return style;
|
||||
|
||||
const isLeft = isPinned === 'left';
|
||||
const isRight = isPinned === 'right';
|
||||
|
||||
return {
|
||||
...style,
|
||||
position: 'sticky',
|
||||
left: isLeft ? `${column.getStart('left')}px` : undefined,
|
||||
right: isRight ? `${column.getAfter('right')}px` : undefined,
|
||||
zIndex: isHeader ? 11 : 10,
|
||||
backgroundColor: isHeader ? '#2a394b' : 'var(--row-bg)',
|
||||
boxShadow: 'none',
|
||||
textAlign: isNameColumn ? 'left' : 'center',
|
||||
justifyContent: isNameColumn ? 'flex-start' : 'center',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.table-row-scroll {
|
||||
--row-bg: var(--bg);
|
||||
background-color: var(--row-bg);
|
||||
}
|
||||
.table-row-scroll:hover {
|
||||
--row-bg: #2a394b;
|
||||
}
|
||||
|
||||
/* 覆盖 grid 布局为 flex 以支持动态列宽 */
|
||||
.table-header-row-scroll,
|
||||
.table-row-scroll {
|
||||
display: flex !important;
|
||||
width: fit-content !important;
|
||||
min-width: 100%;
|
||||
gap: 0 !important; /* Reset gap because we control width explicitly */
|
||||
}
|
||||
|
||||
.table-header-cell,
|
||||
.table-cell {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
position: relative; /* For resizer */
|
||||
}
|
||||
|
||||
/* 拖拽把手样式 */
|
||||
.resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 12%;
|
||||
bottom: 12%;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.2s, background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.resizer:hover::after {
|
||||
opacity: 1;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
.table-header-cell:hover .resizer::after {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.resizer.disabled {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resizer.disabled::after {
|
||||
opacity: 0;
|
||||
}
|
||||
`}</style>
|
||||
{/* 表头 */}
|
||||
{headerGroup && (
|
||||
<div className="table-header-row table-header-row-scroll">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const style = getCommonPinningStyles(header.column, true);
|
||||
const isNameColumn =
|
||||
header.column.id === 'fundName' ||
|
||||
header.column.columnDef?.accessorKey === 'fundName';
|
||||
const align = isNameColumn ? '' : 'text-center';
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`table-header-cell ${align}`}
|
||||
style={style}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<div
|
||||
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
|
||||
className={`resizer ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
} ${header.column.getCanResize() ? '' : 'disabled'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表体 */}
|
||||
<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>
|
||||
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<div className="table-row empty-row">
|
||||
<div className="table-cell" style={{ textAlign: 'center' }}>
|
||||
<span className="muted">暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{resetConfirmOpen && (
|
||||
<ConfirmModal
|
||||
title="重置列宽"
|
||||
message="是否重置表格列宽为默认值?"
|
||||
onConfirm={handleResetSizing}
|
||||
onCancel={() => setResetConfirmOpen(false)}
|
||||
confirmText="重置"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
192
app/globals.css
192
app/globals.css
@@ -53,7 +53,8 @@ body::before {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1120px;
|
||||
max-width: 90%;
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -481,7 +482,9 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -644,6 +647,13 @@ input[type="number"] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 不需要 hover 高亮的图标按钮(例如 PC 表格模式中的“编辑持仓”) */
|
||||
.icon-button.no-hover:hover {
|
||||
color: inherit;
|
||||
transform: none;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -709,22 +719,190 @@ input[type="number"] {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
/* PC 列表:左右分块,左侧可横向滚动,右侧操作列固定不参与滚动 */
|
||||
.table-pc-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-scroll-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
/* 横向滚动条样式与项目整体规范一致:深色背景、边框色、圆角、hover 高亮 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) var(--bg);
|
||||
}
|
||||
|
||||
.table-scroll-area::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.table-scroll-area::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.table-scroll-area::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.table-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.table-scroll-area::-webkit-scrollbar-thumb:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* 纵向滚动条通用样式(与项目整体规范一致,供弹窗、列表等使用) */
|
||||
.scrollbar-y-styled {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) var(--bg);
|
||||
}
|
||||
|
||||
.scrollbar-y-styled::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-y-styled::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-y-styled::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-y-styled::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.scrollbar-y-styled::-webkit-scrollbar-thumb:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* 固定像素列宽,避免初始在视口外的列滚入后布局错误、内容不展示;不依赖 fr/内容测量 */
|
||||
.table-scroll-area-inner {
|
||||
/*width: 1192px;*/
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
/* 基金名称 净值 涨跌幅 估值涨跌幅 估值时间 持仓金额 当日收益 持有收益(三列同宽) */
|
||||
.table-header-row-scroll,
|
||||
.table-row-scroll {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 100px 100px 100px 140px 140px 140px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-header-row-scroll {
|
||||
padding: 16px 24px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-header-row-scroll .table-header-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表格行内容不换行,单行显示 */
|
||||
.table-row-scroll .table-cell {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 当日收益、持有收益列:约束内容不溢出到相邻列,过长显示省略号 */
|
||||
.table-row-scroll .profit-cell,
|
||||
.table-row-scroll .holding-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.table-row-scroll .profit-cell > *,
|
||||
.table-row-scroll .holding-cell > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table-row-scroll .name-cell .name-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table-row-scroll {
|
||||
padding: 12px 24px !important;
|
||||
min-height: 52px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-row-scroll:hover,
|
||||
.table-row-scroll.row-hovered {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.table-fixed-row.row-hovered {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.table-fixed-col {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-header-cell-fixed {
|
||||
padding: 16px 12px;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.table-fixed-row {
|
||||
min-height: 59px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-fixed-row .table-cell {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table-row-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1.2fr 1.2fr 1.2fr 60px;
|
||||
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr 0.5fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: #2a394b;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
@@ -733,10 +911,10 @@ input[type="number"] {
|
||||
|
||||
.table-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1.2fr 1.2fr 1.2fr 60px;
|
||||
grid-template-columns: 2.2fr 0.8fr 1fr 1fr 1.2fr 1.2fr 1.2fr 0.5fr;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: #2a394b;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
230
app/page.jsx
230
app/page.jsx
@@ -43,6 +43,7 @@ import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||
import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
|
||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, extractFundNamesWithLLM } from './api/fund';
|
||||
import packageJson from '../package.json';
|
||||
import PcFundTable from './components/PcFundTable';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -447,6 +448,7 @@ export default function HomePage() {
|
||||
const todayStr = formatDate();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [hoveredPcRowCode, setHoveredPcRowCode] = useState(null); // PC 列表行悬浮高亮
|
||||
const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -559,7 +561,7 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
// 计算持仓收益
|
||||
const getHoldingProfit = (fund, holding) => {
|
||||
const getHoldingProfit = useCallback((fund, holding) => {
|
||||
if (!holding || !isNumber(holding.share)) return null;
|
||||
|
||||
const hasTodayData = fund.jzrq === todayStr;
|
||||
@@ -633,35 +635,121 @@ export default function HomePage() {
|
||||
profitToday,
|
||||
profitTotal
|
||||
};
|
||||
};
|
||||
}, [isTradingDay, todayStr]);
|
||||
|
||||
|
||||
// 过滤和排序后的基金列表
|
||||
const displayFunds = funds
|
||||
.filter(f => {
|
||||
if (currentTab === 'all') return true;
|
||||
if (currentTab === 'fav') return favorites.has(f.code);
|
||||
const group = groups.find(g => g.id === currentTab);
|
||||
return group ? group.codes.includes(f.code) : true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'yield') {
|
||||
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
|
||||
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
}
|
||||
if (sortBy === 'holding') {
|
||||
const pa = getHoldingProfit(a, holdings[a.code]);
|
||||
const pb = getHoldingProfit(b, holdings[b.code]);
|
||||
const valA = pa?.profitTotal ?? Number.NEGATIVE_INFINITY;
|
||||
const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY;
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
}
|
||||
if (sortBy === 'name') {
|
||||
return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN');
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const displayFunds = useMemo(
|
||||
() => funds
|
||||
.filter(f => {
|
||||
if (currentTab === 'all') return true;
|
||||
if (currentTab === 'fav') return favorites.has(f.code);
|
||||
const group = groups.find(g => g.id === currentTab);
|
||||
return group ? group.codes.includes(f.code) : true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'yield') {
|
||||
const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0);
|
||||
const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0);
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
}
|
||||
if (sortBy === 'holding') {
|
||||
const pa = getHoldingProfit(a, holdings[a.code]);
|
||||
const pb = getHoldingProfit(b, holdings[b.code]);
|
||||
const valA = pa?.profitTotal ?? Number.NEGATIVE_INFINITY;
|
||||
const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY;
|
||||
return sortOrder === 'asc' ? valA - valB : valB - valA;
|
||||
}
|
||||
if (sortBy === 'name') {
|
||||
return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN');
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit],
|
||||
);
|
||||
|
||||
// PC 端表格数据(用于 PcFundTable)
|
||||
const pcFundTableData = useMemo(
|
||||
() =>
|
||||
displayFunds.map((f) => {
|
||||
const hasTodayData = f.jzrq === todayStr;
|
||||
const shouldHideChange = isTradingDay && !hasTodayData;
|
||||
const navOrEstimate = !shouldHideChange
|
||||
? (f.dwjz ?? '—')
|
||||
: (f.noValuation
|
||||
? (f.dwjz ?? '—')
|
||||
: (f.estPricedCoverage > 0.05
|
||||
? (f.estGsz != null ? Number(f.estGsz).toFixed(4) : '—')
|
||||
: (f.gsz ?? '—')));
|
||||
|
||||
const yesterdayChangePercent =
|
||||
f.zzl != null && f.zzl !== ''
|
||||
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
|
||||
: '—';
|
||||
const yesterdayChangeValue =
|
||||
f.zzl != null && f.zzl !== '' ? Number(f.zzl) : null;
|
||||
const yesterdayDate = f.jzrq || '-';
|
||||
|
||||
const estimateChangePercent = f.noValuation
|
||||
? '—'
|
||||
: (f.estPricedCoverage > 0.05
|
||||
? (f.estGszzl != null
|
||||
? `${f.estGszzl > 0 ? '+' : ''}${Number(f.estGszzl).toFixed(2)}%`
|
||||
: '—')
|
||||
: (isNumber(f.gszzl)
|
||||
? `${f.gszzl > 0 ? '+' : ''}${Number(f.gszzl).toFixed(2)}%`
|
||||
: (f.gszzl ?? '—')));
|
||||
const estimateChangeValue = f.noValuation
|
||||
? null
|
||||
: (f.estPricedCoverage > 0.05
|
||||
? (isNumber(f.estGszzl) ? Number(f.estGszzl) : null)
|
||||
: (isNumber(f.gszzl) ? Number(f.gszzl) : null));
|
||||
const estimateTime = f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-');
|
||||
|
||||
const holding = holdings[f.code];
|
||||
const profit = getHoldingProfit(f, holding);
|
||||
const amount = profit ? profit.amount : null;
|
||||
const holdingAmount =
|
||||
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
|
||||
const holdingAmountValue = amount;
|
||||
|
||||
const profitToday = profit ? profit.profitToday : null;
|
||||
const todayProfit =
|
||||
profitToday == null
|
||||
? ''
|
||||
: `${profitToday > 0 ? '+' : profitToday < 0 ? '-' : ''}¥${Math.abs(profitToday).toFixed(2)}`;
|
||||
const todayProfitValue = profitToday;
|
||||
|
||||
const total = profit ? profit.profitTotal : null;
|
||||
const holdingProfit =
|
||||
total == null
|
||||
? ''
|
||||
: `${total > 0 ? '+' : total < 0 ? '-' : ''}¥${Math.abs(total).toFixed(2)}`;
|
||||
const holdingProfitValue = total;
|
||||
|
||||
return {
|
||||
rawFund: f,
|
||||
code: f.code,
|
||||
fundName: f.name,
|
||||
isUpdated: f.jzrq === todayStr,
|
||||
navOrEstimate,
|
||||
yesterdayChangePercent,
|
||||
yesterdayChangeValue,
|
||||
yesterdayDate,
|
||||
estimateChangePercent,
|
||||
estimateChangeValue,
|
||||
estimateChangeMuted: f.noValuation,
|
||||
estimateTime,
|
||||
holdingAmount,
|
||||
holdingAmountValue,
|
||||
todayProfit,
|
||||
todayProfitValue,
|
||||
holdingProfit,
|
||||
holdingProfitValue,
|
||||
};
|
||||
}),
|
||||
[displayFunds, holdings, isTradingDay, todayStr, getHoldingProfit],
|
||||
);
|
||||
|
||||
// 自动滚动选中 Tab 到可视区域
|
||||
useEffect(() => {
|
||||
@@ -824,24 +912,7 @@ export default function HomePage() {
|
||||
|
||||
const handleAddHistory = (data) => {
|
||||
const fundCode = data.fundCode;
|
||||
const current = holdings[fundCode] || { share: 0, cost: 0 };
|
||||
const isBuy = data.type === 'buy';
|
||||
|
||||
let newShare, newCost;
|
||||
|
||||
if (isBuy) {
|
||||
newShare = current.share + data.share;
|
||||
// 加权平均成本
|
||||
const buyCost = data.amount; // amount is total cost
|
||||
newCost = (current.cost * current.share + buyCost) / newShare;
|
||||
} else {
|
||||
newShare = Math.max(0, current.share - data.share);
|
||||
newCost = current.cost;
|
||||
if (newShare === 0) newCost = 0;
|
||||
}
|
||||
|
||||
handleSaveHolding(fundCode, { share: newShare, cost: newCost });
|
||||
|
||||
// 添加历史记录仅作补录展示,不修改真实持仓金额与份额
|
||||
setTransactions(prev => {
|
||||
const current = prev[fundCode] || [];
|
||||
const record = {
|
||||
@@ -853,8 +924,9 @@ export default function HomePage() {
|
||||
date: data.date,
|
||||
isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false
|
||||
isDca: false,
|
||||
isHistoryOnly: true, // 仅记录,不参与持仓计算
|
||||
timestamp: data.timestamp || Date.now()
|
||||
};
|
||||
}
|
||||
// 按时间倒序排列
|
||||
const next = [record, ...current].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
const nextState = { ...prev, [fundCode]: next };
|
||||
@@ -3654,7 +3726,49 @@ export default function HomePage() {
|
||||
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
|
||||
>
|
||||
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
|
||||
{viewMode === 'list' && (
|
||||
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */}
|
||||
{viewMode === 'list' && !isMobile && (
|
||||
<div className="table-pc-wrap">
|
||||
<div className="table-scroll-area">
|
||||
<div className="table-scroll-area-inner">
|
||||
<PcFundTable
|
||||
data={pcFundTableData}
|
||||
refreshing={refreshing}
|
||||
currentTab={currentTab}
|
||||
favorites={favorites}
|
||||
onRemoveFund={(row) => {
|
||||
if (refreshing) return;
|
||||
if (!row || !row.code) return;
|
||||
requestRemoveFund({ code: row.code, name: row.fundName });
|
||||
}}
|
||||
onToggleFavorite={(row) => {
|
||||
if (!row || !row.code) return;
|
||||
toggleFavorite(row.code);
|
||||
}}
|
||||
onRemoveFromGroup={(row) => {
|
||||
if (!row || !row.code) return;
|
||||
removeFundFromCurrentGroup(row.code);
|
||||
}}
|
||||
onHoldingAmountClick={(row, meta) => {
|
||||
if (!row || !row.code) return;
|
||||
const fund = row.rawFund || { code: row.code, name: row.fundName };
|
||||
if (meta?.hasHolding) {
|
||||
setActionModal({ open: true, fund });
|
||||
} else {
|
||||
setHoldingModal({ open: true, fund });
|
||||
}
|
||||
}}
|
||||
onHoldingProfitClick={(row) => {
|
||||
if (!row || !row.code) return;
|
||||
if (row.holdingProfitValue == null) return;
|
||||
setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && isMobile && (
|
||||
<div className="table-header-row">
|
||||
<div className="table-header-cell">基金名称</div>
|
||||
<div className="table-header-cell text-right">净值/估值</div>
|
||||
@@ -3667,7 +3781,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayFunds.map((f) => (
|
||||
{displayFunds.map((f) =>
|
||||
(viewMode === 'list' && !isMobile) ? null : (
|
||||
<motion.div
|
||||
layout="position"
|
||||
key={f.code}
|
||||
@@ -3993,20 +4108,25 @@ export default function HomePage() {
|
||||
{(() => {
|
||||
const hasTodayData = f.jzrq === todayStr;
|
||||
let isYesterdayChange = false;
|
||||
if (!hasTodayData && isString(f.gztime) && isString(f.jzrq)) {
|
||||
const gzDate = toTz(f.gztime).startOf('day');
|
||||
let isPreviousTradingDay = false;
|
||||
if (!hasTodayData && isString(f.jzrq)) {
|
||||
const today = toTz(todayStr).startOf('day');
|
||||
const jzDate = toTz(f.jzrq).startOf('day');
|
||||
if (gzDate.clone().subtract(1, 'day').isSame(jzDate, 'day')) {
|
||||
const yesterday = today.clone().subtract(1, 'day');
|
||||
if (jzDate.isSame(yesterday, 'day')) {
|
||||
isYesterdayChange = true;
|
||||
} else if (jzDate.isBefore(yesterday, 'day')) {
|
||||
isPreviousTradingDay = true;
|
||||
}
|
||||
}
|
||||
const shouldHideChange = isTradingDay && !hasTodayData && !isYesterdayChange;
|
||||
const shouldHideChange = isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
|
||||
|
||||
if (shouldHideChange) return null;
|
||||
|
||||
const changeLabel = hasTodayData ? '涨跌幅' : (isYesterdayChange ? '昨日涨跌幅' : (isPreviousTradingDay ? '上一交易日涨跌幅' : '涨跌幅'));
|
||||
return (
|
||||
<Stat
|
||||
label={isYesterdayChange ? '昨日涨跌幅' : '涨跌幅'}
|
||||
label={changeLabel}
|
||||
value={f.zzl !== undefined ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : ''}
|
||||
delta={f.zzl}
|
||||
/>
|
||||
@@ -4160,7 +4280,8 @@ export default function HomePage() {
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -4588,4 +4709,3 @@ export default function HomePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
@@ -1772,6 +1773,39 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.29.2",
|
||||
|
||||
Reference in New Issue
Block a user