diff --git a/app/api/fund.js b/app/api/fund.js index 7c75713..218bf90 100644 --- a/app/api/fund.js +++ b/app/api/fund.js @@ -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 () => { const url = process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL; if (!url) return null; diff --git a/app/components/MarketIndexAccordion.jsx b/app/components/MarketIndexAccordion.jsx new file mode 100644 index 0000000..10af6e6 --- /dev/null +++ b/app/components/MarketIndexAccordion.jsx @@ -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 ( + + + + ); +} + +function IndexCard({ item }) { + const isUp = item.change >= 0; + const colorClass = isUp ? 'text-[var(--danger)]' : 'text-[var(--success)]'; + return ( +
+
{item.name}
+
+ {item.price.toFixed(2)} +
+
+ {(item.change >= 0 ? '+' : '') + item.change.toFixed(2)}{' '} + {(item.changePercent >= 0 ? '+' : '') + item.changePercent.toFixed(2)}% +
+
+ +
+
+ ); +} + +// 默认展示:上证指数、深证成指、创业板指 +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 ( +
+ 加载大盘指数… +
+ ); + } + + return ( +
+ + + + +
+ {current ? ( +
+
+ + {current.name} + + + {current.price.toFixed(2)} + + + {(current.change >= 0 ? '+' : '') + current.change.toFixed(2)} + + + {(current.changePercent >= 0 ? '+' : '') + current.changePercent.toFixed(2)}% + +
+
+ ) : ( + 暂无指数数据 + )} +
+
+
{ + e.stopPropagation(); + setSettingOpen(true); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + setSettingOpen(true); + } + }} + aria-label="指数个性化设置" + > + +
+
+
+ +
+ {visibleIndices.map((item, i) => ( +
+ +
+ ))} +
+
+
+
+ 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)); + }} + /> +
+ ); +} diff --git a/app/components/MarketSettingModal.jsx b/app/components/MarketSettingModal.jsx new file mode 100644 index 0000000..893f0e1 --- /dev/null +++ b/app/components/MarketSettingModal.jsx @@ -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 = ( +
+
+
+
+
已添加指数
+
+ 拖动下方指数即可排序 +
+
+
+ + {selectedList.length === 0 ? ( +
+ 暂未添加指数,请在下方选择想要关注的指数。 +
+ ) : ( + + + {selectedList.map((item) => { + const isUp = item.change >= 0; + const color = + isUp ? "var(--danger)" : "var(--success)"; + return ( + + {selectedCodes.length > 1 && ( + + )} +
+ {item.name} +
+
+ {item.price?.toFixed + ? item.price.toFixed(2) + : String(item.price ?? "-")} +
+
+ {(item.change >= 0 ? "+" : "") + + item.change.toFixed(2)}{" "} + {(item.changePercent >= 0 ? "+" : "") + + item.changePercent.toFixed(2)} + % +
+
+ ); + })} +
+
+ )} +
+ +
+
+
+ 点击即可选指数 +
+ {onResetDefault && ( + + )} +
+ +
+ {allIndices.map((item) => { + const active = selectedSet.has(item.code); + return ( + + ); + })} +
+
+
+ ); + + if (!open) return null; + + if (isMobile) { + return ( + { + if (!v) onClose?.(); + }} + direction="bottom" + > + + + + + 指数个性化设置 + + + + + +
{body}
+
+ + {resetConfirmOpen && ( + + } + confirmVariant="primary" + confirmText="恢复默认" + onConfirm={() => { + onResetDefault?.(); + setResetConfirmOpen(false); + }} + onCancel={() => setResetConfirmOpen(false)} + /> + )} + +
+ ); + } + + return ( + { + if (!v) onClose?.(); + }} + > + +
+ +
+ + 指数个性化设置 +
+ + + +
+
+ {body} +
+
+ {resetConfirmOpen && ( + + } + confirmVariant="primary" + confirmText="恢复默认" + onConfirm={() => { + onResetDefault?.(); + setResetConfirmOpen(false); + }} + onCancel={() => setResetConfirmOpen(false)} + /> + )} +
+
+ ); +} + diff --git a/app/globals.css b/app/globals.css index 2de65d4..d65a052 100644 --- a/app/globals.css +++ b/app/globals.css @@ -168,6 +168,13 @@ body::before { width: 1200px; margin: 0 auto; padding: 24px; + /* 隐藏 y 轴滚动条,保留滚动能力 */ + scrollbar-width: none; + -ms-overflow-style: none; +} +.container::-webkit-scrollbar { + width: 0; + display: none; } .page-width-slider { @@ -978,6 +985,13 @@ input[type="number"] { width: 100%; max-width: 100%; overflow-x: clip; + /* 移动端同样隐藏 y 轴滚动条 */ + scrollbar-width: none; + -ms-overflow-style: none; + } + .container::-webkit-scrollbar { + width: 0; + display: none; } .grid { @@ -1890,7 +1904,6 @@ input[type="number"] { @media (max-width: 640px) { .filter-bar { position: sticky; - top: 60px; /* Navbar height */ z-index: 40; width: calc(100% + 32px); background: rgba(15, 23, 42, 0.9); diff --git a/app/page.jsx b/app/page.jsx index f34ef94..9504f32 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -59,6 +59,7 @@ import UpdatePromptModal from "./components/UpdatePromptModal"; import RefreshButton from "./components/RefreshButton"; import WeChatModal from "./components/WeChatModal"; import DcaModal from "./components/DcaModal"; +import MarketIndexAccordion from "./components/MarketIndexAccordion"; import githubImg from "./assets/github.svg"; import { supabase, isSupabaseConfigured } from './lib/supabase'; import { toast as sonnerToast } from 'sonner'; @@ -243,6 +244,7 @@ export default function HomePage() { const containerRef = useRef(null); const [navbarHeight, setNavbarHeight] = useState(0); const [filterBarHeight, setFilterBarHeight] = useState(0); + const [marketIndexAccordionHeight, setMarketIndexAccordionHeight] = useState(0); // 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复 const [theme, setTheme] = useState('dark'); const [showThemeTransition, setShowThemeTransition] = useState(false); @@ -3784,10 +3786,15 @@ export default function HomePage() { - +
-
+
setMaskAmounts((v) => !v)} /> @@ -4007,7 +4014,7 @@ export default function HomePage() {
; +} + +function AccordionItem({ + className, + ...props +}) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}) { + return ( + + svg]:rotate-180", + className + )} + {...props}> + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }