feat:分组资产增加眼睛以及移动端粘性布局

This commit is contained in:
hzm
2026-02-09 23:09:34 +08:00
parent 5fea9afd90
commit 8a82f2f486
3 changed files with 105 additions and 27 deletions

View File

@@ -111,6 +111,25 @@ export function MailIcon(props) {
); );
} }
export function EyeIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function EyeOffIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 3l18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M2 12s4-6 10-6 10 6 10 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 9.5l-2-2M10 8.5l-.5-2.5M14 8.5l.5-2.5M17 9.5l2-2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function GridIcon(props) { export function GridIcon(props) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">

View File

@@ -676,6 +676,22 @@ input[type="number"] {
backdrop-filter: none; backdrop-filter: none;
padding: 0; padding: 0;
} }
.group-summary-sticky {
position: sticky;
top: 175px;
z-index: 35;
width: calc(100% + 32px);
margin: 0 -16px 16px -16px;
padding: 8px 16px;
background: rgba(15, 23, 42, 0.9);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
}
.group-summary-sticky .group-summary-card {
margin-bottom: 0;
}
} }
/* 禁止移动端输入框自动缩放 */ /* 禁止移动端输入框自动缩放 */
@@ -753,7 +769,7 @@ input[type="number"] {
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 4px;
} }
.chip { .chip {

View File

@@ -9,7 +9,7 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import Announcement from "./components/Announcement"; import Announcement from "./components/Announcement";
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common"; import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons"; import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons";
import githubImg from "./assets/github.svg"; import githubImg from "./assets/github.svg";
import weChatGroupImg from "./assets/weChatGroup.png"; import weChatGroupImg from "./assets/weChatGroup.png";
import { supabase, isSupabaseConfigured } from './lib/supabase'; import { supabase, isSupabaseConfigured } from './lib/supabase';
@@ -1728,6 +1728,7 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
function GroupSummary({ funds, holdings, groupName, getProfit }) { function GroupSummary({ funds, holdings, groupName, getProfit }) {
const [showPercent, setShowPercent] = useState(true); const [showPercent, setShowPercent] = useState(true);
const [isMasked, setIsMasked] = useState(false);
const rowRef = useRef(null); const rowRef = useRef(null);
const [assetSize, setAssetSize] = useState(24); const [assetSize, setAssetSize] = useState(24);
const [metricSize, setMetricSize] = useState(18); const [metricSize, setMetricSize] = useState(18);
@@ -1789,13 +1790,27 @@ function GroupSummary({ funds, holdings, groupName, getProfit }) {
if (!summary.hasHolding) return null; if (!summary.hasHolding) return null;
return ( return (
<div className="glass card" style={{ marginBottom: 16, padding: '16px 20px', background: 'rgba(255, 255, 255, 0.03)' }}> <div className="glass card group-summary-card" style={{ marginBottom: 8, padding: '16px 20px', background: 'rgba(255, 255, 255, 0.03)' }}>
<div ref={rowRef} className="row" style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}> <div ref={rowRef} className="row" style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}>
<div> <div>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>{groupName}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<div className="muted" style={{ fontSize: '12px' }}>{groupName}</div>
<button
className="fav-button"
onClick={() => setIsMasked(value => !value)}
aria-label={isMasked ? '显示资产' : '隐藏资产'}
style={{ margin: 0, padding: 2, display: 'inline-flex', alignItems: 'center' }}
>
{isMasked ? <EyeOffIcon width="16" height="16" /> : <EyeIcon width="16" height="16" />}
</button>
</div>
<div style={{ fontSize: '24px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}> <div style={{ fontSize: '24px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}>
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span> <span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} /> {isMasked ? (
<span style={{ fontSize: assetSize, position: 'relative', top: 4 }}>******</span>
) : (
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
)}
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 24 }}> <div style={{ display: 'flex', gap: 24 }}>
@@ -1805,8 +1820,14 @@ function GroupSummary({ funds, holdings, groupName, getProfit }) {
className={summary.totalProfitToday > 0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : ''} className={summary.totalProfitToday > 0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : ''}
style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }} style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}
> >
<span style={{ marginRight: 1 }}>{summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''}</span> {isMasked ? (
<CountUp value={Math.abs(summary.totalProfitToday)} style={{ fontSize: metricSize }} /> <span style={{ fontSize: metricSize }}>******</span>
) : (
<>
<span style={{ marginRight: 1 }}>{summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''}</span>
<CountUp value={Math.abs(summary.totalProfitToday)} style={{ fontSize: metricSize }} />
</>
)}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
@@ -1817,12 +1838,16 @@ function GroupSummary({ funds, holdings, groupName, getProfit }) {
onClick={() => setShowPercent(!showPercent)} onClick={() => setShowPercent(!showPercent)}
title="点击切换金额/百分比" title="点击切换金额/百分比"
> >
<span style={{ marginRight: 1 }}>{summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''}</span> {isMasked ? (
{showPercent ? ( <span style={{ fontSize: metricSize }}>******</span>
<CountUp value={Math.abs(summary.returnRate)} suffix="%" style={{ fontSize: metricSize }} />
) : ( ) : (
<> <>
<CountUp value={Math.abs(summary.totalHoldingReturn)} style={{ fontSize: metricSize }} /> <span style={{ marginRight: 1 }}>{summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''}</span>
{showPercent ? (
<CountUp value={Math.abs(summary.returnRate)} suffix="%" style={{ fontSize: metricSize }} />
) : (
<CountUp value={Math.abs(summary.totalHoldingReturn)} style={{ fontSize: metricSize }} />
)}
</> </>
)} )}
</div> </div>
@@ -2387,13 +2412,12 @@ export default function HomePage() {
}, []); }, []);
const storageHelper = useMemo(() => { const storageHelper = useMemo(() => {
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades']); const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
const triggerSync = (key, prevValue, nextValue) => { const triggerSync = (key, prevValue, nextValue) => {
if (keys.has(key)) { if (keys.has(key)) {
if (key === 'funds') { if (key === 'funds') {
const prevSig = getFundCodesSignature(prevValue); const prevSig = getFundCodesSignature(prevValue);
const nextSig = getFundCodesSignature(nextValue); const nextSig = getFundCodesSignature(nextValue);
debugger
if (prevSig === nextSig) return; if (prevSig === nextSig) return;
} }
if (!skipSyncRef.current) { if (!skipSyncRef.current) {
@@ -2424,7 +2448,7 @@ export default function HomePage() {
}, [getFundCodesSignature, scheduleSync]); }, [getFundCodesSignature, scheduleSync]);
useEffect(() => { useEffect(() => {
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades']); const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
const onStorage = (e) => { const onStorage = (e) => {
if (!e.key) return; if (!e.key) return;
if (!keys.has(e.key)) return; if (!keys.has(e.key)) return;
@@ -2442,6 +2466,12 @@ export default function HomePage() {
}; };
}, [getFundCodesSignature, scheduleSync]); }, [getFundCodesSignature, scheduleSync]);
const applyViewMode = useCallback((mode) => {
if (mode !== 'card' && mode !== 'list') return;
setViewMode(mode);
storageHelper.setItem('viewMode', mode);
}, [storageHelper]);
const toggleFavorite = (code) => { const toggleFavorite = (code) => {
setFavorites(prev => { setFavorites(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -2596,6 +2626,10 @@ export default function HomePage() {
if (savedHoldings && typeof savedHoldings === 'object') { if (savedHoldings && typeof savedHoldings === 'object') {
setHoldings(savedHoldings); setHoldings(savedHoldings);
} }
const savedViewMode = localStorage.getItem('viewMode');
if (savedViewMode === 'card' || savedViewMode === 'list') {
setViewMode(savedViewMode);
}
} catch { } } catch { }
}, []); }, []);
@@ -2968,7 +3002,7 @@ export default function HomePage() {
const toggleViewMode = () => { const toggleViewMode = () => {
const nextMode = viewMode === 'card' ? 'list' : 'card'; const nextMode = viewMode === 'card' ? 'list' : 'card';
setViewMode(nextMode); applyViewMode(nextMode);
}; };
const requestRemoveFund = (fund) => { const requestRemoveFund = (fund) => {
@@ -3179,6 +3213,8 @@ export default function HomePage() {
}) })
: []; : [];
const viewMode = payload.viewMode === 'list' ? 'list' : 'card';
return JSON.stringify({ return JSON.stringify({
funds: uniqueFundCodes, funds: uniqueFundCodes,
favorites, favorites,
@@ -3186,7 +3222,8 @@ export default function HomePage() {
collapsedCodes, collapsedCodes,
refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000, refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000,
holdings, holdings,
pendingTrades pendingTrades,
viewMode
}); });
} }
@@ -3196,6 +3233,7 @@ export default function HomePage() {
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); const favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
const groups = JSON.parse(localStorage.getItem('groups') || '[]'); const groups = JSON.parse(localStorage.getItem('groups') || '[]');
const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
const viewMode = localStorage.getItem('viewMode') === 'list' ? 'list' : 'card';
const fundCodes = new Set( const fundCodes = new Set(
Array.isArray(funds) Array.isArray(funds)
? funds.map((f) => f?.code).filter(Boolean) ? funds.map((f) => f?.code).filter(Boolean)
@@ -3252,6 +3290,7 @@ export default function HomePage() {
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
holdings: cleanedHoldings, holdings: cleanedHoldings,
pendingTrades: cleanedPendingTrades, pendingTrades: cleanedPendingTrades,
viewMode,
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
}; };
} catch { } catch {
@@ -3263,6 +3302,7 @@ export default function HomePage() {
refreshMs: 30000, refreshMs: 30000,
holdings: {}, holdings: {},
pendingTrades: [], pendingTrades: [],
viewMode: 'card',
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
}; };
} }
@@ -3298,7 +3338,7 @@ export default function HomePage() {
storageHelper.setItem('refreshMs', String(nextRefreshMs)); storageHelper.setItem('refreshMs', String(nextRefreshMs));
if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') { if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') {
setViewMode(cloudData.viewMode); applyViewMode(cloudData.viewMode);
} }
const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {}; const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {};
@@ -3415,6 +3455,7 @@ export default function HomePage() {
groups: JSON.parse(localStorage.getItem('groups') || '[]'), groups: JSON.parse(localStorage.getItem('groups') || '[]'),
collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'),
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
viewMode: localStorage.getItem('viewMode') === 'list' ? 'list' : 'card',
holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), holdings: JSON.parse(localStorage.getItem('holdings') || '{}'),
pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'),
exportedAt: nowInTz().toISOString() exportedAt: nowInTz().toISOString()
@@ -3520,7 +3561,7 @@ export default function HomePage() {
storageHelper.setItem('refreshMs', String(data.refreshMs)); storageHelper.setItem('refreshMs', String(data.refreshMs));
} }
if (data.viewMode === 'card' || data.viewMode === 'list') { if (data.viewMode === 'card' || data.viewMode === 'list') {
setViewMode(data.viewMode); applyViewMode(data.viewMode);
} }
if (data.holdings && typeof data.holdings === 'object') { if (data.holdings && typeof data.holdings === 'object') {
@@ -3907,7 +3948,7 @@ export default function HomePage() {
</div> </div>
<div className="col-12"> <div className="col-12">
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}> <div className="filter-bar" style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div className="tabs-container"> <div className="tabs-container">
<div <div
className="tabs-scroll-area" className="tabs-scroll-area"
@@ -3988,7 +4029,7 @@ export default function HomePage() {
<div className="view-toggle" style={{ display: 'flex', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '2px' }}> <div className="view-toggle" style={{ display: 'flex', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '2px' }}>
<button <button
className={`icon-button ${viewMode === 'card' ? 'active' : ''}`} className={`icon-button ${viewMode === 'card' ? 'active' : ''}`}
onClick={() => { setViewMode('card'); }} onClick={() => { applyViewMode('card'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'card' ? 'var(--primary)' : 'transparent', color: viewMode === 'card' ? '#05263b' : 'var(--muted)' }} style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'card' ? 'var(--primary)' : 'transparent', color: viewMode === 'card' ? '#05263b' : 'var(--muted)' }}
title="卡片视图" title="卡片视图"
> >
@@ -3996,7 +4037,7 @@ export default function HomePage() {
</button> </button>
<button <button
className={`icon-button ${viewMode === 'list' ? 'active' : ''}`} className={`icon-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => { setViewMode('list'); }} onClick={() => { applyViewMode('list'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'list' ? 'var(--primary)' : 'transparent', color: viewMode === 'list' ? '#05263b' : 'var(--muted)' }} style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'list' ? 'var(--primary)' : 'transparent', color: viewMode === 'list' ? '#05263b' : 'var(--muted)' }}
title="表格视图" title="表格视图"
> >
@@ -4066,12 +4107,14 @@ export default function HomePage() {
</div> </div>
) : ( ) : (
<> <>
<GroupSummary <div className={'group-summary-sticky'}>
funds={displayFunds} <GroupSummary
holdings={holdings} funds={displayFunds}
groupName={getGroupName()} holdings={holdings}
getProfit={getHoldingProfit} groupName={getGroupName()}
/> getProfit={getHoldingProfit}
/>
</div>
{currentTab !== 'all' && currentTab !== 'fav' && ( {currentTab !== 'all' && currentTab !== 'fav' && (
<motion.button <motion.button