/gi) || [];
+ for (const r of dataRows) {
+ const tds = (r.match(/([\s\S]*?)<\/td>/gi) || []).map(td => td.replace(/<[^>]*>/g, '').trim());
+ if (!tds.length) continue;
+ let code = '';
+ let name = '';
+ let weight = '';
+ if (idxCode >= 0 && tds[idxCode]) {
+ const m = tds[idxCode].match(/(\d{6})/);
+ code = m ? m[1] : tds[idxCode];
+ } else {
+ const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
+ if (codeIdx >= 0) code = tds[codeIdx];
+ }
+ if (idxName >= 0 && tds[idxName]) {
+ name = tds[idxName];
+ } else if (code) {
+ const i = tds.findIndex(txt => txt && txt !== code && !/%$/.test(txt));
+ name = i >= 0 ? tds[i] : '';
+ }
+ if (idxWeight >= 0 && tds[idxWeight]) {
+ const wm = tds[idxWeight].match(/([\d.]+)\s*%/);
+ weight = wm ? `${wm[1]}%` : tds[idxWeight];
+ } else {
+ const wIdx = tds.findIndex(txt => /\d+(?:\.\d+)?\s*%/.test(txt));
+ weight = wIdx >= 0 ? tds[wIdx].match(/([\d.]+)\s*%/)?.[1] + '%' : '';
+ }
+ if (code || name || weight) {
+ holdings.push({ code, name, weight, change: null });
+ }
+ }
+ holdings = holdings.slice(0, 10);
+ const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
+ if (needQuotes.length) {
+ try {
+ const tencentCodes = needQuotes.map(h => {
+ const cd = String(h.code || '');
+ if (/^\d{6}$/.test(cd)) {
+ const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
+ return `s_${pfx}${cd}`;
+ }
+ if (/^\d{5}$/.test(cd)) {
+ return `s_hk${cd}`;
+ }
+ return null;
+ }).filter(Boolean).join(',');
+ if (!tencentCodes) {
+ resolveH(holdings);
+ return;
+ }
+ const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
+ await new Promise((resQuote) => {
+ const scriptQuote = document.createElement('script');
+ scriptQuote.src = quoteUrl;
+ scriptQuote.onload = () => {
+ needQuotes.forEach(h => {
+ const cd = String(h.code || '');
+ let varName = '';
+ if (/^\d{6}$/.test(cd)) {
+ const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
+ varName = `v_s_${pfx}${cd}`;
+ } else if (/^\d{5}$/.test(cd)) {
+ varName = `v_s_hk${cd}`;
+ } else {
+ return;
+ }
+ const dataStr = window[varName];
+ if (dataStr) {
+ const parts = dataStr.split('~');
+ if (parts.length > 5) {
+ h.change = parseFloat(parts[5]);
+ }
+ }
+ });
+ if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
+ resQuote();
+ };
+ scriptQuote.onerror = () => {
+ if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
+ resQuote();
+ };
+ document.body.appendChild(scriptQuote);
+ });
+ } catch (e) {
+ }
+ }
+ resolveH(holdings);
+ }).catch(() => resolveH([]));
+ });
+ Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
+ if (tData) {
+ if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
+ gzData.dwjz = tData.dwjz;
+ gzData.jzrq = tData.jzrq;
+ gzData.zzl = tData.zzl;
+ }
+ }
+ resolve({ ...gzData, holdings });
+ });
+ };
+ scriptGz.onerror = () => {
+ window.jsonpgz = originalJsonpgz;
+ if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
+ reject(new Error('基金数据加载失败'));
+ };
+ document.body.appendChild(scriptGz);
+ setTimeout(() => {
+ if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
+ }, 5000);
+ });
+};
+
+export const searchFunds = async (val) => {
+ if (!val.trim()) return [];
+ if (typeof window === 'undefined' || typeof document === 'undefined') return [];
+ const callbackName = `SuggestData_${Date.now()}`;
+ const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
+ return new Promise((resolve, reject) => {
+ window[callbackName] = (data) => {
+ let results = [];
+ if (data && data.Datas) {
+ results = data.Datas.filter(d =>
+ d.CATEGORY === 700 ||
+ d.CATEGORY === '700' ||
+ d.CATEGORYDESC === '基金'
+ );
+ }
+ delete window[callbackName];
+ resolve(results);
+ };
+ const script = document.createElement('script');
+ script.src = url;
+ script.async = true;
+ script.onload = () => {
+ if (document.body.contains(script)) document.body.removeChild(script);
+ };
+ script.onerror = () => {
+ if (document.body.contains(script)) document.body.removeChild(script);
+ delete window[callbackName];
+ reject(new Error('搜索请求失败'));
+ };
+ document.body.appendChild(script);
+ });
+};
+
+export const fetchShanghaiIndexDate = async () => {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return null;
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = `https://qt.gtimg.cn/q=sh000001&_t=${Date.now()}`;
+ script.onload = () => {
+ const data = window.v_sh000001;
+ let dateStr = null;
+ if (data) {
+ const parts = data.split('~');
+ if (parts.length > 30) {
+ dateStr = parts[30].slice(0, 8);
+ }
+ }
+ if (document.body.contains(script)) document.body.removeChild(script);
+ resolve(dateStr);
+ };
+ script.onerror = () => {
+ if (document.body.contains(script)) document.body.removeChild(script);
+ reject(new Error('指数数据加载失败'));
+ };
+ document.body.appendChild(script);
+ });
+};
+
+export const fetchLatestRelease = async () => {
+ const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
+ if (!res.ok) return null;
+ const data = await res.json();
+ return {
+ tagName: data.tag_name,
+ body: data.body || ''
+ };
+};
+
+export const submitFeedback = async (formData) => {
+ const response = await fetch('https://api.web3forms.com/submit', {
+ method: 'POST',
+ body: formData
+ });
+ return response.json();
+};
diff --git a/app/components/Common.jsx b/app/components/Common.jsx
new file mode 100644
index 0000000..c397934
--- /dev/null
+++ b/app/components/Common.jsx
@@ -0,0 +1,286 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import zhifubaoImg from "../assets/zhifubao.jpg";
+import weixinImg from "../assets/weixin.jpg";
+import { CalendarIcon, MinusIcon, PlusIcon } from './Icons';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+dayjs.tz.setDefault('Asia/Shanghai');
+
+const TZ = 'Asia/Shanghai';
+const nowInTz = () => dayjs().tz(TZ);
+const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
+const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
+
+export function DatePicker({ value, onChange }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
+
+ useEffect(() => {
+ const close = () => setIsOpen(false);
+ if (isOpen) window.addEventListener('click', close);
+ return () => window.removeEventListener('click', close);
+ }, [isOpen]);
+
+ const year = currentMonth.year();
+ const month = currentMonth.month();
+
+ const handlePrevMonth = (e) => {
+ e.stopPropagation();
+ setCurrentMonth(currentMonth.subtract(1, 'month').startOf('month'));
+ };
+
+ const handleNextMonth = (e) => {
+ e.stopPropagation();
+ setCurrentMonth(currentMonth.add(1, 'month').startOf('month'));
+ };
+
+ const handleSelect = (e, day) => {
+ e.stopPropagation();
+ const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
+
+ const today = nowInTz().startOf('day');
+ const selectedDate = toTz(dateStr).startOf('day');
+
+ if (selectedDate.isAfter(today)) return;
+
+ onChange(dateStr);
+ setIsOpen(false);
+ };
+
+ const daysInMonth = currentMonth.daysInMonth();
+ const firstDayOfWeek = currentMonth.startOf('month').day();
+
+ const days = [];
+ for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
+ for (let i = 1; i <= daysInMonth; i++) days.push(i);
+
+ return (
+ e.stopPropagation()}>
+ setIsOpen(!isOpen)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '0 12px',
+ height: '40px',
+ background: 'rgba(0,0,0,0.2)',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ border: '1px solid transparent',
+ transition: 'all 0.2s'
+ }}
+ >
+ {value || '选择日期'}
+
+
+
+
+ {isOpen && (
+
+
+
+ {year}年 {month + 1}月
+
+
+
+
+ {['日', '一', '二', '三', '四', '五', '六'].map(d => (
+ {d}
+ ))}
+ {days.map((d, i) => {
+ if (!d) return ;
+ const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
+ const isSelected = value === dateStr;
+ const today = nowInTz().startOf('day');
+ const current = toTz(dateStr).startOf('day');
+ const isToday = current.isSame(today);
+ const isFuture = current.isAfter(today);
+
+ return (
+ !isFuture && handleSelect(e, d)}
+ style={{
+ height: 28,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '13px',
+ borderRadius: '6px',
+ cursor: isFuture ? 'not-allowed' : 'pointer',
+ background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
+ color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
+ fontWeight: isSelected || isToday ? 600 : 400,
+ opacity: isFuture ? 0.3 : 1
+ }}
+ onMouseEnter={(e) => {
+ if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
+ }}
+ >
+ {d}
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
+
+export function DonateTabs() {
+ const [method, setMethod] = useState('wechat');
+
+ return (
+
+
+
+
+
+
+
+ {method === 'alipay' ? (
+ 
+ ) : (
+ 
+ )}
+
+
+ );
+}
+
+export function NumericInput({ value, onChange, step = 1, min = 0, placeholder }) {
+ const decimals = String(step).includes('.') ? String(step).split('.')[1].length : 0;
+ const fmt = (n) => Number(n).toFixed(decimals);
+ const inc = () => {
+ const v = parseFloat(value);
+ const base = isNaN(v) ? 0 : v;
+ const next = base + step;
+ onChange(fmt(next));
+ };
+ const dec = () => {
+ const v = parseFloat(value);
+ const base = isNaN(v) ? 0 : v;
+ const next = Math.max(min, base - step);
+ onChange(fmt(next));
+ };
+ return (
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ style={{ width: '100%', paddingRight: 56 }}
+ />
+
+
+ );
+}
+
+export function Stat({ label, value, delta }) {
+ const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/app/components/Icons.jsx b/app/components/Icons.jsx
new file mode 100644
index 0000000..0699f9a
--- /dev/null
+++ b/app/components/Icons.jsx
@@ -0,0 +1,190 @@
+'use client';
+
+export function PlusIcon(props) {
+ return (
+
+ );
+}
+
+export function UpdateIcon(props) {
+ return (
+
+ );
+}
+
+export function TrashIcon(props) {
+ return (
+
+ );
+}
+
+export function SettingsIcon(props) {
+ return (
+
+ );
+}
+
+export function CloudIcon(props) {
+ return (
+
+ );
+}
+
+export function RefreshIcon(props) {
+ return (
+
+ );
+}
+
+export function ChevronIcon(props) {
+ return (
+
+ );
+}
+
+export function SortIcon(props) {
+ return (
+
+ );
+}
+
+export function UserIcon(props) {
+ return (
+
+ );
+}
+
+export function LogoutIcon(props) {
+ return (
+
+ );
+}
+
+export function LoginIcon(props) {
+ return (
+
+ );
+}
+
+export function MailIcon(props) {
+ return (
+
+ );
+}
+
+export function GridIcon(props) {
+ return (
+
+ );
+}
+
+export function CloseIcon(props) {
+ return (
+
+ );
+}
+
+export function ExitIcon(props) {
+ return (
+
+ );
+}
+
+export function ListIcon(props) {
+ return (
+
+ );
+}
+
+export function DragIcon(props) {
+ return (
+
+ );
+}
+
+export function FolderPlusIcon(props) {
+ return (
+
+ );
+}
+
+export function StarIcon({ filled, ...props }) {
+ return (
+
+ );
+}
+
+export function CalendarIcon(props) {
+ return (
+
+ );
+}
+
+export function MinusIcon(props) {
+ return (
+
+ );
+}
diff --git a/app/page.jsx b/app/page.jsx
index 02b4f9c..c503600 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -4,15 +4,16 @@ import { useEffect, useRef, useState, useMemo, useLayoutEffect, useCallback } fr
import { motion, AnimatePresence, Reorder } from 'framer-motion';
import { createAvatar } from '@dicebear/core';
import { glass } from '@dicebear/collection';
-import Announcement from "./components/Announcement";
-import zhifubaoImg from "./assets/zhifubao.jpg";
-import weixinImg from "./assets/weixin.jpg";
-import githubImg from "./assets/github.svg";
-import { supabase } from './lib/supabase';
-import packageJson from '../package.json';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
+import Announcement from "./components/Announcement";
+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 githubImg from "./assets/github.svg";
+import { supabase } from './lib/supabase';
+import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
+import packageJson from '../package.json';
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -23,536 +24,6 @@ const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
-// 全局 JSONP/Script 加载辅助函数
-const loadScript = (url) => {
- return new Promise((resolve, reject) => {
- if (typeof document === 'undefined') return resolve();
- const script = document.createElement('script');
- script.src = url;
- script.async = true;
- script.onload = () => {
- if (document.body.contains(script)) document.body.removeChild(script);
- resolve();
- };
- script.onerror = () => {
- if (document.body.contains(script)) document.body.removeChild(script);
- reject(new Error('加载失败'));
- };
- document.body.appendChild(script);
- });
-};
-
-// 获取指定日期的基金净值
-const fetchFundNetValue = async (code, date) => {
- // 使用东方财富 F10 接口获取历史净值 HTML
- const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
- try {
- await loadScript(url);
- if (window.apidata && window.apidata.content) {
- const content = window.apidata.content;
- if (content.includes('暂无数据')) return null;
-
- // 解析 HTML 表格
- // 格式: |
');
- for (const row of rows) {
- if (row.includes(`| ${date} | `)) {
- // 找到对应日期的行,提取单元格
- const cells = row.match(/]*>(.*?)<\/td>/g);
- if (cells && cells.length >= 2) {
- // 第二列是单位净值 (cells[1])
- const valStr = cells[1].replace(/<[^>]+>/g, '');
- const val = parseFloat(valStr);
- return isNaN(val) ? null : val;
- }
- }
- }
- }
- return null;
- } catch (e) {
- console.error('获取净值失败', e);
- return null;
- }
-};
-
-const fetchSmartFundNetValue = async (code, startDate) => {
- const today = nowInTz().startOf('day');
-
- let current = toTz(startDate).startOf('day');
-
- for (let i = 0; i < 30; i++) {
- if (current.isAfter(today)) break;
-
- const dateStr = current.format('YYYY-MM-DD');
- const val = await fetchFundNetValue(code, dateStr);
- if (val !== null) {
- return { date: dateStr, value: val };
- }
-
- current = current.add(1, 'day');
- }
- return null;
-};
-
-function PlusIcon(props) {
- return (
-
- );
-}
-
-function UpdateIcon(props) {
- return (
-
- );
-}
-
-function TrashIcon(props) {
- return (
-
- );
-}
-
-function SettingsIcon(props) {
- return (
-
- );
-}
-
-function CloudIcon(props) {
- return (
-
- );
-}
-
-function RefreshIcon(props) {
- return (
-
- );
-}
-
-function ChevronIcon(props) {
- return (
-
- );
-}
-
-function SortIcon(props) {
- return (
-
- );
-}
-
-function UserIcon(props) {
- return (
-
- );
-}
-
-function LogoutIcon(props) {
- return (
-
- );
-}
-
-function LoginIcon(props) {
- return (
-
- );
-}
-
-function MailIcon(props) {
- return (
-
- );
-}
-
-function GridIcon(props) {
- return (
-
- );
-}
-
-function CloseIcon(props) {
- return (
-
- );
-}
-
-function ExitIcon(props) {
- return (
-
- );
-}
-
-function ListIcon(props) {
- return (
-
- );
-}
-
-function DragIcon(props) {
- return (
-
- );
-}
-
-function FolderPlusIcon(props) {
- return (
-
- );
-}
-
-function StarIcon({ filled, ...props }) {
- return (
-
- );
-}
-
-function CalendarIcon(props) {
- return (
-
- );
-}
-
-function DatePicker({ value, onChange }) {
- const [isOpen, setIsOpen] = useState(false);
- const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
-
- // 点击外部关闭
- useEffect(() => {
- const close = () => setIsOpen(false);
- if (isOpen) window.addEventListener('click', close);
- return () => window.removeEventListener('click', close);
- }, [isOpen]);
-
- const year = currentMonth.year();
- const month = currentMonth.month();
-
- const handlePrevMonth = (e) => {
- e.stopPropagation();
- setCurrentMonth(currentMonth.subtract(1, 'month').startOf('month'));
- };
-
- const handleNextMonth = (e) => {
- e.stopPropagation();
- setCurrentMonth(currentMonth.add(1, 'month').startOf('month'));
- };
-
- const handleSelect = (e, day) => {
- e.stopPropagation();
- const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
-
- const today = nowInTz().startOf('day');
- const selectedDate = toTz(dateStr).startOf('day');
-
- if (selectedDate.isAfter(today)) return;
-
- onChange(dateStr);
- setIsOpen(false);
- };
-
- // 生成日历数据
- const daysInMonth = currentMonth.daysInMonth();
- const firstDayOfWeek = currentMonth.startOf('month').day();
-
- const days = [];
- for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
- for (let i = 1; i <= daysInMonth; i++) days.push(i);
-
- return (
- e.stopPropagation()}>
- setIsOpen(!isOpen)}
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: '0 12px',
- height: '40px',
- background: 'rgba(0,0,0,0.2)',
- borderRadius: '8px',
- cursor: 'pointer',
- border: '1px solid transparent',
- transition: 'all 0.2s'
- }}
- >
- {value || '选择日期'}
-
-
-
-
- {isOpen && (
-
-
-
- {year}年 {month + 1}月
-
-
-
-
- {['日', '一', '二', '三', '四', '五', '六'].map(d => (
- {d}
- ))}
- {days.map((d, i) => {
- if (!d) return ;
- const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
- const isSelected = value === dateStr;
- const today = nowInTz().startOf('day');
- const current = toTz(dateStr).startOf('day');
- const isToday = current.isSame(today);
- const isFuture = current.isAfter(today);
-
- return (
- !isFuture && handleSelect(e, d)}
- style={{
- height: 28,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- fontSize: '13px',
- borderRadius: '6px',
- cursor: isFuture ? 'not-allowed' : 'pointer',
- background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
- color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
- fontWeight: isSelected || isToday ? 600 : 400,
- opacity: isFuture ? 0.3 : 1
- }}
- onMouseEnter={(e) => {
- if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
- }}
- onMouseLeave={(e) => {
- if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
- }}
- >
- {d}
-
- );
- })}
-
-
- )}
-
-
- );
-}
-
-function DonateTabs() {
- const [method, setMethod] = useState('wechat'); // alipay, wechat
-
- return (
-
-
-
-
-
-
-
- {method === 'alipay' ? (
- 
- ) : (
- 
- )}
-
-
- );
-}
-
-function MinusIcon(props) {
- return (
-
- );
-}
-
-function NumericInput({ value, onChange, step = 1, min = 0, placeholder }) {
- const decimals = String(step).includes('.') ? String(step).split('.')[1].length : 0;
- const fmt = (n) => Number(n).toFixed(decimals);
- const inc = () => {
- const v = parseFloat(value);
- const base = isNaN(v) ? 0 : v;
- const next = base + step;
- onChange(fmt(next));
- };
- const dec = () => {
- const v = parseFloat(value);
- const base = isNaN(v) ? 0 : v;
- const next = Math.max(min, base - step);
- onChange(fmt(next));
- };
- return (
-
- onChange(e.target.value)}
- placeholder={placeholder}
- style={{ width: '100%', paddingRight: 56 }}
- />
-
-
- );
-}
-
-function Stat({ label, value, delta }) {
- const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
- return (
-
- {label}
- {value}
-
- );
-}
-
function FeedbackModal({ onClose, user }) {
const [submitting, setSubmitting] = useState(false);
const [succeeded, setSucceeded] = useState(false);
@@ -574,12 +45,7 @@ function FeedbackModal({ onClose, user }) {
formData.append("subject", "基估宝 - 用户反馈");
try {
- const response = await fetch("https://api.web3forms.com/submit", {
- method: "POST",
- body: formData
- });
-
- const data = await response.json();
+ const data = await submitFeedback(formData);
if (data.success) {
setSucceeded(true);
} else {
@@ -2426,16 +1892,13 @@ export default function HomePage() {
useEffect(() => {
const checkUpdate = async () => {
try {
- const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
- if (!res.ok) return;
- const data = await res.json();
- if (data.tag_name) {
- const remoteVersion = data.tag_name.replace(/^v/, '');
- if (remoteVersion !== packageJson.version) {
- setHasUpdate(true);
- setLatestVersion(remoteVersion);
- setUpdateContent(data.body || '');
- }
+ const data = await fetchLatestRelease();
+ if (!data?.tagName) return;
+ const remoteVersion = data.tagName.replace(/^v/, '');
+ if (remoteVersion !== packageJson.version) {
+ setHasUpdate(true);
+ setLatestVersion(remoteVersion);
+ setUpdateContent(data.body || '');
}
} catch (e) {
console.error('Check update failed:', e);
@@ -2476,7 +1939,7 @@ export default function HomePage() {
}, [swipedFundCode]);
// 检查交易日状态
- const checkTradingDay = () => {
+ const checkTradingDay = async () => {
const now = nowInTz();
const isWeekend = now.day() === 0 || now.day() === 6;
@@ -2489,39 +1952,26 @@ export default function HomePage() {
// 工作日通过上证指数判断是否为节假日
// 接口返回示例: v_sh000001="1~上证指数~...~20260205150000~..."
// 第30位是时间字段
- const script = document.createElement('script');
- script.src = `https://qt.gtimg.cn/q=sh000001&_t=${Date.now()}`;
- script.onload = () => {
- const data = window.v_sh000001;
- if (data) {
- const parts = data.split('~');
- if (parts.length > 30) {
- const dateStr = parts[30].slice(0, 8); // 20260205
- const currentStr = todayStr.replace(/-/g, '');
-
- if (dateStr === currentStr) {
- setIsTradingDay(true); // 日期匹配,确认为交易日
- } else {
- // 日期不匹配 (显示的是旧数据)
- // 如果已经过了 09:30 还是旧数据,说明今天休市
- const minutes = now.hour() * 60 + now.minute();
- if (minutes >= 9 * 60 + 30) {
- setIsTradingDay(false);
- } else {
- // 9:30 之前,即使是旧数据,也默认是交易日(盘前)
- setIsTradingDay(true);
- }
- }
+ try {
+ const dateStr = await fetchShanghaiIndexDate();
+ if (!dateStr) {
+ setIsTradingDay(!isWeekend);
+ return;
+ }
+ const currentStr = todayStr.replace(/-/g, '');
+ if (dateStr === currentStr) {
+ setIsTradingDay(true);
+ } else {
+ const minutes = now.hour() * 60 + now.minute();
+ if (minutes >= 9 * 60 + 30) {
+ setIsTradingDay(false);
+ } else {
+ setIsTradingDay(true);
}
}
- document.body.removeChild(script);
- };
- script.onerror = () => {
- document.body.removeChild(script);
- // 接口失败,降级为仅判断周末
+ } catch (e) {
setIsTradingDay(!isWeekend);
- };
- document.body.appendChild(script);
+ }
};
useEffect(() => {
@@ -3288,351 +2738,15 @@ export default function HomePage() {
};
}, [funds, refreshMs]);
- // --- 辅助:JSONP 数据抓取逻辑 ---
- const loadScript = (url) => {
- return new Promise((resolve, reject) => {
- const script = document.createElement('script');
- script.src = url;
- script.async = true;
- script.onload = () => {
- document.body.removeChild(script);
- resolve();
- };
- script.onerror = () => {
- document.body.removeChild(script);
- reject(new Error('数据加载失败'));
- };
- document.body.appendChild(script);
- });
- };
-
- // 当估值接口无法获取数据时,使用腾讯接口获取基金基本信息和净值(回退方案)
- const fetchFundDataFallback = async (c) => {
- return new Promise(async (resolve, reject) => {
- // 先通过东方财富搜索接口获取基金名称
- const searchCallbackName = `SuggestData_fallback_${Date.now()}`;
- const searchUrl = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(c)}&callback=${searchCallbackName}&_=${Date.now()}`;
-
- let fundName = '';
-
- try {
- await new Promise((resSearch, rejSearch) => {
- window[searchCallbackName] = (data) => {
- if (data && data.Datas && data.Datas.length > 0) {
- const found = data.Datas.find(d => d.CODE === c);
- if (found) {
- fundName = found.NAME || found.SHORTNAME || '';
- }
- }
- delete window[searchCallbackName];
- resSearch();
- };
-
- const script = document.createElement('script');
- script.src = searchUrl;
- script.async = true;
- script.onload = () => {
- if (document.body.contains(script)) document.body.removeChild(script);
- };
- script.onerror = () => {
- if (document.body.contains(script)) document.body.removeChild(script);
- delete window[searchCallbackName];
- rejSearch(new Error('搜索接口失败'));
- };
- document.body.appendChild(script);
-
- // 超时处理
- setTimeout(() => {
- if (window[searchCallbackName]) {
- delete window[searchCallbackName];
- resSearch();
- }
- }, 3000);
- });
- } catch (e) {
- // 搜索失败,继续尝试腾讯接口
- }
-
- // 使用腾讯接口获取净值数据
- const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
- const tScript = document.createElement('script');
- tScript.src = tUrl;
- tScript.onload = () => {
- const v = window[`v_jj${c}`];
- if (v && v.length > 5) {
- const p = v.split('~');
- // p[1]: 基金名称, p[5]: 单位净值, p[7]: 涨跌幅, p[8]: 净值日期
- const name = fundName || p[1] || `未知基金(${c})`;
- const dwjz = p[5];
- const zzl = parseFloat(p[7]);
- const jzrq = p[8] ? p[8].slice(0, 10) : '';
-
- if (dwjz) {
- // 成功获取净值数据
- resolve({
- code: c,
- name: name,
- dwjz: dwjz,
- gsz: null, // 无估值数据
- gztime: null,
- jzrq: jzrq,
- gszzl: null, // 无估值涨跌幅
- zzl: !isNaN(zzl) ? zzl : null,
- noValuation: true, // 标记为无估值数据
- holdings: []
- });
- } else {
- reject(new Error('未能获取到基金数据'));
- }
- } else {
- reject(new Error('未能获取到基金数据'));
- }
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- };
- tScript.onerror = () => {
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- reject(new Error('基金数据加载失败'));
- };
- document.body.appendChild(tScript);
- });
- };
-
- const fetchFundData = async (c) => {
- return new Promise(async (resolve, reject) => {
- // 腾讯接口识别逻辑优化
- const getTencentPrefix = (code) => {
- if (code.startsWith('6') || code.startsWith('9')) return 'sh';
- if (code.startsWith('0') || code.startsWith('3')) return 'sz';
- if (code.startsWith('4') || code.startsWith('8')) return 'bj';
- return 'sz';
- };
-
- const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`;
-
- // 使用更安全的方式处理全局回调,避免并发覆盖
- const currentCallback = `jsonpgz_${c}_${Math.random().toString(36).slice(2, 7)}`;
-
- // 动态拦截并处理 jsonpgz 回调
- const scriptGz = document.createElement('script');
- // 东方财富接口固定调用 jsonpgz,我们通过修改全局变量临时捕获它
- scriptGz.src = gzUrl;
-
- const originalJsonpgz = window.jsonpgz;
- window.jsonpgz = (json) => {
- window.jsonpgz = originalJsonpgz; // 立即恢复
- if (!json || typeof json !== 'object') {
- // 估值数据无法获取时,尝试使用腾讯接口获取基金基本信息和净值
- fetchFundDataFallback(c).then(resolve).catch(reject);
- return;
- }
- const gszzlNum = Number(json.gszzl);
- const gzData = {
- code: json.fundcode,
- name: json.name,
- dwjz: json.dwjz,
- gsz: json.gsz,
- gztime: json.gztime,
- jzrq: json.jzrq,
- gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
- };
-
- // 并行获取:1. 腾讯接口获取最新确权净值和涨跌幅;2. 东方财富接口获取持仓
- const tencentPromise = new Promise((resolveT) => {
- const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
- const tScript = document.createElement('script');
- tScript.src = tUrl;
- tScript.onload = () => {
- const v = window[`v_jj${c}`];
- if (v) {
- const p = v.split('~');
- // p[5]: 单位净值, p[7]: 涨跌幅, p[8]: 净值日期
- resolveT({
- dwjz: p[5],
- zzl: parseFloat(p[7]),
- jzrq: p[8] ? p[8].slice(0, 10) : ''
- });
- } else {
- resolveT(null);
- }
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- };
- tScript.onerror = () => {
- if (document.body.contains(tScript)) document.body.removeChild(tScript);
- resolveT(null);
- };
- document.body.appendChild(tScript);
- });
-
- const holdingsPromise = new Promise((resolveH) => {
- const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
- loadScript(holdingsUrl).then(async () => {
- let holdings = [];
- const html = window.apidata?.content || '';
- const headerRow = (html.match(/[\s\S]*?<\/thead>/i) || [])[0] || '';
- const headerCells = (headerRow.match(/([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
- let idxCode = -1, idxName = -1, idxWeight = -1;
- headerCells.forEach((h, i) => {
- const t = h.replace(/\s+/g, '');
- if (idxCode < 0 && (t.includes('股票代码') || t.includes('证券代码'))) idxCode = i;
- if (idxName < 0 && (t.includes('股票名称') || t.includes('证券名称'))) idxName = i;
- if (idxWeight < 0 && (t.includes('占净值比例') || t.includes('占比'))) idxWeight = i;
- });
- const rows = html.match(/ | |