feat:组件拆分和性能优化
This commit is contained in:
406
app/api/fund.js
Normal file
406
app/api/fund.js
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
export const loadScript = (url) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof document === 'undefined' || !document.body) return resolve();
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (document.body.contains(script)) document.body.removeChild(script);
|
||||||
|
};
|
||||||
|
script.onload = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('数据加载失败'));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchFundNetValue = async (code, date) => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
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;
|
||||||
|
const rows = content.split('<tr>');
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.includes(`<td>${date}</td>`)) {
|
||||||
|
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
|
||||||
|
if (cells && cells.length >= 2) {
|
||||||
|
const valStr = cells[1].replace(/<[^>]+>/g, '');
|
||||||
|
const val = parseFloat(valStr);
|
||||||
|
return isNaN(val) ? null : val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchFundDataFallback = async (c) => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
throw new Error('无浏览器环境');
|
||||||
|
}
|
||||||
|
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('~');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchFundData = async (c) => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
throw new Error('无浏览器环境');
|
||||||
|
}
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`;
|
||||||
|
const scriptGz = document.createElement('script');
|
||||||
|
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
|
||||||
|
};
|
||||||
|
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('~');
|
||||||
|
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(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
||||||
|
const headerCells = (headerRow.match(/<th[\s\S]*?>([\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(/<tbody[\s\S]*?<\/tbody>/i) || [];
|
||||||
|
const dataRows = rows.length ? rows[0].match(/<tr[\s\S]*?<\/tr>/gi) || [] : html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||||
|
for (const r of dataRows) {
|
||||||
|
const tds = (r.match(/<td[\s\S]*?>([\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();
|
||||||
|
};
|
||||||
286
app/components/Common.jsx
Normal file
286
app/components/Common.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div
|
||||||
|
className="input-trigger"
|
||||||
|
onClick={() => 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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{value || '选择日期'}</span>
|
||||||
|
<CalendarIcon width="16" height="16" className="muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
className="glass card"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 12,
|
||||||
|
zIndex: 10,
|
||||||
|
background: 'rgba(30, 41, 59, 0.95)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<button type="button" onClick={handlePrevMonth} className="icon-button" style={{ width: 24, height: 24 }}><</button>
|
||||||
|
<span style={{ fontWeight: 600 }}>{year}年 {month + 1}月</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="icon-button"
|
||||||
|
style={{ width: 24, height: 24 }}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
|
||||||
|
{['日', '一', '二', '三', '四', '五', '六'].map(d => (
|
||||||
|
<div key={d} className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>{d}</div>
|
||||||
|
))}
|
||||||
|
{days.map((d, i) => {
|
||||||
|
if (!d) return <div key={i} />;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => !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}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DonateTabs() {
|
||||||
|
const [method, setMethod] = useState('wechat');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div className="tabs glass" style={{ padding: 4, borderRadius: 12, width: '100%', display: 'flex' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMethod('alipay')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 0',
|
||||||
|
border: 'none',
|
||||||
|
background: method === 'alipay' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
|
||||||
|
color: method === 'alipay' ? 'var(--primary)' : 'var(--muted)',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
支付宝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMethod('wechat')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 0',
|
||||||
|
border: 'none',
|
||||||
|
background: method === 'wechat' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
|
||||||
|
color: method === 'wechat' ? 'var(--primary)' : 'var(--muted)',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
微信支付
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{method === 'alipay' ? (
|
||||||
|
<img
|
||||||
|
src={zhifubaoImg.src}
|
||||||
|
alt="支付宝收款码"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={weixinImg.src}
|
||||||
|
alt="微信收款码"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="input no-zoom"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ width: '100%', paddingRight: 56 }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', right: 6, top: 6, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<button className="icon-button" type="button" onClick={inc} style={{ width: 44, height: 16, padding: 0 }}>
|
||||||
|
<PlusIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" onClick={dec} style={{ width: 44, height: 16, padding: 0 }}>
|
||||||
|
<MinusIcon width="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat({ label, value, delta }) {
|
||||||
|
const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
|
||||||
|
return (
|
||||||
|
<div className="stat" style={{ flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||||
|
<span className="label" style={{ fontSize: '11px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
|
<span className={`value ${dir}`} style={{ fontSize: '15px', lineHeight: 1.2, whiteSpace: 'nowrap' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
app/components/Icons.jsx
Normal file
190
app/components/Icons.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export function PlusIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3 6h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M8 6l1-2h6l1 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M6 6l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M19.4 15a7.97 7.97 0 0 0 .1-2l2-1.5-2-3.5-2.3.5a8.02 8.02 0 0 0-1.7-1l-.4-2.3h-4l-.4 2.3a8.02 8.02 0 0 0-1.7 1l-2.3-.5-2 3.5 2 1.5a7.97 7.97 0 0 0 .1 2l-2 1.5 2 3.5 2.3-.5a8.02 8.02 0 0 0 1.7 1l.4 2.3h4l.4-2.3a8.02 8.02 0 0 0 1.7-1l2.3.5 2-3.5-2-1.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 17.5a4.5 4.5 0 0 0-1.5-8.77A6 6 0 1 0 6 16.5H18a3.5 3.5 0 0 0 2-6.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 12a8 8 0 0 1 12.5-6.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M16 5h3v3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M20 12a8 8 0 0 1-12.5 6.9" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M8 19H5v-3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3 7h18M6 12h12M9 17h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoutIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<polyline points="16 17 21 12 16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<polyline points="10 17 15 12 10 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M22 6l-10 7L2 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExitIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 8h16M4 12h16M4 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderPlusIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 13h6m-3-3v6m-9-4V5a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarIcon({ filled, ...props }) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MinusIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
930
app/page.jsx
930
app/page.jsx
@@ -4,15 +4,16 @@ import { useEffect, useRef, useState, useMemo, useLayoutEffect, useCallback } fr
|
|||||||
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||||
import { createAvatar } from '@dicebear/core';
|
import { createAvatar } from '@dicebear/core';
|
||||||
import { glass } from '@dicebear/collection';
|
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 dayjs from 'dayjs';
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
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(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -23,536 +24,6 @@ const nowInTz = () => dayjs().tz(TZ);
|
|||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
|
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 表格
|
|
||||||
// 格式: <tr><td>日期</td><td class='tor bold'>单位净值</td>...
|
|
||||||
const rows = content.split('<tr>');
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.includes(`<td>${date}</td>`)) {
|
|
||||||
// 找到对应日期的行,提取单元格
|
|
||||||
const cells = row.match(/<td[^>]*>(.*?)<\/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 (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrashIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M3 6h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<path d="M8 6l1-2h6l1 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<path d="M6 6l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M19.4 15a7.97 7.97 0 0 0 .1-2l2-1.5-2-3.5-2.3.5a8.02 8.02 0 0 0-1.7-1l-.4-2.3h-4l-.4 2.3a8.02 8.02 0 0 0-1.7 1l-2.3-.5-2 3.5 2 1.5a7.97 7.97 0 0 0 .1 2l-2 1.5 2 3.5 2.3-.5a8.02 8.02 0 0 0 1.7 1l.4 2.3h4l.4-2.3a8.02 8.02 0 0 0 1.7-1l2.3.5 2-3.5-2-1.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloudIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M20 17.5a4.5 4.5 0 0 0-1.5-8.77A6 6 0 1 0 6 16.5H18a3.5 3.5 0 0 0 2-6.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RefreshIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M4 12a8 8 0 0 1 12.5-6.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<path d="M16 5h3v3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M20 12a8 8 0 0 1-12.5 6.9" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M8 19H5v-3" stroke="currentColor" strokeWidth="2" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChevronIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M3 7h18M6 12h12M9 17h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogoutIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<polyline points="16 17 21 12 16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<polyline points="10 17 15 12 10 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<line x1="15" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MailIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M22 6l-10 7L2 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GridIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloseIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExitIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DragIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M4 8h16M4 12h16M4 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderPlusIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 13h6m-3-3v6m-9-4V5a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StarIcon({ filled, ...props }) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div
|
|
||||||
className="input-trigger"
|
|
||||||
onClick={() => 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{value || '选择日期'}</span>
|
|
||||||
<CalendarIcon width="16" height="16" className="muted" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
|
||||||
className="glass card"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
marginTop: 8,
|
|
||||||
padding: 12,
|
|
||||||
zIndex: 10,
|
|
||||||
background: 'rgba(30, 41, 59, 0.95)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<button type="button" onClick={handlePrevMonth} className="icon-button" style={{ width: 24, height: 24 }}><</button>
|
|
||||||
<span style={{ fontWeight: 600 }}>{year}年 {month + 1}月</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNextMonth}
|
|
||||||
className="icon-button"
|
|
||||||
style={{ width: 24, height: 24 }}
|
|
||||||
// 如果下个月已经是未来,可以禁用(可选,这里简单起见不禁用翻页,只禁用日期点击)
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="calendar-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
|
|
||||||
{['日', '一', '二', '三', '四', '五', '六'].map(d => (
|
|
||||||
<div key={d} className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>{d}</div>
|
|
||||||
))}
|
|
||||||
{days.map((d, i) => {
|
|
||||||
if (!d) return <div key={i} />;
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick={(e) => !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}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DonateTabs() {
|
|
||||||
const [method, setMethod] = useState('wechat'); // alipay, wechat
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
|
||||||
<div className="tabs glass" style={{ padding: 4, borderRadius: 12, width: '100%', display: 'flex' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setMethod('alipay')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 0',
|
|
||||||
border: 'none',
|
|
||||||
background: method === 'alipay' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
|
|
||||||
color: method === 'alipay' ? 'var(--primary)' : 'var(--muted)',
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
transition: 'all 0.2s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
支付宝
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMethod('wechat')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '8px 0',
|
|
||||||
border: 'none',
|
|
||||||
background: method === 'wechat' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
|
|
||||||
color: method === 'wechat' ? 'var(--primary)' : 'var(--muted)',
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
transition: 'all 0.2s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
微信支付
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
background: 'white',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 8,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{method === 'alipay' ? (
|
|
||||||
<img
|
|
||||||
src={zhifubaoImg.src}
|
|
||||||
alt="支付宝收款码"
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={weixinImg.src}
|
|
||||||
alt="微信收款码"
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MinusIcon(props) {
|
|
||||||
return (
|
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
className="input no-zoom" // 增加 no-zoom 类
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
style={{ width: '100%', paddingRight: 56 }}
|
|
||||||
/>
|
|
||||||
<div style={{ position: 'absolute', right: 6, top: 6, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<button className="icon-button" type="button" onClick={inc} style={{ width: 44, height: 16, padding: 0 }}>
|
|
||||||
<PlusIcon width="14" height="14" />
|
|
||||||
</button>
|
|
||||||
<button className="icon-button" type="button" onClick={dec} style={{ width: 44, height: 16, padding: 0 }}>
|
|
||||||
<MinusIcon width="14" height="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, delta }) {
|
|
||||||
const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
|
|
||||||
return (
|
|
||||||
<div className="stat" style={{ flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
|
||||||
<span className="label" style={{ fontSize: '11px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
|
||||||
<span className={`value ${dir}`} style={{ fontSize: '15px', lineHeight: 1.2, whiteSpace: 'nowrap' }}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackModal({ onClose, user }) {
|
function FeedbackModal({ onClose, user }) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [succeeded, setSucceeded] = useState(false);
|
const [succeeded, setSucceeded] = useState(false);
|
||||||
@@ -574,12 +45,7 @@ function FeedbackModal({ onClose, user }) {
|
|||||||
formData.append("subject", "基估宝 - 用户反馈");
|
formData.append("subject", "基估宝 - 用户反馈");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.web3forms.com/submit", {
|
const data = await submitFeedback(formData);
|
||||||
method: "POST",
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setSucceeded(true);
|
setSucceeded(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -2426,17 +1892,14 @@ export default function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkUpdate = async () => {
|
const checkUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
|
const data = await fetchLatestRelease();
|
||||||
if (!res.ok) return;
|
if (!data?.tagName) return;
|
||||||
const data = await res.json();
|
const remoteVersion = data.tagName.replace(/^v/, '');
|
||||||
if (data.tag_name) {
|
|
||||||
const remoteVersion = data.tag_name.replace(/^v/, '');
|
|
||||||
if (remoteVersion !== packageJson.version) {
|
if (remoteVersion !== packageJson.version) {
|
||||||
setHasUpdate(true);
|
setHasUpdate(true);
|
||||||
setLatestVersion(remoteVersion);
|
setLatestVersion(remoteVersion);
|
||||||
setUpdateContent(data.body || '');
|
setUpdateContent(data.body || '');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Check update failed:', e);
|
console.error('Check update failed:', e);
|
||||||
}
|
}
|
||||||
@@ -2476,7 +1939,7 @@ export default function HomePage() {
|
|||||||
}, [swipedFundCode]);
|
}, [swipedFundCode]);
|
||||||
|
|
||||||
// 检查交易日状态
|
// 检查交易日状态
|
||||||
const checkTradingDay = () => {
|
const checkTradingDay = async () => {
|
||||||
const now = nowInTz();
|
const now = nowInTz();
|
||||||
const isWeekend = now.day() === 0 || now.day() === 6;
|
const isWeekend = now.day() === 0 || now.day() === 6;
|
||||||
|
|
||||||
@@ -2489,39 +1952,26 @@ export default function HomePage() {
|
|||||||
// 工作日通过上证指数判断是否为节假日
|
// 工作日通过上证指数判断是否为节假日
|
||||||
// 接口返回示例: v_sh000001="1~上证指数~...~20260205150000~..."
|
// 接口返回示例: v_sh000001="1~上证指数~...~20260205150000~..."
|
||||||
// 第30位是时间字段
|
// 第30位是时间字段
|
||||||
const script = document.createElement('script');
|
try {
|
||||||
script.src = `https://qt.gtimg.cn/q=sh000001&_t=${Date.now()}`;
|
const dateStr = await fetchShanghaiIndexDate();
|
||||||
script.onload = () => {
|
if (!dateStr) {
|
||||||
const data = window.v_sh000001;
|
setIsTradingDay(!isWeekend);
|
||||||
if (data) {
|
return;
|
||||||
const parts = data.split('~');
|
}
|
||||||
if (parts.length > 30) {
|
|
||||||
const dateStr = parts[30].slice(0, 8); // 20260205
|
|
||||||
const currentStr = todayStr.replace(/-/g, '');
|
const currentStr = todayStr.replace(/-/g, '');
|
||||||
|
|
||||||
if (dateStr === currentStr) {
|
if (dateStr === currentStr) {
|
||||||
setIsTradingDay(true); // 日期匹配,确认为交易日
|
setIsTradingDay(true);
|
||||||
} else {
|
} else {
|
||||||
// 日期不匹配 (显示的是旧数据)
|
|
||||||
// 如果已经过了 09:30 还是旧数据,说明今天休市
|
|
||||||
const minutes = now.hour() * 60 + now.minute();
|
const minutes = now.hour() * 60 + now.minute();
|
||||||
if (minutes >= 9 * 60 + 30) {
|
if (minutes >= 9 * 60 + 30) {
|
||||||
setIsTradingDay(false);
|
setIsTradingDay(false);
|
||||||
} else {
|
} else {
|
||||||
// 9:30 之前,即使是旧数据,也默认是交易日(盘前)
|
|
||||||
setIsTradingDay(true);
|
setIsTradingDay(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
}
|
|
||||||
document.body.removeChild(script);
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
document.body.removeChild(script);
|
|
||||||
// 接口失败,降级为仅判断周末
|
|
||||||
setIsTradingDay(!isWeekend);
|
setIsTradingDay(!isWeekend);
|
||||||
};
|
}
|
||||||
document.body.appendChild(script);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3288,351 +2738,15 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
}, [funds, refreshMs]);
|
}, [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(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
|
|
||||||
const headerCells = (headerRow.match(/<th[\s\S]*?>([\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(/<tbody[\s\S]*?<\/tbody>/i) || [];
|
|
||||||
const dataRows = rows.length ? rows[0].match(/<tr[\s\S]*?<\/tr>/gi) || [] : html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
|
||||||
for (const r of dataRows) {
|
|
||||||
const tds = (r.match(/<td[\s\S]*?>([\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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const performSearch = async (val) => {
|
const performSearch = async (val) => {
|
||||||
if (!val.trim()) {
|
if (!val.trim()) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
// 使用 JSONP 方式获取数据,添加 callback 参数
|
|
||||||
const callbackName = `SuggestData_${Date.now()}`;
|
|
||||||
const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
const fundsOnly = await searchFunds(val);
|
||||||
window[callbackName] = (data) => {
|
|
||||||
if (data && data.Datas) {
|
|
||||||
// 过滤出基金类型的数据 (CATEGORY 为 700 是公募基金)
|
|
||||||
const fundsOnly = data.Datas.filter(d =>
|
|
||||||
d.CATEGORY === 700 ||
|
|
||||||
d.CATEGORY === "700" ||
|
|
||||||
d.CATEGORYDESC === "基金"
|
|
||||||
);
|
|
||||||
setSearchResults(fundsOnly);
|
setSearchResults(fundsOnly);
|
||||||
}
|
|
||||||
delete window[callbackName];
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('搜索失败', e);
|
console.error('搜索失败', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
reactCompiler: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
47
package-lock.json
generated
47
package-lock.json
generated
@@ -17,10 +17,47 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.28.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dicebear/adventurer": {
|
"node_modules/@dicebear/adventurer": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.3.1.tgz",
|
||||||
@@ -1178,6 +1215,16 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/babel-plugin-react-compiler": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.26.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
|
|||||||
@@ -19,5 +19,8 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user