feat: 新增大盘指数

This commit is contained in:
hzm
2026-03-15 00:03:21 +08:00
parent c85e0021cd
commit bc5ed496aa
6 changed files with 1065 additions and 6 deletions

View File

@@ -0,0 +1,481 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { fetchMarketIndices } from '@/app/api/fund';
import { ChevronRightIcon } from 'lucide-react';
import { SettingsIcon } from './Icons';
import { cn } from '@/lib/utils';
import MarketSettingModal from './MarketSettingModal';
/** 简单伪随机,用于稳定迷你图形状 */
function seeded(seed) {
return () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
}
/** 迷你走势:优先展示当日分时数据,失败时退回占位折线 */
function MiniTrendLine({ changePercent, code, className }) {
const isDown = changePercent <= 0;
const width = 80;
const height = 28;
const pad = 3;
const innerH = height - 2 * pad;
const innerW = width - 2 * pad;
// 占位伪走势(无真实历史数据)
const fallbackPath = useMemo(() => {
const points = 12;
const rnd = seeded(Math.abs(Math.floor(changePercent * 100)) + 1);
const arr = Array.from({ length: points }, (_, i) => {
const t = i / (points - 1);
const x = pad + t * innerW;
const y = isDown
? pad + innerH * (1 - t * 0.6) - (rnd() * 4 - 2)
: pad + innerH * (0.4 + t * 0.6) + (rnd() * 4 - 2);
return [x, Math.max(pad, Math.min(height - pad, y))];
});
return arr.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
}, [changePercent, isDown, innerH, innerW, pad, height]);
// 当日分时真实走势 path
const [realPath, setRealPath] = useState(null);
useEffect(() => {
if (!code || typeof window === 'undefined' || typeof document === 'undefined') {
setRealPath(null);
return;
}
let cancelled = false;
const varName = `min_data_${code}`;
const url = `https://web.ifzq.gtimg.cn/appstock/app/minute/query?_var=${varName}&code=${code}&_=${Date.now()}`;
const script = document.createElement('script');
script.src = url;
script.async = true;
const cleanup = () => {
if (document.body && document.body.contains(script)) {
document.body.removeChild(script);
}
try {
if (window[varName]) {
delete window[varName];
}
} catch (e) {
// ignore
}
};
script.onload = () => {
if (cancelled) {
cleanup();
return;
}
try {
const raw = window[varName];
const series =
raw &&
raw.data &&
raw.data[code] &&
raw.data[code].data &&
Array.isArray(raw.data[code].data.data)
? raw.data[code].data.data
: null;
if (!series || !series.length) {
setRealPath(null);
return;
}
// 解析 "HHMM price volume amount" 行,只关心 price
const points = series
.map((row) => {
const parts = String(row).split(' ');
const price = parseFloat(parts[1]);
if (!Number.isFinite(price)) return null;
return { price };
})
.filter(Boolean);
if (!points.length) {
setRealPath(null);
return;
}
const minP = points.reduce((m, p) => (p.price < m ? p.price : m), points[0].price);
const maxP = points.reduce((m, p) => (p.price > m ? p.price : m), points[0].price);
const span = maxP - minP || 1;
const n = points.length;
const pathPoints = points.map((p, idx) => {
const t = n > 1 ? idx / (n - 1) : 0;
const x = pad + t * innerW;
const norm = (p.price - minP) / span;
const y = pad + (1 - norm) * innerH;
return [x, Math.max(pad, Math.min(height - pad, y))];
});
const d = pathPoints
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`)
.join(' ');
setRealPath(d);
} finally {
cleanup();
}
};
script.onerror = () => {
if (!cancelled) {
setRealPath(null);
}
cleanup();
};
document.body.appendChild(script);
return () => {
cancelled = true;
cleanup();
};
}, [code, height, innerH, innerW, pad]);
const d = realPath || fallbackPath;
return (
<svg
width={width}
height={height}
className={cn('overflow-visible', className)}
aria-hidden
>
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={isDown ? 'text-[var(--success)]' : 'text-[var(--danger)]'}
/>
</svg>
);
}
function IndexCard({ item }) {
const isUp = item.change >= 0;
const colorClass = isUp ? 'text-[var(--danger)]' : 'text-[var(--success)]';
return (
<div
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-1.5 flex flex-col gap-0.5 w-full"
>
<div className="text-xs font-medium text-[var(--foreground)] truncate">{item.name}</div>
<div className={cn('text-sm font-semibold tabular-nums', colorClass)}>
{item.price.toFixed(2)}
</div>
<div className={cn('text-xs tabular-nums', colorClass)}>
{(item.change >= 0 ? '+' : '') + item.change.toFixed(2)}{' '}
{(item.changePercent >= 0 ? '+' : '') + item.changePercent.toFixed(2)}%
</div>
<div className="mt-0.5 flex items-center justify-center opacity-80">
<MiniTrendLine changePercent={item.changePercent} code={item.code} />
</div>
</div>
);
}
// 默认展示:上证指数、深证成指、创业板指
const DEFAULT_SELECTED_CODES = ['sh000001', 'sz399001', 'sz399006'];
export default function MarketIndexAccordion({
navbarHeight = 0,
onHeightChange,
isMobile,
onCustomSettingsChange,
}) {
const [indices, setIndices] = useState([]);
const [loading, setLoading] = useState(true);
const [openValue, setOpenValue] = useState('');
const [selectedCodes, setSelectedCodes] = useState([]);
const [settingOpen, setSettingOpen] = useState(false);
const [tickerIndex, setTickerIndex] = useState(0);
const rootRef = useRef(null);
useEffect(() => {
const el = rootRef.current;
if (!el || typeof onHeightChange !== 'function') return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) onHeightChange(entry.contentRect.height);
});
ro.observe(el);
onHeightChange(el.getBoundingClientRect().height);
return () => {
ro.disconnect();
onHeightChange(0);
};
}, [onHeightChange, loading, indices.length]);
useEffect(() => {
let cancelled = false;
fetchMarketIndices()
.then((data) => {
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
})
.catch(() => {
if (!cancelled) setIndices([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
// 初始化选中指数(本地偏好 > 默认集合)
useEffect(() => {
if (!indices.length || typeof window === 'undefined') return;
try {
const stored = window.localStorage.getItem('marketIndexSelected');
const availableCodes = new Set(indices.map((it) => it.code));
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const filtered = parsed.filter((c) => availableCodes.has(c));
if (filtered.length) {
setSelectedCodes(filtered);
return;
}
}
}
const defaults = DEFAULT_SELECTED_CODES.filter((c) => availableCodes.has(c));
setSelectedCodes(defaults.length ? defaults : indices.map((it) => it.code).slice(0, 3));
} catch {
setSelectedCodes(indices.map((it) => it.code).slice(0, 3));
}
}, [indices]);
// 持久化用户选择
useEffect(() => {
if (typeof window === 'undefined') return;
if (!selectedCodes.length) return;
try {
// 本地首选 key独立存储便于快速读取
window.localStorage.setItem('marketIndexSelected', JSON.stringify(selectedCodes));
// 同步到 customSettings便于云端同步
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const next = parsed && typeof parsed === 'object' ? { ...parsed, marketIndexSelected: selectedCodes } : { marketIndexSelected: selectedCodes };
window.localStorage.setItem('customSettings', JSON.stringify(next));
onCustomSettingsChange?.();
} catch {
// ignore
}
}, [selectedCodes]);
// 用户已选择的指数列表(按 selectedCodes 顺序)
const visibleIndices = selectedCodes.length
? selectedCodes
.map((code) => indices.find((it) => it.code === code))
.filter(Boolean)
: indices;
// 重置 tickerIndex 确保索引合法
useEffect(() => {
if (tickerIndex >= visibleIndices.length) {
setTickerIndex(0);
}
}, [visibleIndices.length, tickerIndex]);
// 收起状态下轮播展示指数
useEffect(() => {
if (!visibleIndices.length) return;
if (openValue === 'indices') return;
if (visibleIndices.length <= 1) return;
const timer = setInterval(() => {
setTickerIndex((prev) => (prev + 1) % visibleIndices.length);
}, 4000);
return () => clearInterval(timer);
}, [visibleIndices.length, openValue]);
const current =
visibleIndices.length === 0
? null
: visibleIndices[openValue === 'indices' ? 0 : tickerIndex];
const isUp = current ? current.change >= 0 : false;
const colorClass = isUp ? 'text-[var(--danger)]' : 'text-[var(--success)]';
const topMargin = Number(navbarHeight) || 0;
const stickyStyle = {
marginTop: topMargin,
position: 'sticky',
top: topMargin,
zIndex: 10,
width: 'calc(100% + 24px)',
marginLeft: -12,
};
if (loading && indices.length === 0) {
return (
<div
ref={rootRef}
className="market-index-accordion-root mt-2 mb-2 rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-3 flex items-center justify-between"
style={stickyStyle}
>
<span className="text-sm text-[var(--muted-foreground)]">加载大盘指数</span>
</div>
);
}
return (
<div
ref={rootRef}
className="market-index-accordion-root mt-2 mb-2 rounded-lg border border-[var(--border)] bg-[var(--card)] market-index-accordion"
style={stickyStyle}
>
<style jsx>{`
.market-index-accordion :global([data-slot="accordion-trigger"] > svg:last-of-type) {
display: none;
}
:global([data-theme='dark'] .market-index-accordion-root) {
background-color: rgba(15, 23, 42, 0.9);
}
.market-index-ticker {
overflow: hidden;
}
.market-index-ticker-item {
display: inline-flex;
align-items: center;
gap: 0.75rem;
animation: market-index-ticker-slide 0.35s ease-out;
}
@keyframes market-index-ticker-slide {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
`}</style>
<Accordion
type="single"
collapsible
value={openValue}
onValueChange={setOpenValue}
>
<AccordionItem value="indices" className="border-b-0">
<AccordionTrigger
className="py-3 px-4 hover:no-underline hover:bg-[var(--card)] [&[data-state=open]>svg]:rotate-90"
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<div className="flex flex-1 items-center gap-3 min-w-0">
{current ? (
<div className="market-index-ticker">
<div
key={current.code || current.name}
className="market-index-ticker-item"
>
<span className="text-sm font-medium text-[var(--foreground)] shrink-0">
{current.name}
</span>
<span className={cn('tabular-nums font-medium', colorClass)}>
{current.price.toFixed(2)}
</span>
<span className={cn('tabular-nums text-sm', colorClass)}>
{(current.change >= 0 ? '+' : '') + current.change.toFixed(2)}
</span>
<span className={cn('tabular-nums text-sm', colorClass)}>
{(current.changePercent >= 0 ? '+' : '') + current.changePercent.toFixed(2)}%
</span>
</div>
</div>
) : (
<span className="text-sm text-[var(--muted-foreground)]">暂无指数数据</span>
)}
</div>
<div className="flex items-center gap-4 shrink-0 pl-3">
<div
role="button"
tabIndex={openValue === 'indices' ? 0 : -1}
className="icon-button"
style={{
border: 'none',
width: '28px',
height: '28px',
minWidth: '28px',
backgroundColor: 'transparent',
color: 'var(--text)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: openValue === 'indices' ? 1 : 0,
pointerEvents: openValue === 'indices' ? 'auto' : 'none',
transition: 'opacity 0.2s ease',
}}
onClick={(e) => {
e.stopPropagation();
setSettingOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setSettingOpen(true);
}
}}
aria-label="指数个性化设置"
>
<SettingsIcon width="18" height="18" />
</div>
<ChevronRightIcon
className={cn(
'w-4 h-4 text-[var(--muted-foreground)] transition-transform',
openValue === 'indices' ? 'rotate-90' : ''
)}
aria-hidden="true"
/>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-4 pt-0">
<div
className="flex flex-wrap w-full min-w-0"
style={{ gap: 12 }}
>
{visibleIndices.map((item, i) => (
<div
key={item.code || i}
style={{ flex: '0 0 calc((100% - 24px) / 3)' }}
>
<IndexCard item={item} />
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<MarketSettingModal
open={settingOpen}
onClose={() => setSettingOpen(false)}
isMobile={isMobile}
indices={indices}
selectedCodes={selectedCodes}
onChangeSelected={setSelectedCodes}
onResetDefault={() => {
const availableCodes = new Set(indices.map((it) => it.code));
const defaults = DEFAULT_SELECTED_CODES.filter((c) => availableCodes.has(c));
setSelectedCodes(defaults.length ? defaults : indices.map((it) => it.code).slice(0, 3));
}}
/>
</div>
);
}