Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82bdecca0b | ||
|
|
cc605fb45b | ||
|
|
e8bd65e499 | ||
|
|
12229e8eeb | ||
|
|
fb0dc25341 | ||
|
|
b489677d3e | ||
|
|
104a847d2a | ||
|
|
0a97b80499 |
131
app/api/fund.js
131
app/api/fund.js
@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
|
|||||||
const nowInTz = () => dayjs().tz(TZ);
|
const nowInTz = () => dayjs().tz(TZ);
|
||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取基金「关联板块/跟踪标的」信息(走本地 API,并做 1 天缓存)
|
||||||
|
* 接口:/api/related-sectors?code=xxxxxx
|
||||||
|
* 返回:{ code: string, relatedSectors: string }
|
||||||
|
*/
|
||||||
|
export const fetchRelatedSectors = async (code, { cacheTime = ONE_DAY_MS } = {}) => {
|
||||||
|
if (!code) return '';
|
||||||
|
const normalized = String(code).trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
|
||||||
|
const url = `/api/related-sectors?code=${encodeURIComponent(normalized)}`;
|
||||||
|
const cacheKey = `relatedSectors:${normalized}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await cachedRequest(async () => {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
}, cacheKey, { cacheTime });
|
||||||
|
|
||||||
|
const relatedSectors = data?.relatedSectors;
|
||||||
|
return relatedSectors ? String(relatedSectors).trim() : '';
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const loadScript = (url) => {
|
export const loadScript = (url) => {
|
||||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||||
|
|
||||||
@@ -341,8 +370,12 @@ export const fetchFundData = async (c) => {
|
|||||||
let name = '';
|
let name = '';
|
||||||
let weight = '';
|
let weight = '';
|
||||||
if (idxCode >= 0 && tds[idxCode]) {
|
if (idxCode >= 0 && tds[idxCode]) {
|
||||||
const m = tds[idxCode].match(/(\d{6})/);
|
const raw = String(tds[idxCode] || '').trim();
|
||||||
code = m ? m[1] : tds[idxCode];
|
const mA = raw.match(/(\d{6})/);
|
||||||
|
const mHK = raw.match(/(\d{5})/);
|
||||||
|
// 海外股票常见为英文代码(如 AAPL / usAAPL / TSLA.US / 0700.HK)
|
||||||
|
const mAlpha = raw.match(/\b([A-Za-z]{1,10})\b/);
|
||||||
|
code = mA ? mA[1] : (mHK ? mHK[1] : (mAlpha ? mAlpha[1].toUpperCase() : raw));
|
||||||
} else {
|
} else {
|
||||||
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
||||||
if (codeIdx >= 0) code = tds[codeIdx];
|
if (codeIdx >= 0) code = tds[codeIdx];
|
||||||
@@ -365,20 +398,67 @@ export const fetchFundData = async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
holdings = holdings.slice(0, 10);
|
holdings = holdings.slice(0, 10);
|
||||||
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
|
const normalizeTencentCode = (input) => {
|
||||||
|
const raw = String(input || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
// already normalized tencent styles (normalize prefix casing)
|
||||||
|
const mPref = raw.match(/^(us|hk|sh|sz|bj)(.+)$/i);
|
||||||
|
if (mPref) {
|
||||||
|
const p = mPref[1].toLowerCase();
|
||||||
|
const rest = String(mPref[2] || '').trim();
|
||||||
|
// usAAPL / usIXIC: rest use upper; hk00700 keep digits
|
||||||
|
return `${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
const mSPref = raw.match(/^s_(sh|sz|bj|hk)(.+)$/i);
|
||||||
|
if (mSPref) {
|
||||||
|
const p = mSPref[1].toLowerCase();
|
||||||
|
const rest = String(mSPref[2] || '').trim();
|
||||||
|
return `s_${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A股/北证
|
||||||
|
if (/^\d{6}$/.test(raw)) {
|
||||||
|
const pfx =
|
||||||
|
raw.startsWith('6') || raw.startsWith('9')
|
||||||
|
? 'sh'
|
||||||
|
: raw.startsWith('4') || raw.startsWith('8')
|
||||||
|
? 'bj'
|
||||||
|
: 'sz';
|
||||||
|
return `s_${pfx}${raw}`;
|
||||||
|
}
|
||||||
|
// 港股(数字)
|
||||||
|
if (/^\d{5}$/.test(raw)) return `s_hk${raw}`;
|
||||||
|
|
||||||
|
// 形如 0700.HK / 00001.HK
|
||||||
|
const mHkDot = raw.match(/^(\d{4,5})\.(?:HK)$/i);
|
||||||
|
if (mHkDot) return `s_hk${mHkDot[1].padStart(5, '0')}`;
|
||||||
|
|
||||||
|
// 形如 AAPL / TSLA.US / AAPL.O / BRK.B(腾讯接口对“.”支持不稳定,优先取主代码)
|
||||||
|
const mUsDot = raw.match(/^([A-Za-z]{1,10})(?:\.[A-Za-z]{1,6})$/);
|
||||||
|
if (mUsDot) return `us${mUsDot[1].toUpperCase()}`;
|
||||||
|
if (/^[A-Za-z]{1,10}$/.test(raw)) return `us${raw.toUpperCase()}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTencentVarName = (tencentCode) => {
|
||||||
|
const cd = String(tencentCode || '').trim();
|
||||||
|
if (!cd) return '';
|
||||||
|
// s_* uses v_s_*
|
||||||
|
if (/^s_/i.test(cd)) return `v_${cd}`;
|
||||||
|
// us/hk/sh/sz/bj uses v_{code}
|
||||||
|
return `v_${cd}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const needQuotes = holdings
|
||||||
|
.map((h) => ({
|
||||||
|
h,
|
||||||
|
tencentCode: normalizeTencentCode(h.code),
|
||||||
|
}))
|
||||||
|
.filter((x) => Boolean(x.tencentCode));
|
||||||
if (needQuotes.length) {
|
if (needQuotes.length) {
|
||||||
try {
|
try {
|
||||||
const tencentCodes = needQuotes.map(h => {
|
const tencentCodes = needQuotes.map((x) => x.tencentCode).join(',');
|
||||||
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) {
|
if (!tencentCodes) {
|
||||||
resolveH(holdings);
|
resolveH(holdings);
|
||||||
return;
|
return;
|
||||||
@@ -388,22 +468,15 @@ export const fetchFundData = async (c) => {
|
|||||||
const scriptQuote = document.createElement('script');
|
const scriptQuote = document.createElement('script');
|
||||||
scriptQuote.src = quoteUrl;
|
scriptQuote.src = quoteUrl;
|
||||||
scriptQuote.onload = () => {
|
scriptQuote.onload = () => {
|
||||||
needQuotes.forEach(h => {
|
needQuotes.forEach(({ h, tencentCode }) => {
|
||||||
const cd = String(h.code || '');
|
const varName = getTencentVarName(tencentCode);
|
||||||
let varName = '';
|
const dataStr = varName ? window[varName] : null;
|
||||||
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) {
|
if (dataStr) {
|
||||||
const parts = dataStr.split('~');
|
const parts = dataStr.split('~');
|
||||||
if (parts.length > 5) {
|
const isUS = /^us/i.test(String(tencentCode || ''));
|
||||||
h.change = parseFloat(parts[5]);
|
const idx = isUS ? 32 : 5;
|
||||||
|
if (parts.length > idx) {
|
||||||
|
h.change = parseFloat(parts[idx]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -676,7 +749,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (typeof document === 'undefined' || !document.body) {
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
reject(new Error('无浏览器环境'));
|
reject(new Error('无浏览器环境'));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v18';
|
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v19';
|
||||||
|
|
||||||
export default function Announcement() {
|
export default function Announcement() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -75,14 +75,13 @@ export default function Announcement() {
|
|||||||
<span>公告</span>
|
<span>公告</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||||
<p>v0.2.7 更新内容:</p>
|
<p>v0.2.8 更新内容:</p>
|
||||||
<p>1. 业绩走势增加对比线。</p>
|
<p>1. 增加关联板块列。</p>
|
||||||
<p>2. 修复排序存储别名问题。</p>
|
<p>2. 设置持仓支持今日首次买入。</p>
|
||||||
<p>3. PC端斑马纹 hover 样式问题。</p>
|
<p>3. 加仓自动获取费率。</p>
|
||||||
<p>4. 修复大盘指数刷新及用户数据同步问题。</p>
|
|
||||||
<br/>
|
<br/>
|
||||||
<p>下一版本更新内容:</p>
|
<p>下一版本更新内容:</p>
|
||||||
<p>1. 关联板块。</p>
|
<p>1. 关联板块实时估值。</p>
|
||||||
<p>2. 收益曲线。</p>
|
<p>2. 收益曲线。</p>
|
||||||
<p>3. 估值差异列。</p>
|
<p>3. 估值差异列。</p>
|
||||||
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export default function GroupSummary({
|
|||||||
hasHolding = true;
|
hasHolding = true;
|
||||||
totalAsset += profit.amount;
|
totalAsset += profit.amount;
|
||||||
if (profit.profitToday != null) {
|
if (profit.profitToday != null) {
|
||||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
|
||||||
|
totalProfitToday += profit.profitToday;
|
||||||
hasAnyTodayData = true;
|
hasAnyTodayData = true;
|
||||||
}
|
}
|
||||||
if (profit.profitTotal !== null) {
|
if (profit.profitTotal !== null) {
|
||||||
@@ -129,11 +130,14 @@ export default function GroupSummary({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 将当日收益总和四舍五入到两位小数,和卡片展示保持一致
|
||||||
|
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
|
||||||
|
|
||||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAsset,
|
totalAsset,
|
||||||
totalProfitToday,
|
totalProfitToday: roundedTotalProfitToday,
|
||||||
totalHoldingReturn,
|
totalHoldingReturn,
|
||||||
hasHolding,
|
hasHolding,
|
||||||
returnRate,
|
returnRate,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory, pendingCount }) {
|
||||||
const handleOpenChange = (open) => {
|
const handleOpenChange = (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@@ -39,11 +39,26 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
title="查看交易记录"
|
title="查看交易记录"
|
||||||
>
|
>
|
||||||
<span>📜</span>
|
<span>📜</span>
|
||||||
<span>交易记录</span>
|
<span>交易记录</span>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#ef4444',
|
||||||
|
border: '2px solid var(--background)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) {
|
||||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||||
|
|
||||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||||
@@ -124,6 +124,23 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<SettingsIcon width="20" height="20" />
|
<SettingsIcon width="20" height="20" />
|
||||||
<span>设置持仓</span>
|
<span>设置持仓</span>
|
||||||
|
{typeof onOpenTrade === 'function' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenTrade}
|
||||||
|
className="button secondary"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
今日买入?去加仓。
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
<CloseIcon width="20" height="20" />
|
<CloseIcon width="20" height="20" />
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ import FitText from './FitText';
|
|||||||
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
||||||
import MobileSettingModal from './MobileSettingModal';
|
import MobileSettingModal from './MobileSettingModal';
|
||||||
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||||
|
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||||
|
|
||||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'relatedSector',
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
'totalChangePercent',
|
'totalChangePercent',
|
||||||
@@ -39,6 +41,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
|||||||
'estimateNav',
|
'estimateNav',
|
||||||
];
|
];
|
||||||
const MOBILE_COLUMN_HEADERS = {
|
const MOBILE_COLUMN_HEADERS = {
|
||||||
|
relatedSector: '关联板块',
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
@@ -233,6 +236,8 @@ export default function MobileFundTable({
|
|||||||
const defaultVisibility = (() => {
|
const defaultVisibility = (() => {
|
||||||
const o = {};
|
const o = {};
|
||||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||||
|
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||||
|
o.relatedSector = false;
|
||||||
return o;
|
return o;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -245,7 +250,11 @@ export default function MobileFundTable({
|
|||||||
})();
|
})();
|
||||||
const mobileColumnVisibility = (() => {
|
const mobileColumnVisibility = (() => {
|
||||||
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
||||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||||
|
const next = { ...vis };
|
||||||
|
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
return defaultVisibility;
|
return defaultVisibility;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -422,6 +431,7 @@ export default function MobileFundTable({
|
|||||||
const LAST_COLUMN_EXTRA = 12;
|
const LAST_COLUMN_EXTRA = 12;
|
||||||
const FALLBACK_WIDTHS = {
|
const FALLBACK_WIDTHS = {
|
||||||
fundName: 140,
|
fundName: 140,
|
||||||
|
relatedSector: 120,
|
||||||
latestNav: 64,
|
latestNav: 64,
|
||||||
estimateNav: 64,
|
estimateNav: 64,
|
||||||
yesterdayChangePercent: 72,
|
yesterdayChangePercent: 72,
|
||||||
@@ -431,6 +441,49 @@ export default function MobileFundTable({
|
|||||||
holdingProfit: 80,
|
holdingProfit: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const relatedSectorEnabled = mobileColumnVisibility?.relatedSector !== false;
|
||||||
|
const relatedSectorCacheRef = useRef(new Map());
|
||||||
|
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
|
||||||
|
|
||||||
|
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
|
||||||
|
|
||||||
|
const runWithConcurrency = async (items, limit, worker) => {
|
||||||
|
const queue = [...items];
|
||||||
|
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift();
|
||||||
|
if (item == null) continue;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await worker(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relatedSectorEnabled) return;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return;
|
||||||
|
|
||||||
|
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||||
|
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
await runWithConcurrency(missing, 4, async (code) => {
|
||||||
|
const value = await fetchRelatedSector(code);
|
||||||
|
relatedSectorCacheRef.current.set(code, value);
|
||||||
|
if (cancelled) return;
|
||||||
|
setRelatedSectorByCode((prev) => {
|
||||||
|
if (prev[code] === value) return prev;
|
||||||
|
return { ...prev, [code]: value };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [relatedSectorEnabled, data]);
|
||||||
|
|
||||||
const columnWidthMap = useMemo(() => {
|
const columnWidthMap = useMemo(() => {
|
||||||
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||||
const nonNameCount = visibleNonNameIds.length;
|
const nonNameCount = visibleNonNameIds.length;
|
||||||
@@ -456,6 +509,7 @@ export default function MobileFundTable({
|
|||||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
allVisible[id] = true;
|
allVisible[id] = true;
|
||||||
});
|
});
|
||||||
|
allVisible.relatedSector = false;
|
||||||
setMobileColumnVisibility(allVisible);
|
setMobileColumnVisibility(allVisible);
|
||||||
};
|
};
|
||||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||||
@@ -654,6 +708,22 @@ export default function MobileFundTable({
|
|||||||
),
|
),
|
||||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'relatedSector',
|
||||||
|
header: '关联板块',
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const code = original.code;
|
||||||
|
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||||
|
const display = value || '—';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '12px' }}>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: { align: 'left', cellClassName: 'related-sector-cell', width: columnWidthMap.relatedSector ?? 120 },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'latestNav',
|
accessorKey: 'latestNav',
|
||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
@@ -834,7 +904,7 @@ export default function MobileFundTable({
|
|||||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy]
|
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy, relatedSectorByCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ export default function MobileSettingModal({
|
|||||||
估值涨幅与持有收益的汇总
|
估值涨幅与持有收益的汇总
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.id === 'relatedSector' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
仅 fund.cc.cd 地址支持
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleColumnVisibility && (
|
{onToggleColumnVisibility && (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||||
|
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||||
|
|
||||||
const NON_FROZEN_COLUMN_IDS = [
|
const NON_FROZEN_COLUMN_IDS = [
|
||||||
|
'relatedSector',
|
||||||
'yesterdayChangePercent',
|
'yesterdayChangePercent',
|
||||||
'estimateChangePercent',
|
'estimateChangePercent',
|
||||||
'totalChangePercent',
|
'totalChangePercent',
|
||||||
@@ -47,6 +49,7 @@ const NON_FROZEN_COLUMN_IDS = [
|
|||||||
'estimateNav',
|
'estimateNav',
|
||||||
];
|
];
|
||||||
const COLUMN_HEADERS = {
|
const COLUMN_HEADERS = {
|
||||||
|
relatedSector: '关联板块',
|
||||||
latestNav: '最新净值',
|
latestNav: '最新净值',
|
||||||
estimateNav: '估算净值',
|
estimateNav: '估算净值',
|
||||||
yesterdayChangePercent: '昨日涨幅',
|
yesterdayChangePercent: '昨日涨幅',
|
||||||
@@ -282,9 +285,15 @@ export default function PcFundTable({
|
|||||||
})();
|
})();
|
||||||
const columnVisibility = (() => {
|
const columnVisibility = (() => {
|
||||||
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
||||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||||
|
const next = { ...vis };
|
||||||
|
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
const allVisible = {};
|
const allVisible = {};
|
||||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||||
|
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||||
|
allVisible.relatedSector = false;
|
||||||
return allVisible;
|
return allVisible;
|
||||||
})();
|
})();
|
||||||
const columnSizing = (() => {
|
const columnSizing = (() => {
|
||||||
@@ -356,6 +365,7 @@ export default function PcFundTable({
|
|||||||
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||||
allVisible[id] = true;
|
allVisible[id] = true;
|
||||||
});
|
});
|
||||||
|
allVisible.relatedSector = false;
|
||||||
setColumnVisibility(allVisible);
|
setColumnVisibility(allVisible);
|
||||||
};
|
};
|
||||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||||
@@ -443,6 +453,51 @@ export default function PcFundTable({
|
|||||||
};
|
};
|
||||||
}, [stickyTop]);
|
}, [stickyTop]);
|
||||||
|
|
||||||
|
const relatedSectorEnabled = columnVisibility?.relatedSector !== false;
|
||||||
|
const relatedSectorCacheRef = useRef(new Map());
|
||||||
|
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
|
||||||
|
|
||||||
|
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
|
||||||
|
|
||||||
|
const runWithConcurrency = async (items, limit, worker) => {
|
||||||
|
const queue = [...items];
|
||||||
|
const results = [];
|
||||||
|
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift();
|
||||||
|
if (item == null) continue;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
results.push(await worker(item));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relatedSectorEnabled) return;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return;
|
||||||
|
|
||||||
|
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||||
|
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
await runWithConcurrency(missing, 4, async (code) => {
|
||||||
|
const value = await fetchRelatedSector(code);
|
||||||
|
relatedSectorCacheRef.current.set(code, value);
|
||||||
|
if (cancelled) return;
|
||||||
|
setRelatedSectorByCode((prev) => {
|
||||||
|
if (prev[code] === value) return prev;
|
||||||
|
return { ...prev, [code]: value };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [relatedSectorEnabled, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tableEl = tableContainerRef.current;
|
const tableEl = tableContainerRef.current;
|
||||||
const portalEl = portalHeaderRef.current;
|
const portalEl = portalHeaderRef.current;
|
||||||
@@ -563,6 +618,27 @@ export default function PcFundTable({
|
|||||||
cellClassName: 'name-cell',
|
cellClassName: 'name-cell',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'relatedSector',
|
||||||
|
header: '关联板块',
|
||||||
|
size: 180,
|
||||||
|
minSize: 120,
|
||||||
|
cell: (info) => {
|
||||||
|
const original = info.row.original || {};
|
||||||
|
const code = original.code;
|
||||||
|
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||||
|
const display = value || '—';
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '14px' }}>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
align: 'right',
|
||||||
|
cellClassName: 'related-sector-cell',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'latestNav',
|
accessorKey: 'latestNav',
|
||||||
header: '最新净值',
|
header: '最新净值',
|
||||||
@@ -895,7 +971,7 @@ export default function PcFundTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked],
|
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked, relatedSectorByCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -213,6 +213,11 @@ export default function PcTableSettingModal({
|
|||||||
估值涨幅与持有收益的汇总
|
估值涨幅与持有收益的汇总
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.id === 'relatedSector' && (
|
||||||
|
<span className="muted" style={{ fontSize: '12px' }}>
|
||||||
|
仅 fund.cc.cd 地址支持
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleColumnVisibility && (
|
{onToggleColumnVisibility && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import { fetchSmartFundNetValue } from '../api/fund';
|
import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
|
||||||
import { DatePicker, NumericInput } from './Common';
|
import { DatePicker, NumericInput } from './Common';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
import { CloseIcon } from './Icons';
|
import { CloseIcon } from './Icons';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import PendingTradesModal from './PendingTradesModal';
|
import PendingTradesModal from './PendingTradesModal';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -39,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
const [share, setShare] = useState('');
|
const [share, setShare] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [feeRate, setFeeRate] = useState('0');
|
const [feeRate, setFeeRate] = useState('0');
|
||||||
|
const [minBuyAmount, setMinBuyAmount] = useState(0);
|
||||||
|
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
|
||||||
|
const [buyMetaError, setBuyMetaError] = useState(null);
|
||||||
const [date, setDate] = useState(() => {
|
const [date, setDate] = useState(() => {
|
||||||
return formatDate();
|
return formatDate();
|
||||||
});
|
});
|
||||||
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
||||||
const [calcShare, setCalcShare] = useState(null);
|
const [calcShare, setCalcShare] = useState(null);
|
||||||
|
|
||||||
|
const parseNumberish = (input) => {
|
||||||
|
if (input === null || typeof input === 'undefined') return null;
|
||||||
|
if (typeof input === 'number') return Number.isFinite(input) ? input : null;
|
||||||
|
const cleaned = String(input).replace(/[^\d.]/g, '');
|
||||||
|
const n = parseFloat(cleaned);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBuy || !fund?.code) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
setLoadingBuyMeta(true);
|
||||||
|
setBuyMetaError(null);
|
||||||
|
|
||||||
|
fetchFundPingzhongdata(fund.code)
|
||||||
|
.then((pz) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const rate = parseNumberish(pz?.fund_Rate);
|
||||||
|
const minsg = parseNumberish(pz?.fund_minsg);
|
||||||
|
|
||||||
|
if (Number.isFinite(minsg)) {
|
||||||
|
setMinBuyAmount(minsg);
|
||||||
|
} else {
|
||||||
|
setMinBuyAmount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(rate)) {
|
||||||
|
setFeeRate((prev) => {
|
||||||
|
const prevNum = parseNumberish(prev);
|
||||||
|
const shouldOverride = prev === '' || prev === '0' || prevNum === 0 || prevNum === null;
|
||||||
|
return shouldOverride ? rate.toFixed(2) : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBuyMetaError(e?.message || '买入信息加载失败');
|
||||||
|
setMinBuyAmount(0);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadingBuyMeta(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isBuy, fund?.code]);
|
||||||
|
|
||||||
const currentPendingTrades = useMemo(() => {
|
const currentPendingTrades = useMemo(() => {
|
||||||
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
||||||
}, [pendingTrades, fund]);
|
}, [pendingTrades, fund]);
|
||||||
@@ -148,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isValid = isBuy
|
const isValid = isBuy
|
||||||
? (!!amount && !!feeRate && !!date && calcShare !== null)
|
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
|
||||||
: (!!share && !!date);
|
: (!!share && !!date);
|
||||||
|
|
||||||
const handleSetShareFraction = (fraction) => {
|
const handleSetShareFraction = (fraction) => {
|
||||||
@@ -372,72 +426,112 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{isBuy ? (
|
{isBuy ? (
|
||||||
<>
|
<>
|
||||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
|
||||||
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||||
</label>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
<NumericInput
|
</label>
|
||||||
value={amount}
|
<div
|
||||||
onChange={setAmount}
|
style={{
|
||||||
step={100}
|
border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
|
||||||
min={0}
|
? '1px solid var(--danger)'
|
||||||
placeholder="请输入加仓金额"
|
: '1px solid var(--border)',
|
||||||
/>
|
borderRadius: 12
|
||||||
</div>
|
}}
|
||||||
</div>
|
>
|
||||||
|
<NumericInput
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
step={100}
|
||||||
|
min={Number(minBuyAmount) || 0}
|
||||||
|
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(Number(minBuyAmount) || 0) > 0 && (
|
||||||
|
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
||||||
|
最小加仓金额:¥{Number(minBuyAmount)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||||
<NumericInput
|
<NumericInput
|
||||||
value={feeRate}
|
value={feeRate}
|
||||||
onChange={setFeeRate}
|
onChange={setFeeRate}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="0.12"
|
placeholder="0.12"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<DatePicker value={date} onChange={setDate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||||
|
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||||
|
交易时段
|
||||||
|
</label>
|
||||||
|
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
|
onClick={() => setIsAfter3pm(false)}
|
||||||
|
>
|
||||||
|
15:00前
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||||
|
onClick={() => setIsAfter3pm(true)}
|
||||||
|
>
|
||||||
|
15:00后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12, fontSize: '12px' }}>
|
||||||
|
{buyMetaError ? (
|
||||||
|
<span className="muted" style={{ color: 'var(--danger)' }}>{buyMetaError}</span>
|
||||||
|
) : null}
|
||||||
|
{loadingPrice ? (
|
||||||
|
<span className="muted">正在查询净值数据...</span>
|
||||||
|
) : price === 0 ? null : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
|
||||||
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
|
|
||||||
</label>
|
|
||||||
<DatePicker value={date} onChange={setDate} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
{loadingBuyMeta && (
|
||||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
<div
|
||||||
交易时段
|
style={{
|
||||||
</label>
|
position: 'absolute',
|
||||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
inset: 0,
|
||||||
<button
|
display: 'flex',
|
||||||
type="button"
|
alignItems: 'center',
|
||||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
justifyContent: 'center',
|
||||||
onClick={() => setIsAfter3pm(false)}
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(0,0,0,0.25)',
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
WebkitBackdropFilter: 'blur(2px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
15:00前
|
<Spinner className="size-5" />
|
||||||
</button>
|
<span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
|
||||||
onClick={() => setIsAfter3pm(true)}
|
|
||||||
>
|
|
||||||
15:00后
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 12, fontSize: '12px' }}>
|
|
||||||
{loadingPrice ? (
|
|
||||||
<span className="muted">正在查询净值数据...</span>
|
|
||||||
) : price === 0 ? null : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -564,8 +658,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button"
|
className="button"
|
||||||
disabled={!isValid || loadingPrice}
|
disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
|
||||||
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
|
style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
确定
|
确定
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { UpdateIcon } from './Icons';
|
import { UpdateIcon } from './Icons';
|
||||||
|
|
||||||
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
|
export default function UpdatePromptModal({ updateContent, open, onClose, onRefresh }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Dialog open={open} onOpenChange={(v) => !v && onClose?.()}>
|
||||||
className="modal-overlay"
|
<DialogContent
|
||||||
role="dialog"
|
className="glass card"
|
||||||
aria-modal="true"
|
|
||||||
aria-label="更新提示"
|
|
||||||
onClick={onClose}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
style={{ zIndex: 10002 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="glass card modal"
|
|
||||||
style={{ maxWidth: '400px' }}
|
style={{ maxWidth: '400px' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
showCloseButton={false}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="更新提示"
|
||||||
>
|
>
|
||||||
<div className="title" style={{ marginBottom: 12 }}>
|
<DialogHeader>
|
||||||
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
<DialogTitle style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||||
<span>更新提示</span>
|
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
||||||
</div>
|
<span>更新提示</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
||||||
检测到新版本,是否刷新浏览器以更新?
|
检测到新版本,是否刷新浏览器以更新?
|
||||||
@@ -36,7 +29,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
</p>
|
</p>
|
||||||
{updateContent && (
|
{updateContent && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(0,0,0,0.2)',
|
background: 'var(--card)',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
@@ -44,13 +37,14 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
maxHeight: '200px',
|
maxHeight: '200px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
border: '1px solid rgba(255,255,255,0.1)'
|
border: '1px solid var(--border)'
|
||||||
}}>
|
}}>
|
||||||
{updateContent}
|
{updateContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
|
||||||
|
<div className="flex-row" style={{ gap: 12, display: 'flex' }}>
|
||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -66,7 +60,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
|||||||
刷新浏览器
|
刷新浏览器
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</DialogContent>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
81
app/page.jsx
81
app/page.jsx
@@ -883,7 +883,21 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const handleClearConfirm = () => {
|
const handleClearConfirm = () => {
|
||||||
if (clearConfirm?.fund) {
|
if (clearConfirm?.fund) {
|
||||||
handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null });
|
const code = clearConfirm.fund.code;
|
||||||
|
handleSaveHolding(code, { share: null, cost: null });
|
||||||
|
|
||||||
|
setTransactions(prev => {
|
||||||
|
const next = { ...(prev || {}) };
|
||||||
|
delete next[code];
|
||||||
|
storageHelper.setItem('transactions', JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingTrades(prev => {
|
||||||
|
const next = prev.filter(trade => trade.fundCode !== code);
|
||||||
|
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setClearConfirm(null);
|
setClearConfirm(null);
|
||||||
};
|
};
|
||||||
@@ -1040,6 +1054,11 @@ export default function HomePage() {
|
|||||||
setPendingTrades(next);
|
setPendingTrades(next);
|
||||||
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||||
|
|
||||||
|
// 如果该基金没有持仓数据,初始化持仓金额为 0
|
||||||
|
if (!holdings[fund.code]) {
|
||||||
|
handleSaveHolding(fund.code, { share: 0, cost: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
setTradeModal({ open: false, fund: null, type: 'buy' });
|
setTradeModal({ open: false, fund: null, type: 'buy' });
|
||||||
showToast('净值暂未更新,已加入待处理队列', 'info');
|
showToast('净值暂未更新,已加入待处理队列', 'info');
|
||||||
return;
|
return;
|
||||||
@@ -3165,9 +3184,10 @@ export default function HomePage() {
|
|||||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
|
// 一次查询同时拿到 meta 与 data,方便两种模式复用
|
||||||
const { data: meta, error: metaError } = await supabase
|
const { data: meta, error: metaError } = await supabase
|
||||||
.from('user_configs')
|
.from('user_configs')
|
||||||
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
|
.select('id, data, updated_at')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -3181,44 +3201,19 @@ export default function HomePage() {
|
|||||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 冲突检查模式:使用 meta.data 弹出冲突确认弹窗
|
||||||
if (checkConflict) {
|
if (checkConflict) {
|
||||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
|
// 非冲突检查模式:直接复用上方查询到的 meta 数据,覆盖本地
|
||||||
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
|
if (meta.data && isPlainObject(meta.data) && Object.keys(meta.data).length > 0) {
|
||||||
|
await applyCloudConfig(meta.data, meta.updated_at);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('user_configs')
|
|
||||||
.select('id, data, updated_at')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
|
||||||
const localPayload = collectLocalPayload();
|
|
||||||
const localComparable = getComparablePayload(localPayload);
|
|
||||||
const cloudComparable = getComparablePayload(data.data);
|
|
||||||
|
|
||||||
if (localComparable !== cloudComparable) {
|
|
||||||
// 如果数据不一致
|
|
||||||
if (checkConflict) {
|
|
||||||
// 只有明确要求检查冲突时才提示(例如刚登录时)
|
|
||||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 否则直接覆盖本地(例如已登录状态下的刷新)
|
|
||||||
await applyCloudConfig(data.data, data.updated_at);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyCloudConfig(data.data, data.updated_at);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取云端配置失败', e);
|
console.error('获取云端配置失败', e);
|
||||||
@@ -4513,6 +4508,7 @@ export default function HomePage() {
|
|||||||
onClose={() => setActionModal({ open: false, fund: null })}
|
onClose={() => setActionModal({ open: false, fund: null })}
|
||||||
onAction={(type) => handleAction(type, actionModal.fund)}
|
onAction={(type) => handleAction(type, actionModal.fund)}
|
||||||
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
||||||
|
pendingCount={pendingTrades.filter(t => t.fundCode === actionModal.fund?.code).length}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -4621,6 +4617,12 @@ export default function HomePage() {
|
|||||||
holding={holdings[holdingModal.fund?.code]}
|
holding={holdings[holdingModal.fund?.code]}
|
||||||
onClose={() => setHoldingModal({ open: false, fund: null })}
|
onClose={() => setHoldingModal({ open: false, fund: null })}
|
||||||
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
|
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
|
||||||
|
onOpenTrade={() => {
|
||||||
|
const f = holdingModal.fund;
|
||||||
|
if (!f) return;
|
||||||
|
setHoldingModal({ open: false, fund: null });
|
||||||
|
setTradeModal({ open: true, fund: f, type: 'buy' });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -4727,15 +4729,12 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新提示弹窗 */}
|
{/* 更新提示弹窗 */}
|
||||||
<AnimatePresence>
|
<UpdatePromptModal
|
||||||
{updateModalOpen && (
|
open={updateModalOpen}
|
||||||
<UpdatePromptModal
|
updateContent={updateContent}
|
||||||
updateContent={updateContent}
|
onClose={() => setUpdateModalOpen(false)}
|
||||||
onClose={() => setUpdateModalOpen(false)}
|
onRefresh={() => window.location.reload()}
|
||||||
onRefresh={() => window.location.reload()}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isScanning && (
|
{isScanning && (
|
||||||
|
|||||||
21
components/ui/spinner.jsx
Normal file
21
components/ui/spinner.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn(
|
||||||
|
"size-4 animate-spin text-muted-foreground motion-reduce:animate-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.7",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.7",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^9.3.1",
|
"@dicebear/collection": "^9.3.1",
|
||||||
"@dicebear/core": "^9.3.1",
|
"@dicebear/core": "^9.3.1",
|
||||||
@@ -5923,7 +5923,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.2.7",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user