feat: 新增大盘指数
This commit is contained in:
@@ -513,6 +513,91 @@ export const fetchShanghaiIndexDate = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 大盘指数项:name, code, price, change, changePercent
|
||||||
|
* 同时用于:
|
||||||
|
* - qt.gtimg.cn 实时快照(code 用于 q= 参数,varKey 为全局变量名)
|
||||||
|
* - 分时 mini 图(code 传给 minute/query,当不支持分时时会自动回退占位折线)
|
||||||
|
*
|
||||||
|
* 参照产品图:覆盖主要 A 股宽基 + 创业/科创 + 部分海外与港股指数。
|
||||||
|
*/
|
||||||
|
const MARKET_INDEX_KEYS = [
|
||||||
|
// 行 1:上证 / 深证
|
||||||
|
{ code: 'sh000001', varKey: 'v_sh000001', name: '上证指数' },
|
||||||
|
{ code: 'sh000016', varKey: 'v_sh000016', name: '上证50' },
|
||||||
|
{ code: 'sz399001', varKey: 'v_sz399001', name: '深证成指' },
|
||||||
|
{ code: 'sz399330', varKey: 'v_sz399330', name: '深证100' },
|
||||||
|
|
||||||
|
// 行 2:北证 / 沪深300 / 创业板
|
||||||
|
{ code: 'bj899050', varKey: 'v_bj899050', name: '北证50' },
|
||||||
|
{ code: 'sh000300', varKey: 'v_sh000300', name: '沪深300' },
|
||||||
|
{ code: 'sz399006', varKey: 'v_sz399006', name: '创业板指' },
|
||||||
|
{ code: 'sz399102', varKey: 'v_sz399102', name: '创业板综' },
|
||||||
|
|
||||||
|
// 行 3:创业板 50 / 科创
|
||||||
|
{ code: 'sz399673', varKey: 'v_sz399673', name: '创业板50' },
|
||||||
|
{ code: 'sh000688', varKey: 'v_sh000688', name: '科创50' },
|
||||||
|
{ code: 'sz399005', varKey: 'v_sz399005', name: '中小100' },
|
||||||
|
|
||||||
|
// 行 4:中证系列
|
||||||
|
{ code: 'sh000905', varKey: 'v_sh000905', name: '中证500' },
|
||||||
|
{ code: 'sh000906', varKey: 'v_sh000906', name: '中证800' },
|
||||||
|
{ code: 'sh000852', varKey: 'v_sh000852', name: '中证1000' },
|
||||||
|
{ code: 'sh000903', varKey: 'v_sh000903', name: '中证A100' },
|
||||||
|
|
||||||
|
// 行 5:等权 / 国证 / 纳指
|
||||||
|
{ code: 'sh000932', varKey: 'v_sh000932', name: '500等权' },
|
||||||
|
{ code: 'sz399303', varKey: 'v_sz399303', name: '国证2000' },
|
||||||
|
{ code: 'usIXIC', varKey: 'v_usIXIC', name: '纳斯达克' },
|
||||||
|
{ code: 'usNDX', varKey: 'v_usNDX', name: '纳斯达克100' },
|
||||||
|
|
||||||
|
// 行 6:美股三大 + 恒生
|
||||||
|
{ code: 'usINX', varKey: 'v_usINX', name: '标普500' },
|
||||||
|
{ code: 'usDJI', varKey: 'v_usDJI', name: '道琼斯' },
|
||||||
|
{ code: 'hkHSI', varKey: 'v_hkHSI', name: '恒生指数' },
|
||||||
|
{ code: 'hkHSTECH', varKey: 'v_hkHSTECH', name: '恒生科技指数' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseIndexRaw(data) {
|
||||||
|
if (!data || typeof data !== 'string') return null;
|
||||||
|
const parts = data.split('~');
|
||||||
|
if (parts.length < 33) return null;
|
||||||
|
const name = parts[1] || '';
|
||||||
|
const price = parseFloat(parts[3], 10);
|
||||||
|
const change = parseFloat(parts[31], 10);
|
||||||
|
const changePercent = parseFloat(parts[32], 10);
|
||||||
|
if (Number.isNaN(price)) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
price: Number.isFinite(price) ? price : 0,
|
||||||
|
change: Number.isFinite(change) ? change : 0,
|
||||||
|
changePercent: Number.isFinite(changePercent) ? changePercent : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMarketIndices = async () => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
const codes = MARKET_INDEX_KEYS.map((item) => item.code).join(',');
|
||||||
|
script.src = `https://qt.gtimg.cn/q=${codes}&_t=${Date.now()}`;
|
||||||
|
script.onload = () => {
|
||||||
|
const list = MARKET_INDEX_KEYS.map(({ name: defaultName, varKey }) => {
|
||||||
|
const raw = window[varKey];
|
||||||
|
const parsed = parseIndexRaw(raw);
|
||||||
|
if (!parsed) return { name: defaultName, code: '', price: 0, change: 0, changePercent: 0 };
|
||||||
|
return { ...parsed, code: varKey.replace('v_', '') };
|
||||||
|
});
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
resolve(list);
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
reject(new Error('指数数据加载失败'));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchLatestRelease = async () => {
|
export const fetchLatestRelease = async () => {
|
||||||
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
|
const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL;
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|||||||
481
app/components/MarketIndexAccordion.jsx
Normal file
481
app/components/MarketIndexAccordion.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
409
app/components/MarketSettingModal.jsx
Normal file
409
app/components/MarketSettingModal.jsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AnimatePresence, Reorder } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerClose,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { CloseIcon, MinusIcon, ResetIcon, SettingsIcon } from "./Icons";
|
||||||
|
import ConfirmModal from "./ConfirmModal";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指数个性化设置弹框
|
||||||
|
*
|
||||||
|
* - 移动端:使用 Drawer(自底向上抽屉)
|
||||||
|
* - PC 端:使用 Dialog(居中弹窗)
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - 是否打开
|
||||||
|
* @param {() => void} props.onClose - 关闭回调
|
||||||
|
* @param {boolean} props.isMobile - 是否为移动端(由上层传入)
|
||||||
|
* @param {Array<{code:string,name:string,price:number,change:number,changePercent:number}>} props.indices - 当前可用的大盘指数列表
|
||||||
|
* @param {string[]} props.selectedCodes - 已选中的指数 code,决定展示顺序
|
||||||
|
* @param {(codes: string[]) => void} props.onChangeSelected - 更新选中指数集合
|
||||||
|
* @param {() => void} props.onResetDefault - 恢复默认选中集合
|
||||||
|
*/
|
||||||
|
export default function MarketSettingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
isMobile,
|
||||||
|
indices = [],
|
||||||
|
selectedCodes = [],
|
||||||
|
onChangeSelected,
|
||||||
|
onResetDefault,
|
||||||
|
}) {
|
||||||
|
const selectedList = useMemo(() => {
|
||||||
|
if (!indices?.length || !selectedCodes?.length) return [];
|
||||||
|
const map = new Map(indices.map((it) => [it.code, it]));
|
||||||
|
return selectedCodes
|
||||||
|
.map((code) => map.get(code))
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [indices, selectedCodes]);
|
||||||
|
|
||||||
|
const allIndices = indices || [];
|
||||||
|
const selectedSet = useMemo(
|
||||||
|
() => new Set(selectedCodes || []),
|
||||||
|
[selectedCodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleCode = (code) => {
|
||||||
|
if (!code) return;
|
||||||
|
if (selectedSet.has(code)) {
|
||||||
|
// 至少保留一个指数,阻止把最后一个也移除
|
||||||
|
if (selectedCodes.length <= 1) return;
|
||||||
|
const next = selectedCodes.filter((c) => c !== code);
|
||||||
|
onChangeSelected?.(next);
|
||||||
|
} else {
|
||||||
|
const next = [...selectedCodes, code];
|
||||||
|
onChangeSelected?.(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorder = (newOrder) => {
|
||||||
|
onChangeSelected?.(newOrder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-4 pt-2 text-[var(--text)]">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>已添加指数</div>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
拖动下方指数即可排序
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedList.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
padding: "12px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
暂未添加指数,请在下方选择想要关注的指数。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
as="div"
|
||||||
|
axis="y"
|
||||||
|
values={selectedCodes}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="flex flex-wrap gap-3"
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{selectedList.map((item) => {
|
||||||
|
const isUp = item.change >= 0;
|
||||||
|
const color =
|
||||||
|
isUp ? "var(--danger)" : "var(--success)";
|
||||||
|
return (
|
||||||
|
<Reorder.Item
|
||||||
|
key={item.code}
|
||||||
|
value={item.code}
|
||||||
|
className={cn(
|
||||||
|
"glass card",
|
||||||
|
"relative flex flex-col gap-1.5 rounded-xl border border-[var(--border)] bg-[var(--card)] px-3 py-2"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
cursor: "grab",
|
||||||
|
flex: "0 0 calc((100% - 24px) / 3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCodes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleCode(item.code);
|
||||||
|
}}
|
||||||
|
className="icon-button"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "rgba(255,96,96,0.1)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
border: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
aria-label={`移除 ${item.name}`}
|
||||||
|
>
|
||||||
|
<MinusIcon width="10" height="10" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
paddingRight: 18,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.price?.toFixed
|
||||||
|
? item.price.toFixed(2)
|
||||||
|
: String(item.price ?? "-")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item.change >= 0 ? "+" : "") +
|
||||||
|
item.change.toFixed(2)}{" "}
|
||||||
|
{(item.changePercent >= 0 ? "+" : "") +
|
||||||
|
item.changePercent.toFixed(2)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
点击即可选指数
|
||||||
|
</div>
|
||||||
|
{onResetDefault && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setResetConfirmOpen(true)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
aria-label="恢复默认指数"
|
||||||
|
>
|
||||||
|
<ResetIcon width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="chips"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allIndices.map((item) => {
|
||||||
|
const active = selectedSet.has(item.code);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.code || item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleCode(item.code)}
|
||||||
|
className={cn("chip", active && "active")}
|
||||||
|
style={{
|
||||||
|
height: 30,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "0 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) onClose?.();
|
||||||
|
}}
|
||||||
|
direction="bottom"
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
className="glass"
|
||||||
|
defaultHeight="77vh"
|
||||||
|
minHeight="40vh"
|
||||||
|
maxHeight="90vh"
|
||||||
|
>
|
||||||
|
<DrawerHeader className="flex flex-row items-center justify-between gap-2 py-4">
|
||||||
|
<DrawerTitle className="flex items-center gap-2.5 text-left">
|
||||||
|
<SettingsIcon width="20" height="20" />
|
||||||
|
<span>指数个性化设置</span>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerClose
|
||||||
|
className="icon-button border-none bg-transparent p-1"
|
||||||
|
title="关闭"
|
||||||
|
style={{
|
||||||
|
borderColor: "transparent",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto">{body}</div>
|
||||||
|
</DrawerContent>
|
||||||
|
<AnimatePresence>
|
||||||
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
key="mobile-index-reset-confirm"
|
||||||
|
title="恢复默认指数"
|
||||||
|
message="是否恢复已添加指数为默认配置?"
|
||||||
|
icon={
|
||||||
|
<ResetIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
className="shrink-0 text-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
confirmVariant="primary"
|
||||||
|
confirmText="恢复默认"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetDefault?.();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="!p-0 max-w-xl"
|
||||||
|
overlayClassName="modal-overlay"
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<div className="glass card modal">
|
||||||
|
<DialogHeader
|
||||||
|
className="flex flex-row items-center justify-between gap-2 mb-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<SettingsIcon width="20" height="20" />
|
||||||
|
<DialogTitle>指数个性化设置</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button border-none bg-transparent p-1"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
style={{ maxHeight: "70vh", overflowY: "auto" }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{resetConfirmOpen && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="恢复默认指数"
|
||||||
|
message="是否恢复已添加指数为默认配置?"
|
||||||
|
icon={
|
||||||
|
<ResetIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
className="shrink-0 text-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
confirmVariant="primary"
|
||||||
|
confirmText="恢复默认"
|
||||||
|
onConfirm={() => {
|
||||||
|
onResetDefault?.();
|
||||||
|
setResetConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setResetConfirmOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -168,6 +168,13 @@ body::before {
|
|||||||
width: 1200px;
|
width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
/* 隐藏 y 轴滚动条,保留滚动能力 */
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.container::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-width-slider {
|
.page-width-slider {
|
||||||
@@ -978,6 +985,13 @@ input[type="number"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
|
/* 移动端同样隐藏 y 轴滚动条 */
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.container::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -1890,7 +1904,6 @@ input[type="number"] {
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 60px; /* Navbar height */
|
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: calc(100% + 32px);
|
width: calc(100% + 32px);
|
||||||
background: rgba(15, 23, 42, 0.9);
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
|||||||
17
app/page.jsx
17
app/page.jsx
@@ -59,6 +59,7 @@ import UpdatePromptModal from "./components/UpdatePromptModal";
|
|||||||
import RefreshButton from "./components/RefreshButton";
|
import RefreshButton from "./components/RefreshButton";
|
||||||
import WeChatModal from "./components/WeChatModal";
|
import WeChatModal from "./components/WeChatModal";
|
||||||
import DcaModal from "./components/DcaModal";
|
import DcaModal from "./components/DcaModal";
|
||||||
|
import MarketIndexAccordion from "./components/MarketIndexAccordion";
|
||||||
import githubImg from "./assets/github.svg";
|
import githubImg from "./assets/github.svg";
|
||||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||||
import { toast as sonnerToast } from 'sonner';
|
import { toast as sonnerToast } from 'sonner';
|
||||||
@@ -243,6 +244,7 @@ export default function HomePage() {
|
|||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||||
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
const [filterBarHeight, setFilterBarHeight] = useState(0);
|
||||||
|
const [marketIndexAccordionHeight, setMarketIndexAccordionHeight] = useState(0);
|
||||||
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
// 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复
|
||||||
const [theme, setTheme] = useState('dark');
|
const [theme, setTheme] = useState('dark');
|
||||||
const [showThemeTransition, setShowThemeTransition] = useState(false);
|
const [showThemeTransition, setShowThemeTransition] = useState(false);
|
||||||
@@ -3784,10 +3786,15 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MarketIndexAccordion
|
||||||
|
navbarHeight={navbarHeight}
|
||||||
|
onHeightChange={setMarketIndexAccordionHeight}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||||
|
/>
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<div ref={filterBarRef} className="filter-bar" style={{ ...(isMobile ? {} : { top: navbarHeight }), marginTop: navbarHeight, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
<div ref={filterBarRef} className="filter-bar" style={{ top: navbarHeight + marketIndexAccordionHeight, marginTop: 0, 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"
|
||||||
@@ -3947,7 +3954,7 @@ export default function HomePage() {
|
|||||||
holdings={holdings}
|
holdings={holdings}
|
||||||
groupName={getGroupName()}
|
groupName={getGroupName()}
|
||||||
getProfit={getHoldingProfit}
|
getProfit={getHoldingProfit}
|
||||||
stickyTop={navbarHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight + (isMobile ? -14 : 0)}
|
||||||
masked={maskAmounts}
|
masked={maskAmounts}
|
||||||
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
onToggleMasked={() => setMaskAmounts((v) => !v)}
|
||||||
/>
|
/>
|
||||||
@@ -4007,7 +4014,7 @@ export default function HomePage() {
|
|||||||
<div className="table-scroll-area">
|
<div className="table-scroll-area">
|
||||||
<div className="table-scroll-area-inner">
|
<div className="table-scroll-area-inner">
|
||||||
<PcFundTable
|
<PcFundTable
|
||||||
stickyTop={navbarHeight + filterBarHeight}
|
stickyTop={navbarHeight + marketIndexAccordionHeight + filterBarHeight}
|
||||||
data={pcFundTableData}
|
data={pcFundTableData}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
@@ -4089,7 +4096,7 @@ export default function HomePage() {
|
|||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
favorites={favorites}
|
favorites={favorites}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
stickyTop={navbarHeight + filterBarHeight - 14}
|
stickyTop={navbarHeight + filterBarHeight + marketIndexAccordionHeight}
|
||||||
blockDrawerClose={!!fundDeleteConfirm}
|
blockDrawerClose={!!fundDeleteConfirm}
|
||||||
closeDrawerRef={fundDetailDrawerCloseRef}
|
closeDrawerRef={fundDetailDrawerCloseRef}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
|
|||||||
64
components/ui/accordion.jsx
Normal file
64
components/ui/accordion.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}>
|
||||||
|
<div className={cn("pt-0 pb-4 w-full", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
Reference in New Issue
Block a user