66 Commits

Author SHA1 Message Date
hzm
d9bc246088 feat: 0.2.1 版本公告 2026-03-06 00:14:49 +08:00
hzm
fe3c2b64f6 feat: 拍照识别增加持有收益 2026-03-06 00:09:06 +08:00
hzm
c08c97d706 feat: 添加基金支持导入到分组 2026-03-05 21:18:29 +08:00
hzm
873728a6a2 feat: 提升截图基金名称识别准确率 2026-03-05 20:42:02 +08:00
hzm
9cfac48b59 feat: 本地缓存排序 2026-03-05 18:57:40 +08:00
hzm
0a828f33bf feat: 折线图禁止触发横向滚动 2026-03-05 18:33:33 +08:00
hzm
6d44803a27 Revert "feat: 修改 Docker 部署时 env 文件地址"
This reverts commit b13ada16df.
2026-03-05 08:23:19 +08:00
hzm
b13ada16df feat: 修改 Docker 部署时 env 文件地址 2026-03-04 20:16:55 +08:00
hzm
a206076a56 feat: 调整pwa图标边距 2026-03-04 14:36:05 +08:00
hzm
df7abaecdc feat: 添加苹果pwa图标 2026-03-04 14:27:45 +08:00
hzm
171ebac326 feat: PC 容器 max-width 调整为 100% 2026-03-04 11:20:05 +08:00
hzm
31553bb1a4 feat: 公告支持在小屏上展示 2026-03-04 11:05:36 +08:00
hzm
c65f2b8ab1 feat: crypto.randomUUID 替换为 uuid 2026-03-04 10:21:33 +08:00
hzm
c9038757dd feat: 定投标记展示 2026-03-04 08:30:30 +08:00
hzm
be4fc5eabe feat: viewMode 不再往云端同步 2026-03-03 21:02:27 +08:00
hzm
b8d239de40 feat: 初始化刷新后覆盖本地funds数据到云端 2026-03-03 20:57:19 +08:00
hzm
e2d8858432 feat: 前10重仓股票占比展示问题 2026-03-03 20:19:52 +08:00
hzm
9f47ee3f08 feat: fetchFundPingzhongdata 接口默认缓存时间一个小时 2026-03-03 19:59:53 +08:00
hzm
eb7483a5dd feat: 调整前10重仓股票和业绩走势数据获取方式 2026-03-03 19:52:43 +08:00
hzm
0bdbb6d168 feat: 昨日涨跌幅名称 2026-03-02 21:06:51 +08:00
hzm
d6d64f1897 feat: 选择的分组记录在本地 2026-03-02 20:45:44 +08:00
hzm
0504b9ae06 feat: 确定删除支持回车事件 2026-03-02 19:40:08 +08:00
hzm
162f1c3b99 feat: 优化实时分时折线图在亮色主题下的展示 2026-03-02 09:50:37 +08:00
hzm
b0b4cfded1 fix:修正 README 对 supabase 邮件部分描述 2026-03-02 08:51:25 +08:00
hzm
39f8152e70 fix:定投触发需严格判断是否为交易日 2026-03-02 08:44:23 +08:00
hzm
3958580571 feat: 忽略idea 2026-03-02 08:32:15 +08:00
hzm
6a53479bd7 fix: 定投队列触发判断周末和交易日 2026-03-02 08:28:46 +08:00
hzm
20e101bb65 fix: 前10重仓股票判断获取到的数据是否为上一季度 2026-03-02 08:18:54 +08:00
hzm
cbb9d2a105 fix: 修正公告文字错误 2026-03-01 22:26:56 +08:00
hzm
848226cfbb fix: 修正公告文字错误 2026-03-01 22:26:13 +08:00
hzm
5d46515e63 feat:发布 0.2.0 2026-03-01 22:23:53 +08:00
hzm
92d22b0bef feat:分组下个性化数据独立 2026-03-01 21:03:42 +08:00
hzm
8bcffffaa7 feat:当日收益展示百分比 2026-03-01 19:51:51 +08:00
hzm
0ea310f9b3 feat:移动端横向移动吸附效果 2026-03-01 19:29:07 +08:00
hzm
4fcb076d99 feat:移动端表格宽度动态计算 2026-03-01 19:07:14 +08:00
hzm
e7661e7b38 feat:个性化数据往云端同步 2026-03-01 16:49:46 +08:00
hzm
2a406be0b1 feat:移动端表格模式重构 2026-03-01 15:07:53 +08:00
hzm
dd9ec06c65 feat:补充缺失文件 2026-02-28 23:15:25 +08:00
hzm
e0260f01ec feat:表格列排序 2026-02-28 23:08:42 +08:00
hzm
9e743e29f4 feat:随机头像风格换成 identicon 2026-02-28 21:27:05 +08:00
hzm
ad746c0fcd feat:缩小移动端导航栏边距 2026-02-28 20:02:51 +08:00
hzm
5ab0ad45c2 fix: 初始化刷新接口需等待获取云端数据之后 2026-02-28 19:55:38 +08:00
hzm
1256b807a9 feat:亮色主题 2026-02-28 19:45:54 +08:00
hzm
37243c5fc0 feat: 部分弹框关闭后页面无法滚动问题 2026-02-28 11:00:38 +08:00
hzm
1c2195dd64 feat:更新图标位置调整 2026-02-27 22:30:49 +08:00
hzm
c3157439c3 feat:移动端列表视图如果有持仓金额则展示持仓金额 2026-02-27 21:56:58 +08:00
hzm
7236684178 fix:实时估值分时显示条件判断bug 2026-02-27 21:42:13 +08:00
hzm
dae7576c7a feat:实时估值分时显示条件判断 2026-02-27 21:27:25 +08:00
hzm
67ca3ce81d feat: 优化首页性能 2026-02-27 21:01:09 +08:00
hzm
c740999e90 feat: funds 字段同步时比较内容调整 2026-02-27 20:44:00 +08:00
hzm
e7192987f4 feat: PC 端表格拖拽排序 2026-02-27 20:27:08 +08:00
hzm
510664c4d3 fix: PC表格支持以百分比显示持有收益 2026-02-27 10:50:21 +08:00
hzm
bf791949d0 feat: 调整默认PC列宽 2026-02-27 09:57:26 +08:00
hzm
8084f96dce fix: 图标问题 2026-02-27 09:39:08 +08:00
hzm
8dbe1c7cbb feat:更新公告 2026-02-27 08:44:57 +08:00
hzm
c2f4fec86d feat:允许PC表格重置 2026-02-27 08:39:47 +08:00
hzm
cbfa9a433a feat:存储列拖拽产生的列宽 2026-02-27 08:18:53 +08:00
hzm
b27ab48d27 feat:重构PC端表格 2026-02-27 08:12:01 +08:00
hzm
f33c6397c0 fix: 移动容器宽度 2026-02-26 21:27:59 +08:00
hzm
1146f88466 feat: 调整PC表格列宽 2026-02-26 21:24:33 +08:00
hzm
8f2ca3ab23 fix: 主布局宽度问题 2026-02-26 11:16:19 +08:00
hzm
f3adc1c7aa feat: 添加滚动条样式和优化表格布局 2026-02-26 11:06:45 +08:00
hzm
d5131b87db fix: 对齐持有收益样式 2026-02-26 09:50:47 +08:00
hzm
d8d5e7b100 feat: PC端表格模式区分涨跌幅和估值涨跌幅 2026-02-26 09:12:42 +08:00
hzm
026dbfceeb feat: 手动添加的交易记录,不算入持仓金额 2026-02-26 07:43:19 +08:00
hzm
21eb5d7fd7 feat: 发布 0.1.7 2026-02-25 22:40:50 +08:00
34 changed files with 110562 additions and 1043 deletions

2
.gitignore vendored
View File

@@ -83,5 +83,5 @@ fabric.properties
.env.local .env.local
.DS_Store .DS_Store
.idea .idea/*
.husky/_/ .husky/_/

View File

@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.cursor" />
<excludeFolder url="file://$MODULE_DIR$/.trae" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>

View File

@@ -72,7 +72,7 @@
3. 修改接收到的邮件为验证码 3. 修改接收到的邮件为验证码
在 Supabase控制台 → Authentication → Email → Confirm sign up选择 `{{.token}}`。 在 Supabase控制台 → Authentication → Email Templates 中,选择 **Magic Link** 模板进行编辑,在邮件正文中使用变量 `{{ .Token }}` 展示验证码
4. 修改验证码位数 4. 修改验证码位数

View File

@@ -126,6 +126,55 @@ const parseLatestNetValueFromLsjzContent = (content) => {
return null; return null;
}; };
const extractHoldingsReportDate = (html) => {
if (!html) return null;
// 优先匹配带有“报告期 / 截止日期”等关键字附近的日期
const m1 = html.match(/(报告期|截止日期)[^0-9]{0,20}(\d{4}-\d{2}-\d{2})/);
if (m1) return m1[2];
// 兜底:取文中出现的第一个 yyyy-MM-dd 格式日期
const m2 = html.match(/(\d{4}-\d{2}-\d{2})/);
return m2 ? m2[1] : null;
};
const isLastQuarterReport = (reportDateStr) => {
if (!reportDateStr) return false;
const report = dayjs(reportDateStr, 'YYYY-MM-DD');
if (!report.isValid()) return false;
const now = nowInTz();
const m = now.month(); // 0-11
const q = Math.floor(m / 3); // 当前季度 0-3 => Q1-Q4
let lastQ;
let year;
if (q === 0) {
// 当前为 Q1则上一季度是上一年的 Q4
lastQ = 3;
year = now.year() - 1;
} else {
lastQ = q - 1;
year = now.year();
}
const quarterEnds = [
{ month: 2, day: 31 }, // Q1 -> 03-31
{ month: 5, day: 30 }, // Q2 -> 06-30
{ month: 8, day: 30 }, // Q3 -> 09-30
{ month: 11, day: 31 } // Q4 -> 12-31
];
const { month: endMonth, day: endDay } = quarterEnds[lastQ];
const lastQuarterEnd = dayjs(
`${year}-${String(endMonth + 1).padStart(2, '0')}-${endDay}`,
'YYYY-MM-DD'
);
return report.isSame(lastQuarterEnd, 'day');
};
export const fetchSmartFundNetValue = async (code, startDate) => { export const fetchSmartFundNetValue = async (code, startDate) => {
const today = nowInTz().startOf('day'); const today = nowInTz().startOf('day');
let current = toTz(startDate).startOf('day'); let current = toTz(startDate).startOf('day');
@@ -199,7 +248,9 @@ export const fetchFundDataFallback = async (c) => {
gszzl: null, gszzl: null,
zzl: Number.isFinite(latest.growth) ? latest.growth : null, zzl: Number.isFinite(latest.growth) ? latest.growth : null,
noValuation: true, noValuation: true,
holdings: [] holdings: [],
holdingsReportDate: null,
holdingsIsLastQuarter: false
}); });
} else { } else {
reject(new Error('未能获取到基金数据')); reject(new Error('未能获取到基金数据'));
@@ -255,9 +306,23 @@ export const fetchFundData = async (c) => {
}); });
const holdingsPromise = new Promise((resolveH) => { const holdingsPromise = new Promise((resolveH) => {
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`; const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
loadScript(holdingsUrl).then(async (apidata) => { const holdingsCacheKey = `fund_holdings_archives_${c}`;
cachedRequest(
() => loadScript(holdingsUrl),
holdingsCacheKey,
{ cacheTime: 60 * 60 * 1000 }
).then(async (apidata) => {
let holdings = []; let holdings = [];
const html = apidata?.content || ''; const html = apidata?.content || '';
const holdingsReportDate = extractHoldingsReportDate(html);
const holdingsIsLastQuarter = isLastQuarterReport(holdingsReportDate);
// 如果不是上一季度末的披露数据,则不展示重仓(并避免继续解析/请求行情)
if (!holdingsIsLastQuarter) {
resolveH({ holdings: [], holdingsReportDate, holdingsIsLastQuarter: false });
return;
}
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || ''; 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()); const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
let idxCode = -1, idxName = -1, idxWeight = -1; let idxCode = -1, idxName = -1, idxWeight = -1;
@@ -354,10 +419,15 @@ export const fetchFundData = async (c) => {
} catch (e) { } catch (e) {
} }
} }
resolveH(holdings); resolveH({ holdings, holdingsReportDate, holdingsIsLastQuarter });
}).catch(() => resolveH([])); }).catch(() => resolveH({ holdings: [], holdingsReportDate: null, holdingsIsLastQuarter: false }));
}); });
Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdings]) => { Promise.all([lsjzPromise, holdingsPromise]).then(([tData, holdingsResult]) => {
const {
holdings,
holdingsReportDate,
holdingsIsLastQuarter
} = holdingsResult || {};
if (tData) { if (tData) {
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) { if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
gzData.dwjz = tData.dwjz; gzData.dwjz = tData.dwjz;
@@ -365,7 +435,12 @@ export const fetchFundData = async (c) => {
gzData.zzl = tData.zzl; gzData.zzl = tData.zzl;
} }
} }
resolve({ ...gzData, holdings }); resolve({
...gzData,
holdings,
holdingsReportDate,
holdingsIsLastQuarter
});
}); });
}; };
scriptGz.onerror = () => { scriptGz.onerror = () => {
@@ -459,73 +534,140 @@ export const submitFeedback = async (formData) => {
return response.json(); return response.json();
}; };
// 使用智谱 GLM 从 OCR 文本中抽取基金名称 const PINGZHONGDATA_GLOBAL_KEYS = [
export const extractFundNamesWithLLM = async (ocrText) => { 'ishb',
const apiKey = '8df8ccf74a174722847c83b7e222f2af.4A39rJvUeBVDmef1'; 'fS_name',
if (!apiKey || !ocrText) return []; 'fS_code',
'fund_sourceRate',
'fund_Rate',
'fund_minsg',
'stockCodes',
'zqCodes',
'stockCodesNew',
'zqCodesNew',
'syl_1n',
'syl_6y',
'syl_3y',
'syl_1y',
'Data_fundSharesPositions',
'Data_netWorthTrend',
'Data_ACWorthTrend',
'Data_grandTotal',
'Data_rateInSimilarType',
'Data_rateInSimilarPersent',
'Data_fluctuationScale',
'Data_holderStructure',
'Data_assetAllocation',
'Data_performanceEvaluation',
'Data_currentFundManager',
'Data_buySedemption',
'swithSameType',
];
try { let pingzhongdataQueue = Promise.resolve();
const models = ['glm-4.5-flash', 'glm-4.7-flash'];
const model = models[Math.floor(Math.random() * models.length)];
const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', { const enqueuePingzhongdataLoad = (fn) => {
method: 'POST', const p = pingzhongdataQueue.then(fn, fn);
headers: { // 避免队列被 reject 永久阻塞
'Content-Type': 'application/json', pingzhongdataQueue = p.catch(() => undefined);
Authorization: `Bearer ${apiKey}`, return p;
},
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content:
'你是一个基金 OCR 文本解析助手。' +
'从下面的 OCR 文本中抽取其中出现的「基金名称列表」。' +
'要求1基金名称一般为中文中间不能有空字符串,可包含部分英文或括号' +
'2名称后面通常会跟着金额或持有金额数字可能带千分位逗号和小数' +
'3忽略无关信息只返回你判断为基金名称的字符串' +
'4去重后输出。输出格式严格返回 JSON如 {"fund_names": ["基金名称1","基金名称2"]},不要输出任何多余说明',
},
{
role: 'user',
content: String(ocrText),
},
],
temperature: 0.2,
max_tokens: 1024,
thinking: {
type: 'disabled',
},
}),
});
if (!resp.ok) {
return [];
}
const data = await resp.json();
let content = data?.choices?.[0]?.message?.content?.match(/\{[\s\S]*?\}/)?.[0];
if (!isString(content)) return [];
let parsed;
try {
parsed = JSON.parse(content);
} catch {
return [];
}
const names = parsed?.fund_names;
if (!Array.isArray(names)) return [];
return names
.map((n) => (isString(n) ? n.trim().replaceAll(' ','') : ''))
.filter(Boolean);
} catch (e) {
return [];
}
}; };
let historyQueue = Promise.resolve(); const snapshotPingzhongdataGlobals = (fundCode) => {
const out = {};
for (const k of PINGZHONGDATA_GLOBAL_KEYS) {
if (typeof window?.[k] === 'undefined') continue;
try {
out[k] = JSON.parse(JSON.stringify(window[k]));
} catch (e) {
out[k] = window[k];
}
}
return {
fundCode: out.fS_code || fundCode,
fundName: out.fS_name || '',
...out,
};
};
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
return new Promise((resolve, reject) => {
if (typeof document === 'undefined' || !document.body) {
reject(new Error('无浏览器环境'));
return;
}
const url = `https://fund.eastmoney.com/pingzhongdata/${fundCode}.js?v=${Date.now()}`;
const script = document.createElement('script');
script.src = url;
script.async = true;
let done = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = null;
script.onload = null;
script.onerror = null;
if (document.body.contains(script)) document.body.removeChild(script);
};
timer = setTimeout(() => {
if (done) return;
done = true;
cleanup();
reject(new Error('pingzhongdata 请求超时'));
}, timeoutMs);
script.onload = () => {
if (done) return;
done = true;
const data = snapshotPingzhongdataGlobals(fundCode);
cleanup();
resolve(data);
};
script.onerror = () => {
if (done) return;
done = true;
cleanup();
reject(new Error('pingzhongdata 加载失败'));
};
document.body.appendChild(script);
});
};
const fetchAndParsePingzhongdata = async (fundCode) => {
// 使用 JSONP(script 注入) 方式获取并解析 pingzhongdata
return enqueuePingzhongdataLoad(() => jsonpLoadPingzhongdata(fundCode));
};
/**
* 获取并解析「基金走势图/资产等」数据pingzhongdata
* 来源https://fund.eastmoney.com/pingzhongdata/${fundCode}.js
*/
export const fetchFundPingzhongdata = async (fundCode, { cacheTime = 60 * 60 * 1000 } = {}) => {
if (!fundCode) throw new Error('fundCode 不能为空');
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('无浏览器环境');
}
const cacheKey = `pingzhongdata_${fundCode}`;
try {
return await cachedRequest(
() => fetchAndParsePingzhongdata(fundCode),
cacheKey,
{ cacheTime }
);
} catch (e) {
clearCachedRequest(cacheKey);
throw e;
}
};
export const fetchFundHistory = async (code, range = '1m') => { export const fetchFundHistory = async (code, range = '1m') => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
@@ -539,73 +681,65 @@ export const fetchFundHistory = async (code, range = '1m') => {
case '6m': start = start.subtract(6, 'month'); break; case '6m': start = start.subtract(6, 'month'); break;
case '1y': start = start.subtract(1, 'year'); break; case '1y': start = start.subtract(1, 'year'); break;
case '3y': start = start.subtract(3, 'year'); break; case '3y': start = start.subtract(3, 'year'); break;
case 'all': start = dayjs(0).tz(TZ); break;
default: start = start.subtract(1, 'month'); default: start = start.subtract(1, 'month');
} }
const sdate = start.format('YYYY-MM-DD'); // 业绩走势统一走 pingzhongdata.Data_netWorthTrend
const edate = end.format('YYYY-MM-DD'); try {
const per = 49; const pz = await fetchFundPingzhongdata(code);
const trend = pz?.Data_netWorthTrend;
if (Array.isArray(trend) && trend.length) {
const startMs = start.startOf('day').valueOf();
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
const endMs = end.endOf('day').valueOf();
const out = trend
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
.map((d) => {
const value = Number(d.y);
if (!Number.isFinite(value)) return null;
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
return { date, value };
})
.filter(Boolean);
return new Promise((resolve) => { if (out.length) return out;
historyQueue = historyQueue.then(async () => { }
let allData = []; } catch (e) {
let page = 1; return [];
let totalPages = 1; }
return [];
};
export const parseFundTextWithLLM = async (text) => {
const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
if (!apiKey || !text) return null;
try { try {
const parseContent = (content) => { const response = await fetch('https://apis.iflow.cn/v1/chat/completions', {
if (!content) return []; method: 'POST',
const rows = content.split('<tr>'); headers: {
const data = []; 'Authorization': `Bearer ${apiKey}`,
for (const row of rows) { 'Content-Type': 'application/json'
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g); },
if (cells && cells.length >= 2) { body: JSON.stringify({
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim(); model: 'qwen3-max',
const valStr = cells[1].replace(/<[^>]+>/g, '').trim(); messages: [
const val = parseFloat(valStr); { role: 'system', content: "你是一个基金文本解析助手。请从提供的OCR文本中执行以下任务\n抽取所有基金信息包括基金名称中文字符串可含英文或括号名称后常跟随金额数字。基金代码6位数字如果存在。持有金额数字格式可能含千分位逗号或小数如果存在。持有收益数字格式可能含千分位逗号或小数如果存在。忽略无关文本。输出格式以JSON数组形式返回结果每个基金信息为一个对象包含以下字段基金名称必填字符串基金代码可选字符串不存在时为空字符串持有金额可选字符串不存在时为空字符串持有收益可选字符串不存在时为空字符串示例输出[{'fundName':'华夏成长混合','fundCode':'000001','holdAmounts':'50,000.00','holdGains':'2,500.00'},{'fundName':'易方达消费行业','fundCode':'','holdAmounts':'10,000.00','holdGains':'}]。除了示例输出的内容外,不要输出任何多余内容"},
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) { { role: 'user', content: text }
data.push({ date: dateStr, value: val }); ],
} temperature: 0.3,
} max_tokens: 2000
} })
return data; });
};
// Fetch first page to get metadata if (!response.ok) {
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`; return null;
const firstApidata = await loadScript(firstUrl);
if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
resolve([]);
return;
} }
// Parse total pages const data = await response.json();
if (firstApidata.pages) { return data?.choices?.[0]?.message?.content || null;
totalPages = parseInt(firstApidata.pages, 10) || 1;
}
allData = allData.concat(parseContent(firstApidata.content));
// Fetch remaining pages
for (page = 2; page <= totalPages; page++) {
const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`;
const nextApidata = await loadScript(nextUrl);
if (nextApidata && nextApidata.content) {
allData = allData.concat(parseContent(nextApidata.content));
}
}
// The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first)
resolve(allData.reverse());
} catch (e) { } catch (e) {
console.error('Fetch history error:', e); return null;
resolve([]);
} }
}).catch((e) => {
console.error('Queue error:', e);
resolve([]);
});
});
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -197,6 +197,10 @@ export default function AddHistoryModal({ fund, onClose, onConfirm }) {
/> />
</div> </div>
<div className="muted" style={{ fontSize: '11px', lineHeight: 1.5, marginBottom: 16, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
*此处补录的买入/卖出仅作记录展示不会改变当前持仓金额与份额实际持仓请在持仓设置中维护
</div>
<button <button
className="button primary full-width" className="button primary full-width"
onClick={handleSubmit} onClick={handleSubmit}

View File

@@ -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_v9'; const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v11';
export default function Announcement() { export default function Announcement() {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -52,6 +52,8 @@ export default function Announcement() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '16px', gap: '16px',
maxHeight: 'calc(100dvh - 40px)',
overflow: 'hidden',
}} }}
> >
<div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}> <div className="title" style={{ display: 'flex', alignItems: 'center', gap: '12px', fontWeight: 700, fontSize: '18px', color: 'var(--accent)' }}>
@@ -62,16 +64,14 @@ export default function Announcement() {
</svg> </svg>
<span>公告</span> <span>公告</span>
</div> </div>
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}> <div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
为了增加更多用户方便访问, 新增国内加速地址<a className="link-button" <p>v0.2.1 版本更新内容如下</p>
target="_blank" <p>1. 改进拍照识别基金准确度</p>
rel="noopener noreferrer" <p>2. 拍照导入支持识别持仓金额持仓收益</p>
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a> 以下功能将会在下一个版本上线
<p>v0.1.7 版本更新内容如下</p> <p>1. 列表页查看基金详情</p>
<p>1. 实时基金估值折线图测试版</p> <p>2. 大盘走势数据</p>
<p>2. 定投</p> <p>3. 关联板块</p>
以下内容会在近期更新
<p>1. 自定义布局</p>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

View File

@@ -73,20 +73,8 @@ export function DatePicker({ value, onChange }) {
return ( return (
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}> <div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
<div <div
className="input-trigger" className="date-picker-trigger"
onClick={() => setIsOpen(!isOpen)} 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> <span>{value || '选择日期'}</span>
<CalendarIcon width="16" height="16" className="muted" /> <CalendarIcon width="16" height="16" className="muted" />
@@ -98,7 +86,7 @@ export function DatePicker({ value, onChange }) {
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="glass card" className="date-picker-dropdown glass card"
style={{ style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
@@ -106,10 +94,7 @@ export function DatePicker({ value, onChange }) {
width: '100%', width: '100%',
marginTop: 8, marginTop: 8,
padding: 12, padding: 12,
zIndex: 10, 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 }}> <div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
@@ -141,26 +126,8 @@ export function DatePicker({ value, onChange }) {
return ( return (
<div <div
key={i} key={i}
className={`date-picker-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isFuture ? 'future' : ''}`}
onClick={(e) => !isFuture && handleSelect(e, d)} 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} {d}
</div> </div>

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { createPortal } from 'react-dom';
import { TrashIcon } from './Icons'; import { TrashIcon } from './Icons';
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) { export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
return ( const content = (
<motion.div <motion.div
className="modal-overlay" className="modal-overlay"
role="dialog" role="dialog"
@@ -34,10 +35,25 @@ export default function ConfirmModal({ title, message, onConfirm, onCancel, conf
{message} {message}
</p> </p>
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button> <button
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>{confirmText}</button> className="button secondary"
onClick={onCancel}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
取消
</button>
<button
className="button danger"
onClick={onConfirm}
style={{ flex: 1 }}
autoFocus
>
{confirmText}
</button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
); );
if (typeof document === 'undefined') return null;
return createPortal(content, document.body);
} }

View File

@@ -185,7 +185,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal dca-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '420px' }} style={{ maxWidth: '420px' }}
> >
@@ -220,28 +220,8 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
gap: 6 gap: 6
}} }}
> >
<span <span className={`dca-toggle-track ${enabled ? 'enabled' : ''}`}>
style={{ <span className="dca-toggle-thumb" style={{ left: enabled ? 16 : 2 }} />
width: 32,
height: 18,
borderRadius: 999,
background: enabled ? 'var(--primary)' : 'rgba(148,163,184,0.6)',
position: 'relative',
transition: 'background 0.2s'
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: enabled ? 16 : 2,
width: 14,
height: 14,
borderRadius: '50%',
background: '#0f172a',
transition: 'left 0.2s'
}}
/>
</span> </span>
<span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}> <span style={{ fontSize: 12, color: enabled ? 'var(--primary)' : 'var(--muted)' }}>
{enabled ? '已启用' : '未启用'} {enabled ? '已启用' : '未启用'}
@@ -284,23 +264,13 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
<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 className="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}> <div className="dca-option-group row" style={{ gap: 4 }}>
{CYCLES.map((opt) => ( {CYCLES.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
className={`dca-option-btn ${cycle === opt.value ? 'active' : ''}`}
onClick={() => setCycle(opt.value)} onClick={() => setCycle(opt.value)}
style={{
flex: 1,
border: 'none',
background: cycle === opt.value ? 'var(--primary)' : 'transparent',
color: cycle === opt.value ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 11,
cursor: 'pointer',
padding: '4px 6px',
whiteSpace: 'nowrap'
}}
> >
{opt.label} {opt.label}
</button> </button>
@@ -314,23 +284,13 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
<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 className="row" style={{ gap: 4, background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: 4 }}> <div className="dca-option-group row" style={{ gap: 4 }}>
{WEEKDAY_OPTIONS.map((opt) => ( {WEEKDAY_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
className={`dca-option-btn dca-weekday-btn ${weeklyDay === opt.value ? 'active' : ''}`}
onClick={() => setWeeklyDay(opt.value)} onClick={() => setWeeklyDay(opt.value)}
style={{
flex: 1,
border: 'none',
background: weeklyDay === opt.value ? 'var(--primary)' : 'transparent',
color: weeklyDay === opt.value ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
padding: '6px 4px',
whiteSpace: 'nowrap'
}}
> >
{opt.label} {opt.label}
</button> </button>
@@ -344,19 +304,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
<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 <div className="dca-monthly-day-group scrollbar-y-styled">
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
background: 'rgba(0,0,0,0.2)',
borderRadius: 8,
padding: 4,
maxHeight: 140,
overflowY: 'auto',
scrollBehavior: 'smooth'
}}
>
{Array.from({ length: 28 }).map((_, idx) => { {Array.from({ length: 28 }).map((_, idx) => {
const day = idx + 1; const day = idx + 1;
const active = monthlyDay === day; const active = monthlyDay === day;
@@ -365,17 +313,8 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
key={day} key={day}
ref={active ? monthlyDayRef : null} ref={active ? monthlyDayRef : null}
type="button" type="button"
className={`dca-option-btn dca-monthly-btn ${active ? 'active' : ''}`}
onClick={() => setMonthlyDay(day)} onClick={() => setMonthlyDay(day)}
style={{
flex: '0 0 calc(25% - 4px)',
border: 'none',
background: active ? 'var(--primary)' : 'transparent',
color: active ? '#05263b' : 'var(--muted)',
borderRadius: 6,
fontSize: 11,
cursor: 'pointer',
padding: '4px 0'
}}
> >
{day} {day}
</button> </button>
@@ -389,15 +328,7 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
<label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}> <label className="muted" style={{ display: 'block', marginBottom: 4, fontSize: '14px' }}>
首次扣款日期 首次扣款日期
</label> </label>
<div <div className="dca-first-date-display">
style={{
borderRadius: 12,
border: '1px solid var(--border)',
padding: '10px 12px',
fontSize: 14,
background: 'rgba(15,23,42,0.6)'
}}
>
{firstDate} {firstDate}
</div> </div>
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}> <div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
@@ -408,9 +339,9 @@ export default function DcaModal({ fund, plan, onClose, onConfirm }) {
<div className="row" style={{ gap: 12, marginTop: 12 }}> <div className="row" style={{ gap: 12, marginTop: 12 }}>
<button <button
type="button" type="button"
className="button secondary" className="button secondary dca-cancel-btn"
onClick={onClose} onClick={onClose}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }} style={{ flex: 1 }}
> >
取消 取消
</button> </button>

View File

@@ -0,0 +1,87 @@
'use client';
import { useLayoutEffect, useRef } from 'react';
/**
* 根据容器宽度动态缩小字体,使内容不溢出。
* 使用 ResizeObserver 监听容器宽度,内容超出时按比例缩小 fontSize不低于 minFontSize。
*
* @param {Object} props
* @param {React.ReactNode} props.children - 要显示的文本(会单行、不换行)
* @param {number} [props.maxFontSize=14] - 最大字号px
* @param {number} [props.minFontSize=10] - 最小字号px再窄也不低于此值
* @param {string} [props.className] - 外层容器 className
* @param {Object} [props.style] - 外层容器 style宽度由父级决定建议父级有明确宽度
* @param {string} [props.as='span'] - 外层容器标签 'span' | 'div'
*/
export default function FitText({
children,
maxFontSize = 14,
minFontSize = 10,
className,
style = {},
as: Tag = 'span',
}) {
const containerRef = useRef(null);
const contentRef = useRef(null);
const adjust = () => {
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
const containerWidth = container.clientWidth;
if (containerWidth <= 0) return;
// 先恢复到最大字号再测量,确保在「未缩放」状态下取到真实内容宽度
content.style.fontSize = `${maxFontSize}px`;
const run = () => {
const contentWidth = content.scrollWidth;
if (contentWidth <= 0) return;
let size = maxFontSize;
if (contentWidth > containerWidth) {
size = (containerWidth / contentWidth) * maxFontSize;
size = Math.max(minFontSize, Math.min(maxFontSize, size));
}
content.style.fontSize = `${size}px`;
};
requestAnimationFrame(run);
};
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
adjust();
const ro = new ResizeObserver(adjust);
ro.observe(container);
return () => ro.disconnect();
}, [children, maxFontSize, minFontSize]);
return (
<Tag
ref={containerRef}
className={className}
style={{
display: 'block',
width: '100%',
overflow: 'hidden',
...style,
}}
>
<span
ref={contentRef}
style={{
display: 'inline-block',
whiteSpace: 'nowrap',
fontWeight: 'inherit',
fontSize: `${maxFontSize}px`,
}}
>
{children}
</span>
</Tag>
);
}

View File

@@ -22,14 +22,41 @@ ChartJS.register(
Filler Filler
); );
const CHART_COLORS = {
dark: {
danger: '#f87171',
success: '#34d399',
primary: '#22d3ee',
muted: '#9ca3af',
border: '#1f2937',
text: '#e5e7eb',
crosshairText: '#0f172a',
},
light: {
danger: '#dc2626',
success: '#059669',
primary: '#0891b2',
muted: '#475569',
border: '#e2e8f0',
text: '#0f172a',
crosshairText: '#ffffff',
}
};
function getChartThemeColors(theme) {
return CHART_COLORS[theme] || CHART_COLORS.dark;
}
/** /**
* 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。 * 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
* series: Array<{ time: string, value: number, date?: string }> * series: Array<{ time: string, value: number, date?: string }>
* referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。 * referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
* theme: 'light' | 'dark',用于亮色主题下坐标轴与 crosshair 样式
*/ */
export default function FundIntradayChart({ series = [], referenceNav }) { export default function FundIntradayChart({ series = [], referenceNav, theme = 'dark' }) {
const chartRef = useRef(null); const chartRef = useRef(null);
const hoverTimeoutRef = useRef(null); const hoverTimeoutRef = useRef(null);
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!series.length) return { labels: [], datasets: [] }; if (!series.length) return { labels: [], datasets: [] };
@@ -40,9 +67,8 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
: values[0]; : values[0];
const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0)); const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0));
const lastPct = percentages[percentages.length - 1]; const lastPct = percentages[percentages.length - 1];
const riseColor = '#f87171'; // 涨用红色 const riseColor = chartColors.danger;
const fallColor = '#34d399'; // 跌用绿色 const fallColor = chartColors.success;
// 以最新点相对参考净值的涨跌定色:涨(>=0)红,跌(<0)绿
const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor; const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor;
return { return {
@@ -68,9 +94,11 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
} }
] ]
}; };
}, [series, referenceNav]); }, [series, referenceNav, chartColors.danger, chartColors.success]);
const options = useMemo(() => ({ const options = useMemo(() => {
const colors = getChartThemeColors(theme);
return {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
@@ -88,7 +116,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
display: true, display: true,
grid: { display: false }, grid: { display: false },
ticks: { ticks: {
color: '#9ca3af', color: colors.muted,
font: { size: 10 }, font: { size: 10 },
maxTicksLimit: 6 maxTicksLimit: 6
} }
@@ -96,9 +124,9 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
y: { y: {
display: true, display: true,
position: 'left', position: 'left',
grid: { color: '#1f2937', drawBorder: false }, grid: { color: colors.border, drawBorder: false },
ticks: { ticks: {
color: '#9ca3af', color: colors.muted,
font: { size: 10 }, font: { size: 10 },
callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v) callback: (v) => (isNumber(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
} }
@@ -142,7 +170,8 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
}, 2000); }, 2000);
} }
} }
}), []); };
}, [theme]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -152,7 +181,9 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
}; };
}, []); }, []);
const plugins = useMemo(() => [{ const plugins = useMemo(() => {
const colors = getChartThemeColors(theme);
return [{
id: 'crosshair', id: 'crosshair',
afterDraw: (chart) => { afterDraw: (chart) => {
const ctx = chart.ctx; const ctx = chart.ctx;
@@ -175,17 +206,15 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
ctx.save(); ctx.save();
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeStyle = '#9ca3af'; ctx.strokeStyle = colors.muted;
ctx.moveTo(x, topY); ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY); ctx.lineTo(x, bottomY);
ctx.moveTo(leftX, y); ctx.moveTo(leftX, y);
ctx.lineTo(rightX, y); ctx.lineTo(rightX, y);
ctx.stroke(); ctx.stroke();
const prim = typeof document !== 'undefined' const prim = colors.primary;
? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee') const textCol = colors.crosshairText;
: '#22d3ee';
const bgText = '#0f172a';
ctx.font = '10px sans-serif'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
@@ -202,7 +231,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
const labelCenterX = labelLeft + tw / 2; const labelCenterX = labelLeft + tw / 2;
ctx.fillStyle = prim; ctx.fillStyle = prim;
ctx.fillRect(labelLeft, bottomY, tw, 16); ctx.fillRect(labelLeft, bottomY, tw, 16);
ctx.fillStyle = bgText; ctx.fillStyle = textCol;
ctx.fillText(timeStr, labelCenterX, bottomY + 8); ctx.fillText(timeStr, labelCenterX, bottomY + 8);
} }
if (data && index in data) { if (data && index in data) {
@@ -211,12 +240,13 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
const vw = ctx.measureText(valueStr).width + 8; const vw = ctx.measureText(valueStr).width + 8;
ctx.fillStyle = prim; ctx.fillStyle = prim;
ctx.fillRect(leftX, y - 8, vw, 16); ctx.fillRect(leftX, y - 8, vw, 16);
ctx.fillStyle = bgText; ctx.fillStyle = textCol;
ctx.fillText(valueStr, leftX + vw / 2, y); ctx.fillText(valueStr, leftX + vw / 2, y);
} }
ctx.restore(); ctx.restore();
} }
}], []); }];
}, [theme]);
if (series.length < 2) return null; if (series.length < 2) return null;
@@ -230,11 +260,20 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
<span <span
style={{ style={{
fontSize: 9, fontSize: 9,
padding: '1px 5px', padding: '2px 6px',
borderRadius: 4, borderRadius: 4,
...(theme === 'light'
? {
border: '1px solid',
borderColor: chartColors.primary,
color: chartColors.primary,
background: 'transparent',
}
: {
background: 'var(--primary)', background: 'var(--primary)',
color: '#0f172a', color: '#0f172a',
fontWeight: 600 }),
fontWeight: 600,
}} }}
title="正在测试中的功能" title="正在测试中的功能"
> >
@@ -243,7 +282,7 @@ export default function FundIntradayChart({ series = [], referenceNav }) {
</span> </span>
{displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>} {displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>}
</div> </div>
<div style={{ position: 'relative', height: 100, width: '100%' }}> <div style={{ position: 'relative', height: 100, width: '100%', touchAction: 'pan-y' }}>
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} /> <Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
</div> </div>
</div> </div>

View File

@@ -29,7 +29,32 @@ ChartJS.register(
Filler Filler
); );
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) { const CHART_COLORS = {
dark: {
danger: '#f87171',
success: '#34d399',
primary: '#22d3ee',
muted: '#9ca3af',
border: '#1f2937',
text: '#e5e7eb',
crosshairText: '#0f172a',
},
light: {
danger: '#dc2626',
success: '#059669',
primary: '#0891b2',
muted: '#475569',
border: '#e2e8f0',
text: '#0f172a',
crosshairText: '#ffffff',
}
};
function getChartThemeColors(theme) {
return CHART_COLORS[theme] || CHART_COLORS.dark;
}
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) {
const [range, setRange] = useState('1m'); const [range, setRange] = useState('1m');
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -37,6 +62,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const chartRef = useRef(null); const chartRef = useRef(null);
const hoverTimeoutRef = useRef(null); const hoverTimeoutRef = useRef(null);
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
useEffect(() => { useEffect(() => {
// If collapsed, don't fetch data unless we have no data yet // If collapsed, don't fetch data unless we have no data yet
if (!isExpanded && data.length > 0) return; if (!isExpanded && data.length > 0) return;
@@ -74,7 +101,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
{ label: '近3月', value: '3m' }, { label: '近3月', value: '3m' },
{ label: '近6月', value: '6m' }, { label: '近6月', value: '6m' },
{ label: '近1年', value: '1y' }, { label: '近1年', value: '1y' },
{ label: '近3年', value: '3y'} { label: '近3年', value: '3y' },
{ label: '成立来', value: 'all' }
]; ];
const change = useMemo(() => { const change = useMemo(() => {
@@ -84,12 +112,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
return ((last - first) / first) * 100; return ((last - first) / first) * 100;
}, [data]); }, [data]);
// Red for up, Green for down (CN market style) // Red for up, Green for down (CN market style),随主题使用 CSS 变量
// Hardcoded hex values from globals.css for Chart.js const upColor = chartColors.danger;
const upColor = '#f87171'; // --danger与折线图红色一致 const downColor = chartColors.success;
const downColor = '#34d399'; // --success
const lineColor = change >= 0 ? upColor : downColor; const lineColor = change >= 0 ? upColor : downColor;
const primaryColor = typeof document !== 'undefined' ? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee') : '#22d3ee'; const primaryColor = chartColors.primary;
const chartData = useMemo(() => { const chartData = useMemo(() => {
// Calculate percentage change based on the first data point // Calculate percentage change based on the first data point
@@ -165,9 +192,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
} }
] ]
}; };
}, [data, lineColor, transactions, primaryColor]); }, [data, transactions, lineColor, primaryColor, upColor]);
const options = useMemo(() => { const options = useMemo(() => {
const colors = getChartThemeColors(theme);
return { return {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -190,7 +218,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
drawBorder: false drawBorder: false
}, },
ticks: { ticks: {
color: '#9ca3af', color: colors.muted,
font: { size: 10 }, font: { size: 10 },
maxTicksLimit: 4, maxTicksLimit: 4,
maxRotation: 0 maxRotation: 0
@@ -201,12 +229,12 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
display: true, display: true,
position: 'left', position: 'left',
grid: { grid: {
color: '#1f2937', color: colors.border,
drawBorder: false, drawBorder: false,
tickLength: 0 tickLength: 0
}, },
ticks: { ticks: {
color: '#9ca3af', color: colors.muted,
font: { size: 10 }, font: { size: 10 },
count: 5, count: 5,
callback: (value) => `${value.toFixed(2)}%` callback: (value) => `${value.toFixed(2)}%`
@@ -240,7 +268,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}, },
onClick: () => {} onClick: () => {}
}; };
}, []); }, [theme]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -250,7 +278,9 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}; };
}, []); }, []);
const plugins = useMemo(() => [{ const plugins = useMemo(() => {
const colors = getChartThemeColors(theme);
return [{
id: 'crosshair', id: 'crosshair',
afterEvent: (chart, args) => { afterEvent: (chart, args) => {
const { event, replay } = args || {}; const { event, replay } = args || {};
@@ -276,7 +306,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
afterDraw: (chart) => { afterDraw: (chart) => {
const ctx = chart.ctx; const ctx = chart.ctx;
const datasets = chart.data.datasets; const datasets = chart.data.datasets;
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee'; const primaryColor = colors.primary;
// 绘制圆角矩形(兼容无 roundRect 的环境) // 绘制圆角矩形(兼容无 roundRect 的环境)
const drawRoundRect = (left, top, w, h, r) => { const drawRoundRect = (left, top, w, h, r) => {
@@ -377,7 +407,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.beginPath(); ctx.beginPath();
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeStyle = '#9ca3af'; ctx.strokeStyle = colors.muted;
// Draw vertical line // Draw vertical line
ctx.moveTo(x, topY); ctx.moveTo(x, topY);
@@ -415,7 +445,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const labelCenterX = labelLeft + textWidth / 2; const labelCenterX = labelLeft + textWidth / 2;
ctx.fillStyle = primaryColor; ctx.fillStyle = primaryColor;
ctx.fillRect(labelLeft, bottomY, textWidth, 16); ctx.fillRect(labelLeft, bottomY, textWidth, 16);
ctx.fillStyle = '#0f172a'; // --background ctx.fillStyle = colors.crosshairText;
ctx.fillText(dateStr, labelCenterX, bottomY + 8); ctx.fillText(dateStr, labelCenterX, bottomY + 8);
// Y axis label (value) // Y axis label (value)
@@ -423,7 +453,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const valWidth = ctx.measureText(valueStr).width + 8; const valWidth = ctx.measureText(valueStr).width + 8;
ctx.fillStyle = primaryColor; ctx.fillStyle = primaryColor;
ctx.fillRect(leftX, y - 8, valWidth, 16); ctx.fillRect(leftX, y - 8, valWidth, 16);
ctx.fillStyle = '#0f172a'; // --background ctx.fillStyle = colors.crosshairText;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(valueStr, leftX + valWidth / 2, y); ctx.fillText(valueStr, leftX + valWidth / 2, y);
} }
@@ -442,7 +472,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const label = datasets[dsIndex].label; const label = datasets[dsIndex].label;
// Determine background color based on dataset index // Determine background color based on dataset index
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致) // 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
const bgColor = dsIndex === 1 ? primaryColor : '#f87171'; const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
// If collision, offset Buy label upwards // If collision, offset Buy label upwards
let yOffset = 0; let yOffset = 0;
@@ -457,7 +487,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.restore(); ctx.restore();
} }
} }
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据 }];
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
return ( return (
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}> <div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
@@ -499,21 +530,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<div style={{ position: 'relative', height: 180, width: '100%' }}> <div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
{loading && ( {loading && (
<div style={{ <div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
}}>
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span> <span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
</div> </div>
)} )}
{!loading && data.length === 0 && ( {!loading && data.length === 0 && (
<div style={{ <div className="chart-overlay">
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10
}}>
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span> <span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
</div> </div>
)} )}
@@ -523,23 +548,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
)} )}
</div> </div>
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}> <div className="trend-range-bar">
{ranges.map(r => ( {ranges.map(r => (
<button <button
key={r.value} key={r.value}
type="button"
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setRange(r.value); }} onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
style={{
flex: 1,
padding: '6px 0',
fontSize: '11px',
borderRadius: '6px',
border: 'none',
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
cursor: 'pointer',
transition: 'all 0.2s',
fontWeight: range === r.value ? 600 : 400
}}
> >
{r.label} {r.label}
</button> </button>

View File

@@ -75,9 +75,9 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
减仓 减仓
</button> </button>
<button <button
className="button col-4" className="button col-4 dca-btn"
onClick={() => onAction('dca')} onClick={() => onAction('dca')}
style={{ background: 'rgba(34, 211, 238, 0.12)', border: '1px solid #ffffff', color: '#ffffff', fontSize: 14 }} style={{ fontSize: 14 }}
> >
定投 定投
</button> </button>

View File

@@ -77,6 +77,13 @@ export function RefreshIcon(props) {
); );
} }
export function ResetIcon(props) {
return (
<svg t="1772152323013" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4796" width="16" height="16"><path fill="currentColor" d="M864 512a352 352 0 0 0-600.96-248.96c-15.744 15.872-40.704 42.88-63.232 67.648H320a32 32 0 1 1 0 64H128a31.872 31.872 0 0 1-32-32v-192a32 32 0 1 1 64 0v108.672c20.544-22.528 42.688-46.4 57.856-61.504a416 416 0 1 1 0 588.288 32 32 0 1 1 45.248-45.248A352 352 0 0 0 864 512z" p-id="4797"></path>
</svg>
);
}
export function ChevronIcon(props) { export function ChevronIcon(props) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
@@ -236,3 +243,20 @@ export function CameraIcon(props) {
</svg> </svg>
); );
} }
export function SunIcon(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">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
}
export function MoonIcon(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">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
}

View File

@@ -0,0 +1,779 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import FitText from './FitText';
import MobileSettingModal from './MobileSettingModal';
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
const MOBILE_NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent',
'estimateChangePercent',
'todayProfit',
'holdingProfit',
'latestNav',
'estimateNav',
];
const MOBILE_COLUMN_HEADERS = {
latestNav: '最新净值',
estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨跌幅',
estimateChangePercent: '估值涨跌幅',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
function SortableRow({ row, children, isTableDragging, disabled }) {
const {
attributes,
listeners,
transform,
transition,
setNodeRef,
setActivatorNodeRef,
isDragging,
} = useSortable({ id: row.original.code, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
};
return (
<motion.div
ref={setNodeRef}
className="table-row-wrapper"
layout={isTableDragging ? undefined : 'position'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{ ...style, position: 'relative' }}
{...attributes}
>
{typeof children === 'function' ? children(setActivatorNodeRef, listeners) : children}
</motion.div>
);
}
/**
* 移动端基金列表表格组件(基于 @tanstack/react-table与 PcFundTable 相同数据结构)
*
* @param {Object} props - 与 PcFundTable 一致
* @param {Array<Object>} props.data - 表格数据(与 pcFundTableData 同结构)
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金
* @param {string} [props.currentTab] - 当前分组
* @param {Set<string>} [props.favorites] - 自选集合
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
* @param {boolean} [props.refreshing] - 是否刷新中
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
*/
export default function MobileFundTable({
data = [],
onRemoveFund,
currentTab,
favorites = new Set(),
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
refreshing = false,
sortBy = 'default',
onReorder,
onCustomSettingsChange,
}) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { delay: 400, tolerance: 5 },
}),
useSensor(KeyboardSensor)
);
const [activeId, setActiveId] = useState(null);
const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
useEffect(() => {
onToggleFavoriteRef.current = onToggleFavorite;
onRemoveFromGroupRef.current = onRemoveFromGroup;
onHoldingAmountClickRef.current = onHoldingAmountClick;
}, [
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
]);
const handleDragStart = (e) => setActiveId(e.active.id);
const handleDragCancel = () => setActiveId(null);
const handleDragEnd = (e) => {
const { active, over } = e;
if (active && over && active.id !== over.id && onReorder) {
const oldIndex = data.findIndex((item) => item.code === active.id);
const newIndex = data.findIndex((item) => item.code === over.id);
if (oldIndex !== -1 && newIndex !== -1) onReorder(oldIndex, newIndex);
}
setActiveId(null);
};
const groupKey = currentTab ?? 'all';
const getCustomSettingsWithMigration = () => {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== 'object') return {};
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
const all = {
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
pcTableColumnOrder: parsed.pcTableColumnOrder,
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
pcTableColumns: parsed.pcTableColumns,
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
};
delete parsed.pcTableColumnOrder;
delete parsed.pcTableColumnVisibility;
delete parsed.pcTableColumns;
delete parsed.mobileTableColumnOrder;
delete parsed.mobileTableColumnVisibility;
parsed.all = all;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
}
return parsed;
} catch {
return {};
}
};
const getInitialMobileConfigByGroup = () => {
const parsed = getCustomSettingsWithMigration();
const byGroup = {};
Object.keys(parsed).forEach((k) => {
if (k === 'pcContainerWidth') return;
const group = parsed[k];
if (!group || typeof group !== 'object') return;
const order = Array.isArray(group.mobileTableColumnOrder) && group.mobileTableColumnOrder.length > 0
? group.mobileTableColumnOrder
: null;
const visibility = group.mobileTableColumnVisibility && typeof group.mobileTableColumnVisibility === 'object'
? group.mobileTableColumnVisibility
: null;
byGroup[k] = {
mobileTableColumnOrder: order ? (() => {
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
})() : null,
mobileTableColumnVisibility: visibility,
};
});
return byGroup;
};
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
const currentGroupMobile = configByGroup[groupKey];
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
const defaultVisibility = (() => {
const o = {};
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
return o;
})();
const mobileColumnOrder = (() => {
const order = currentGroupMobile?.mobileTableColumnOrder ?? defaultOrder;
if (!Array.isArray(order) || order.length === 0) return [...MOBILE_NON_FROZEN_COLUMN_IDS];
const valid = order.filter((id) => MOBILE_NON_FROZEN_COLUMN_IDS.includes(id));
const missing = MOBILE_NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
})();
const mobileColumnVisibility = (() => {
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
return defaultVisibility;
})();
const persistMobileGroupConfig = (updates) => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
if (updates.mobileTableColumnOrder !== undefined) group.mobileTableColumnOrder = updates.mobileTableColumnOrder;
if (updates.mobileTableColumnVisibility !== undefined) group.mobileTableColumnVisibility = updates.mobileTableColumnVisibility;
parsed[groupKey] = group;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
onCustomSettingsChange?.();
} catch {}
};
const setMobileColumnOrder = (nextOrderOrUpdater) => {
const next = typeof nextOrderOrUpdater === 'function'
? nextOrderOrUpdater(mobileColumnOrder)
: nextOrderOrUpdater;
persistMobileGroupConfig({ mobileTableColumnOrder: next });
};
const setMobileColumnVisibility = (nextOrUpdater) => {
const next = typeof nextOrUpdater === 'function'
? nextOrUpdater(mobileColumnVisibility)
: nextOrUpdater;
persistMobileGroupConfig({ mobileTableColumnVisibility: next });
};
const [settingModalOpen, setSettingModalOpen] = useState(false);
const tableContainerRef = useRef(null);
const [tableContainerWidth, setTableContainerWidth] = useState(0);
useEffect(() => {
const el = tableContainerRef.current;
if (!el) return;
const updateWidth = () => setTableContainerWidth(el.clientWidth || 0);
updateWidth();
const ro = new ResizeObserver(updateWidth);
ro.observe(el);
return () => ro.disconnect();
}, []);
const NAME_CELL_WIDTH = 140;
const GAP = 12;
const LAST_COLUMN_EXTRA = 12;
const FALLBACK_WIDTHS = {
fundName: 140,
latestNav: 64,
estimateNav: 64,
yesterdayChangePercent: 72,
estimateChangePercent: 80,
todayProfit: 80,
holdingProfit: 80,
};
const columnWidthMap = useMemo(() => {
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
const nonNameCount = visibleNonNameIds.length;
if (tableContainerWidth > 0 && nonNameCount > 0) {
const gapTotal = nonNameCount >= 3 ? 3 * GAP : (nonNameCount) * GAP;
const remaining = tableContainerWidth - NAME_CELL_WIDTH - gapTotal - LAST_COLUMN_EXTRA;
const divisor = nonNameCount >= 3 ? 3 : nonNameCount;
const otherColumnWidth = Math.max(48, Math.floor(remaining / divisor));
const map = { fundName: NAME_CELL_WIDTH };
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
map[id] = otherColumnWidth;
});
return map;
}
return { ...FALLBACK_WIDTHS };
}, [tableContainerWidth, mobileColumnOrder, mobileColumnVisibility]);
const handleResetMobileColumnOrder = () => {
setMobileColumnOrder([...MOBILE_NON_FROZEN_COLUMN_IDS]);
};
const handleResetMobileColumnVisibility = () => {
const allVisible = {};
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
setMobileColumnVisibility(allVisible);
};
const handleToggleMobileColumnVisibility = (columnId, visible) => {
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
};
// 移动端名称列:无拖拽把手,长按整行触发排序
const MobileFundNameCell = ({ info }) => {
const original = info.row.original || {};
const code = original.code;
const isUpdated = original.isUpdated;
const hasDca = original.hasDca;
const hasHoldingAmount = original.holdingAmountValue != null;
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
return (
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isGroupTab ? (
<button
className="icon-button fav-button"
onClick={(e) => {
e.stopPropagation?.();
onRemoveFromGroupRef.current?.(original);
}}
title="从当前分组移除"
style={{ backgroundColor: 'transparent'}}
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation?.();
onToggleFavoriteRef.current?.(original);
}}
title={isFavorites ? '取消自选' : '添加自选'}
style={{ backgroundColor: 'transparent'}}
>
<StarIcon width="18" height="18" filled={isFavorites} />
</button>
)}
<div className="title-text">
<span className="name-text" title={isUpdated ? '今日净值已更新' : ''}>
{info.getValue() ?? '—'}
</span>
{holdingAmountDisplay ? (
<span
className="muted code-text"
role="button"
tabIndex={0}
title="点击设置持仓"
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}
}}
>
{holdingAmountDisplay}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>}
</span>
) : code ? (
<span
className="muted code-text"
role="button"
tabIndex={0}
title="设置持仓"
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}
}}
>
#{code}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>}
</span>
) : null}
</div>
</div>
);
};
const columns = useMemo(
() => [
{
accessorKey: 'fundName',
header: () => (
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<span>基金名称</span>
<button
type="button"
className="icon-button"
onClick={(e) => {
e.stopPropagation?.();
setSettingModalOpen(true);
}}
title="个性化设置"
style={{
border: 'none',
width: '28px',
height: '28px',
minWidth: '28px',
backgroundColor: 'transparent',
color: 'var(--text)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<SettingsIcon width="18" height="18" />
</button>
</div>
),
cell: (info) => <MobileFundNameCell info={info} />,
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
},
{
accessorKey: 'latestNav',
header: '最新净值',
cell: (info) => (
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
</FitText>
</span>
),
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.latestNav },
},
{
accessorKey: 'estimateNav',
header: '估算净值',
cell: (info) => (
<span style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
</FitText>
</span>
),
meta: { align: 'right', cellClassName: 'value-cell', width: columnWidthMap.estimateNav },
},
{
accessorKey: 'yesterdayChangePercent',
header: '昨日涨跌幅',
cell: (info) => {
const original = info.row.original || {};
const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-';
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
</span>
<span className="muted" style={{ fontSize: '10px' }}>{date}</span>
</div>
);
},
meta: { align: 'right', cellClassName: 'change-cell', width: columnWidthMap.yesterdayChangePercent },
},
{
accessorKey: 'estimateChangePercent',
header: '估值涨跌幅',
cell: (info) => {
const original = info.row.original || {};
const value = original.estimateChangeValue;
const isMuted = original.estimateChangeMuted;
const time = original.estimateTime ?? '-';
const displayTime = typeof time === 'string' && time.length > 5 ? time.slice(5) : time;
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'}
</span>
<span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
</div>
);
},
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
},
{
accessorKey: 'todayProfit',
header: '当日收益',
cell: (info) => {
const original = info.row.original || {};
const value = original.todayProfitValue;
const hasProfit = value != null;
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? '';
return (
<div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{amountStr}
</FitText>
</span>
{percentStr ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
</FitText>
</span>
) : null}
</div>
);
},
meta: { align: 'right', cellClassName: 'profit-cell', width: columnWidthMap.todayProfit },
},
{
accessorKey: 'holdingProfit',
header: '持有收益',
cell: (info) => {
const original = info.row.original || {};
const value = original.holdingProfitValue;
const hasTotal = value != null;
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
const percentStr = original.holdingProfitPercent ?? '';
return (
<div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
<FitText maxFontSize={14} minFontSize={10}>
{amountStr}
</FitText>
</span>
{percentStr ? (
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
</FitText>
</span>
) : null}
</div>
);
},
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
},
],
[currentTab, favorites, refreshing, columnWidthMap]
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: {
columnOrder: ['fundName', ...mobileColumnOrder],
columnVisibility: { fundName: true, ...mobileColumnVisibility },
},
onColumnOrderChange: (updater) => {
const next = typeof updater === 'function' ? updater(['fundName', ...mobileColumnOrder]) : updater;
const newNonFrozen = next.filter((id) => id !== 'fundName');
if (newNonFrozen.length) {
setMobileColumnOrder(newNonFrozen);
}
},
onColumnVisibilityChange: (updater) => {
const next = typeof updater === 'function' ? updater({ fundName: true, ...mobileColumnVisibility }) : updater;
const rest = { ...next };
delete rest.fundName;
setMobileColumnVisibility(rest);
},
initialState: {
columnPinning: {
left: ['fundName'],
},
},
defaultColumn: {
cell: (info) => info.getValue() ?? '—',
},
});
const headerGroup = table.getHeaderGroups()[0];
const snapPositionsRef = useRef([]);
const scrollEndTimerRef = useRef(null);
useEffect(() => {
if (!headerGroup?.headers?.length) {
snapPositionsRef.current = [];
return;
}
const gap = 12;
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
const positions = [0];
let acc = 0;
// 从第二列开始累加,因为第一列是固定的,滚动是为了让后续列贴合到第一列右侧
// 累加的是"被滚出去"的非固定列的宽度
for (let i = 1; i < widths.length - 1; i++) {
acc += widths[i] + gap;
positions.push(acc);
}
snapPositionsRef.current = positions;
}, [headerGroup?.headers?.length, columnWidthMap, mobileColumnOrder]);
useEffect(() => {
const el = tableContainerRef.current;
if (!el || snapPositionsRef.current.length === 0) return;
const snapToNearest = () => {
const positions = snapPositionsRef.current;
if (positions.length === 0) return;
const scrollLeft = el.scrollLeft;
const maxScroll = el.scrollWidth - el.clientWidth;
if (maxScroll <= 0) return;
const nearest = positions.reduce((prev, curr) =>
Math.abs(curr - scrollLeft) < Math.abs(prev - scrollLeft) ? curr : prev
);
const clamped = Math.max(0, Math.min(maxScroll, nearest));
if (Math.abs(clamped - scrollLeft) > 2) {
el.scrollTo({ left: clamped, behavior: 'smooth' });
}
};
const handleScroll = () => {
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
scrollEndTimerRef.current = setTimeout(snapToNearest, 120);
};
el.addEventListener('scroll', handleScroll, { passive: true });
return () => {
el.removeEventListener('scroll', handleScroll);
if (scrollEndTimerRef.current) clearTimeout(scrollEndTimerRef.current);
};
}, []);
const mobileGridLayout = (() => {
if (!headerGroup?.headers?.length) return { gridTemplateColumns: '', minWidth: undefined };
const gap = 12;
const widths = headerGroup.headers.map((h) => h.column.columnDef.meta?.width ?? 80);
if (widths.length > 0) widths[widths.length - 1] += LAST_COLUMN_EXTRA;
return {
gridTemplateColumns: widths.map((w) => `${w}px`).join(' '),
minWidth: widths.reduce((a, b) => a + b, 0) + (widths.length - 1) * gap,
};
})();
const getPinClass = (columnId, isHeader) => {
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
return '';
};
const getAlignClass = (columnId) => {
if (columnId === 'fundName') return '';
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
return 'text-right';
};
return (
<div className="mobile-fund-table" ref={tableContainerRef}>
<div
className="mobile-fund-table-scroll"
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
>
{headerGroup && (
<div
className="table-header-row mobile-fund-table-header"
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
>
{headerGroup.headers.map((header, headerIndex) => {
const columnId = header.column.id;
const pinClass = getPinClass(columnId, true);
const alignClass = getAlignClass(columnId);
const isLastColumn = headerIndex === headerGroup.headers.length - 1;
return (
<div
key={header.id}
className={`table-header-cell ${alignClass} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
})}
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={data.map((item) => item.code)}
strategy={verticalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => (
<SortableRow
key={row.original.code || row.id}
row={row}
isTableDragging={!!activeId}
disabled={sortBy !== 'default'}
>
{(setActivatorNodeRef, listeners) => (
<div
ref={sortBy === 'default' ? setActivatorNodeRef : undefined}
className="table-row"
style={{
background: 'var(--bg)',
position: 'relative',
zIndex: 1,
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}),
}}
{...(sortBy === 'default' ? listeners : {})}
>
{row.getVisibleCells().map((cell, cellIndex) => {
const columnId = cell.column.id;
const pinClass = getPinClass(columnId, false);
const alignClass = getAlignClass(columnId);
const cellClassName = cell.column.columnDef.meta?.cellClassName || '';
const isLastColumn = cellIndex === row.getVisibleCells().length - 1;
return (
<div
key={cell.id}
className={`table-cell ${alignClass} ${cellClassName} ${pinClass}`}
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
)}
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</div>
{table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div>
)}
<MobileSettingModal
open={settingModalOpen}
onClose={() => setSettingModalOpen(false)}
columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
columnVisibility={mobileColumnVisibility}
onColumnReorder={(newOrder) => {
setMobileColumnOrder(newOrder);
}}
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
onResetColumnOrder={handleResetMobileColumnOrder}
onResetColumnVisibility={handleResetMobileColumnVisibility}
/>
</div>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion';
import { createPortal } from 'react-dom';
import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
/**
* 移动端表格个性化设置弹框(底部抽屉)
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列id + 表头名称)
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射id => 是否显示)
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
*/
export default function MobileSettingModal({
open,
onClose,
columns = [],
columnVisibility,
onColumnReorder,
onToggleColumnVisibility,
onResetColumnOrder,
onResetColumnVisibility,
}) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
useEffect(() => {
if (!open) setResetConfirmOpen(false);
}, [open]);
useEffect(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}
}, [open]);
const handleReorder = (newItems) => {
const newOrder = newItems.map((item) => item.id);
onColumnReorder?.(newOrder);
};
const content = (
<AnimatePresence>
{open && (
<motion.div
key="mobile-setting-overlay"
className="mobile-setting-overlay"
role="dialog"
aria-modal="true"
aria-label="个性化设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{ zIndex: 10001 }}
>
<motion.div
className="mobile-setting-drawer glass"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-setting-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>个性化设置</span>
</div>
<button
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="mobile-setting-body">
<h3 className="mobile-setting-subtitle">表头设置</h3>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
gap: 8,
}}
>
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
拖拽调整列顺序
</p>
{(onResetColumnOrder || onResetColumnVisibility) && (
<button
className="icon-button"
onClick={() => setResetConfirmOpen(true)}
title="重置表头设置"
style={{
border: 'none',
width: '28px',
height: '28px',
backgroundColor: 'transparent',
color: 'var(--muted)',
flexShrink: 0,
}}
>
<ResetIcon width="16" height="16" />
</button>
)}
</div>
{columns.length === 0 ? (
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
暂无可配置列
</div>
) : (
<Reorder.Group
axis="y"
values={columns}
onReorder={handleReorder}
className="mobile-setting-list"
>
<AnimatePresence mode="popLayout">
{columns.map((item, index) => (
<Reorder.Item
key={item.id || `col-${index}`}
value={item}
className="mobile-setting-item glass"
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 35,
mass: 1,
layout: { duration: 0.2 },
}}
>
<div
className="drag-handle"
style={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
color: 'var(--muted)',
}}
>
<DragIcon width="18" height="18" />
</div>
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
{onToggleColumnVisibility && (
<button
type="button"
className="icon-button pc-table-column-switch"
onClick={(e) => {
e.stopPropagation();
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
}}
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
style={{
border: 'none',
padding: '0 4px',
backgroundColor: 'transparent',
cursor: 'pointer',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
}}
>
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
<span
className="dca-toggle-thumb"
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
/>
</span>
</button>
)}
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
)}
</div>
</motion.div>
</motion.div>
)}
{resetConfirmOpen && (
<ConfirmModal
key="mobile-reset-confirm"
title="重置表头设置"
message="是否重置表头顺序和显示/隐藏为默认值?"
onConfirm={() => {
onResetColumnOrder?.();
onResetColumnVisibility?.();
setResetConfirmOpen(false);
}}
onCancel={() => setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
</AnimatePresence>
);
if (typeof document === 'undefined') return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,957 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ConfirmModal from './ConfirmModal';
import FitText from './FitText';
import PcTableSettingModal from './PcTableSettingModal';
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
const NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent',
'estimateChangePercent',
'holdingAmount',
'todayProfit',
'holdingProfit',
'latestNav',
'estimateNav',
];
const COLUMN_HEADERS = {
latestNav: '最新净值',
estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨跌幅',
estimateChangePercent: '估值涨跌幅',
holdingAmount: '持仓金额',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
const SortableRowContext = createContext({
setActivatorNodeRef: null,
listeners: null,
});
function SortableRow({ row, children, isTableDragging, disabled }) {
const {
attributes,
listeners,
transform,
transition,
setNodeRef,
setActivatorNodeRef,
isDragging,
} = useSortable({ id: row.original.code, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999, opacity: 0.8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' } : {}),
};
const contextValue = useMemo(
() => ({ setActivatorNodeRef, listeners }),
[setActivatorNodeRef, listeners]
);
return (
<SortableRowContext.Provider value={contextValue}>
<motion.div
ref={setNodeRef}
className="table-row-wrapper"
layout={isTableDragging ? undefined : "position"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
style={{ ...style, position: 'relative' }}
{...attributes}
>
{children}
</motion.div>
</SortableRowContext.Provider>
);
}
/**
* PC 端基金列表表格组件(基于 @tanstack/react-table
*
* @param {Object} props
* @param {Array<Object>} props.data - 表格数据
* 每一行推荐结构(字段命名与 page.jsx 中的数据一致):
* {
* fundName: string; // 基金名称
* code?: string; // 基金代码(可选,只用于展示在名称下方)
* latestNav: string|number; // 最新净值
* estimateNav: string|number; // 估算净值
* yesterdayChangePercent: string|number; // 昨日涨跌幅
* estimateChangePercent: string|number; // 估值涨跌幅
* holdingAmount: string|number; // 持仓金额
* todayProfit: string|number; // 当日收益
* holdingProfit: string|number; // 持有收益
* }
* @param {(row: any) => void} [props.onRemoveFund] - 删除基金的回调
* @param {string} [props.currentTab] - 当前分组
* @param {Set<string>} [props.favorites] - 自选集合
* @param {(row: any) => void} [props.onToggleFavorite] - 添加/取消自选
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
*/
export default function PcFundTable({
data = [],
onRemoveFund,
currentTab,
favorites = new Set(),
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
onHoldingProfitClick, // 保留以兼容调用方,表格内已不再使用点击切换
refreshing = false,
sortBy = 'default',
onReorder,
onCustomSettingsChange,
}) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
useSensor(KeyboardSensor)
);
const [activeId, setActiveId] = useState(null);
const handleDragStart = (event) => {
setActiveId(event.active.id);
};
const handleDragCancel = () => {
setActiveId(null);
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
const oldIndex = data.findIndex(item => item.code === active.id);
const newIndex = data.findIndex(item => item.code === over.id);
if (oldIndex !== -1 && newIndex !== -1 && onReorder) {
onReorder(oldIndex, newIndex);
}
}
setActiveId(null);
};
const groupKey = currentTab ?? 'all';
const getCustomSettingsWithMigration = () => {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== 'object') return {};
if (parsed.pcTableColumnOrder != null || parsed.pcTableColumnVisibility != null || parsed.pcTableColumns != null || parsed.mobileTableColumnOrder != null || parsed.mobileTableColumnVisibility != null) {
const all = {
...(parsed.all && typeof parsed.all === 'object' ? parsed.all : {}),
pcTableColumnOrder: parsed.pcTableColumnOrder,
pcTableColumnVisibility: parsed.pcTableColumnVisibility,
pcTableColumns: parsed.pcTableColumns,
mobileTableColumnOrder: parsed.mobileTableColumnOrder,
mobileTableColumnVisibility: parsed.mobileTableColumnVisibility,
};
delete parsed.pcTableColumnOrder;
delete parsed.pcTableColumnVisibility;
delete parsed.pcTableColumns;
delete parsed.mobileTableColumnOrder;
delete parsed.mobileTableColumnVisibility;
parsed.all = all;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
}
return parsed;
} catch {
return {};
}
};
const buildPcConfigFromGroup = (group) => {
if (!group || typeof group !== 'object') return null;
const sizing = group.pcTableColumns;
const sizingObj = sizing && typeof sizing === 'object'
? Object.fromEntries(Object.entries(sizing).filter(([, v]) => Number.isFinite(v)))
: {};
if (sizingObj.actions) {
const { actions, ...rest } = sizingObj;
Object.assign(sizingObj, rest);
delete sizingObj.actions;
}
const order = Array.isArray(group.pcTableColumnOrder) && group.pcTableColumnOrder.length > 0
? group.pcTableColumnOrder
: null;
const visibility = group.pcTableColumnVisibility && typeof group.pcTableColumnVisibility === 'object'
? group.pcTableColumnVisibility
: null;
return { sizing: sizingObj, order, visibility };
};
const getDefaultPcGroupConfig = () => ({
order: [...NON_FROZEN_COLUMN_IDS],
visibility: null,
sizing: {},
});
const getInitialConfigByGroup = () => {
const parsed = getCustomSettingsWithMigration();
const byGroup = {};
Object.keys(parsed).forEach((k) => {
if (k === 'pcContainerWidth') return;
const group = parsed[k];
const pc = buildPcConfigFromGroup(group);
if (pc) {
byGroup[k] = {
pcTableColumnOrder: pc.order ? (() => {
const valid = pc.order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
})() : null,
pcTableColumnVisibility: pc.visibility,
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
};
}
});
return byGroup;
};
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
const currentGroupPc = configByGroup[groupKey];
const defaultPc = getDefaultPcGroupConfig();
const columnOrder = (() => {
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
if (!Array.isArray(order) || order.length === 0) return [...NON_FROZEN_COLUMN_IDS];
const valid = order.filter((id) => NON_FROZEN_COLUMN_IDS.includes(id));
const missing = NON_FROZEN_COLUMN_IDS.filter((id) => !valid.includes(id));
return [...valid, ...missing];
})();
const columnVisibility = (() => {
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
return allVisible;
})();
const columnSizing = (() => {
const s = currentGroupPc?.pcTableColumns;
if (s && typeof s === 'object') {
const out = Object.fromEntries(Object.entries(s).filter(([, v]) => Number.isFinite(v)));
if (out.actions) {
const { actions, ...rest } = out;
return rest;
}
return out;
}
return {};
})();
const persistPcGroupConfig = (updates) => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const group = parsed[groupKey] && typeof parsed[groupKey] === 'object' ? { ...parsed[groupKey] } : {};
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
parsed[groupKey] = group;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
onCustomSettingsChange?.();
} catch { }
};
const setColumnOrder = (nextOrderOrUpdater) => {
const next = typeof nextOrderOrUpdater === 'function'
? nextOrderOrUpdater(columnOrder)
: nextOrderOrUpdater;
persistPcGroupConfig({ pcTableColumnOrder: next });
};
const setColumnVisibility = (nextOrUpdater) => {
const next = typeof nextOrUpdater === 'function'
? nextOrUpdater(columnVisibility)
: nextOrUpdater;
persistPcGroupConfig({ pcTableColumnVisibility: next });
};
const setColumnSizing = (nextOrUpdater) => {
const next = typeof nextOrUpdater === 'function'
? nextOrUpdater(columnSizing)
: nextOrUpdater;
const { actions, ...rest } = next || {};
persistPcGroupConfig({ pcTableColumns: rest || {} });
};
const [settingModalOpen, setSettingModalOpen] = useState(false);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const handleResetSizing = () => {
setColumnSizing({});
setResetConfirmOpen(false);
};
const handleResetColumnOrder = () => {
setColumnOrder([...NON_FROZEN_COLUMN_IDS]);
};
const handleResetColumnVisibility = () => {
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => {
allVisible[id] = true;
});
setColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
setColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
};
const onRemoveFundRef = useRef(onRemoveFund);
const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
useEffect(() => {
onRemoveFundRef.current = onRemoveFund;
onToggleFavoriteRef.current = onToggleFavorite;
onRemoveFromGroupRef.current = onRemoveFromGroup;
onHoldingAmountClickRef.current = onHoldingAmountClick;
}, [
onRemoveFund,
onToggleFavorite,
onRemoveFromGroup,
onHoldingAmountClick,
]);
const FundNameCell = ({ info }) => {
const original = info.row.original || {};
const code = original.code;
const isUpdated = original.isUpdated;
const hasDca = original.hasDca;
const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
const rowContext = useContext(SortableRowContext);
return (
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{sortBy === 'default' && (
<button
className="icon-button drag-handle"
ref={rowContext?.setActivatorNodeRef}
{...rowContext?.listeners}
style={{ cursor: 'grab', padding: 2, margin: '-2px -4px -2px 0', color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="拖拽排序"
onClick={(e) => e.stopPropagation?.()}
>
<DragIcon width="16" height="16" />
</button>
)}
{isGroupTab ? (
<button
className="icon-button fav-button"
onClick={(e) => {
e.stopPropagation?.();
onRemoveFromGroupRef.current?.(original);
}}
title="从小分组移除"
style={{ backgroundColor: 'transparent'}}
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button
className={`icon-button fav-button ${isFavorites ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation?.();
onToggleFavoriteRef.current?.(original);
}}
title={isFavorites ? '取消自选' : '添加自选'}
>
<StarIcon width="18" height="18" filled={isFavorites} />
</button>
)}
<div className="title-text">
<span
className={`name-text`}
title={isUpdated ? '今日净值已更新' : ''}
>
{info.getValue() ?? '—'}
</span>
{code ? <span className="muted code-text">
#{code}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>}
</span> : null}
</div>
</div>
);
};
const columns = useMemo(
() => [
{
accessorKey: 'fundName',
header: '基金名称',
size: 265,
minSize: 140,
enablePinning: true,
cell: (info) => <FundNameCell info={info} />,
meta: {
align: 'left',
cellClassName: 'name-cell',
},
},
{
accessorKey: 'latestNav',
header: '最新净值',
size: 100,
minSize: 80,
cell: (info) => (
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
</FitText>
),
meta: {
align: 'right',
cellClassName: 'value-cell',
},
},
{
accessorKey: 'estimateNav',
header: '估算净值',
size: 100,
minSize: 80,
cell: (info) => (
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
</FitText>
),
meta: {
align: 'right',
cellClassName: 'value-cell',
},
},
{
accessorKey: 'yesterdayChangePercent',
header: '昨日涨跌幅',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
const value = original.yesterdayChangeValue;
const date = original.yesterdayDate ?? '-';
const cls = value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'}
</FitText>
<span className="muted" style={{ fontSize: '11px' }}>
{date}
</span>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'change-cell',
},
},
{
accessorKey: 'estimateChangePercent',
header: '估值涨跌幅',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
const value = original.estimateChangeValue;
const isMuted = original.estimateChangeMuted;
const time = original.estimateTime ?? '-';
const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<FitText className={cls} style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10} as="div">
{info.getValue() ?? '—'}
</FitText>
<span className="muted" style={{ fontSize: '11px' }}>
{time}
</span>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'est-change-cell',
},
},
{
accessorKey: 'holdingAmount',
header: '持仓金额',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
if (original.holdingAmountValue == null) {
return (
<div
role="button"
tabIndex={0}
className="muted"
title="设置持仓"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '12px', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHoldingAmountClickRef.current?.(original, { hasHolding: false });
}
}}
>
未设置 <SettingsIcon width="12" height="12" />
</div>
);
}
return (
<div
title="点击设置持仓"
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', width: '100%', minWidth: 0 }}
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
>
<div style={{ flex: '1 1 0', minWidth: 0 }}>
<FitText style={{ fontWeight: 700 }} maxFontSize={14} minFontSize={10}>
{info.getValue() ?? '—'}
</FitText>
</div>
<button
className="icon-button no-hover"
onClick={(e) => {
e.stopPropagation?.();
onHoldingAmountClickRef.current?.(original, { hasHolding: true });
}}
title="编辑持仓"
style={{ border: 'none', width: '28px', height: '28px', marginLeft: 4, flexShrink: 0, backgroundColor: 'transparent' }}
>
<SettingsIcon width="14" height="14" />
</button>
</div>
);
},
meta: {
align: 'right',
cellClassName: 'holding-amount-cell',
},
},
{
accessorKey: 'todayProfit',
header: '当日收益',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
const value = original.todayProfitValue;
const hasProfit = value != null;
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? '';
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
</FitText>
{percentStr ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
</FitText>
</span>
) : null}
</div>
);
},
meta: {
align: 'right',
cellClassName: 'profit-cell',
},
},
{
accessorKey: 'holdingProfit',
header: '持有收益',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
const value = original.holdingProfitValue;
const hasTotal = value != null;
const cls = hasTotal ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasTotal ? (info.getValue() ?? '') : '—';
const percentStr = original.holdingProfitPercent ?? '';
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
</FitText>
{percentStr ? (
<span className={`${cls} holding-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}>
{percentStr}
</FitText>
</span>
) : null}
</div>
);
},
meta: {
align: 'right',
cellClassName: 'holding-cell',
},
},
{
id: 'actions',
header: () => (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span>操作</span>
<button
className="icon-button"
onClick={(e) => {
e.stopPropagation?.();
setSettingModalOpen(true);
}}
title="个性化设置"
style={{ border: 'none', width: '24px', height: '24px', backgroundColor: 'transparent', color: 'var(--text)' }}
>
<SettingsIcon width="14" height="14" />
</button>
</div>
),
size: 80,
minSize: 80,
maxSize: 80,
enableResizing: false,
enablePinning: true,
meta: {
align: 'center',
isAction: true,
cellClassName: 'action-cell',
},
cell: (info) => {
const original = info.row.original || {};
const handleClick = (e) => {
e.stopPropagation?.();
if (refreshing) return;
onRemoveFundRef.current?.(original);
};
return (
<div className="row" style={{ justifyContent: 'center', gap: 4 }}>
<button
className="icon-button danger"
onClick={handleClick}
title="删除"
disabled={refreshing}
style={{
width: '28px',
height: '28px',
opacity: refreshing ? 0.6 : 1,
cursor: refreshing ? 'not-allowed' : 'pointer',
}}
>
<TrashIcon width="14" height="14" />
</button>
</div>
);
},
},
],
[currentTab, favorites, refreshing, sortBy],
);
const table = useReactTable({
data,
columns,
enableColumnPinning: true,
enableColumnResizing: true,
columnResizeMode: 'onChange',
onColumnSizingChange: (updater) => {
setColumnSizing((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
const { actions, ...rest } = next || {};
return rest || {};
});
},
state: {
columnSizing,
columnOrder,
columnVisibility,
},
onColumnOrderChange: (updater) => {
setColumnOrder(updater);
},
onColumnVisibilityChange: (updater) => {
setColumnVisibility(updater);
},
initialState: {
columnPinning: {
left: ['fundName'],
right: ['actions'],
},
},
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
cell: (info) => info.getValue() ?? '—',
},
});
const headerGroup = table.getHeaderGroups()[0];
const getCommonPinningStyles = (column, isHeader) => {
const isPinned = column.getIsPinned();
const isNameColumn =
column.id === 'fundName' || column.columnDef?.accessorKey === 'fundName';
const style = {
width: `${column.getSize()}px`,
};
if (!isPinned) return style;
const isLeft = isPinned === 'left';
const isRight = isPinned === 'right';
return {
...style,
position: 'sticky',
left: isLeft ? `${column.getStart('left')}px` : undefined,
right: isRight ? `${column.getAfter('right')}px` : undefined,
zIndex: isHeader ? 11 : 10,
backgroundColor: isHeader ? 'var(--table-pinned-header-bg)' : 'var(--row-bg)',
boxShadow: 'none',
textAlign: isNameColumn ? 'left' : 'center',
justifyContent: isNameColumn ? 'flex-start' : 'center',
};
};
return (
<div className="pc-fund-table">
<style>{`
.table-row-scroll {
--row-bg: var(--bg);
background-color: var(--row-bg);
}
.table-row-scroll:hover {
--row-bg: var(--table-row-hover-bg);
}
/* 覆盖 grid 布局为 flex 以支持动态列宽 */
.table-header-row-scroll,
.table-row-scroll {
display: flex !important;
width: fit-content !important;
min-width: 100%;
gap: 0 !important; /* Reset gap because we control width explicitly */
}
.table-header-cell,
.table-cell {
flex-shrink: 0;
box-sizing: border-box;
padding-left: 8px;
padding-right: 8px;
position: relative; /* For resizer */
}
/* 拖拽把手样式 */
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 8px;
background: transparent;
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 20;
}
.resizer::after {
content: '';
position: absolute;
right: 3px;
top: 12%;
bottom: 12%;
width: 2px;
background: var(--border);
opacity: 0.35;
transition: opacity 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.resizer:hover::after {
opacity: 1;
background: var(--primary);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2);
}
.table-header-cell:hover .resizer::after {
opacity: 0.75;
}
.resizer.disabled {
cursor: default;
background: transparent;
pointer-events: none;
}
.resizer.disabled::after {
opacity: 0;
}
`}</style>
{/* 表头 */}
{headerGroup && (
<div className="table-header-row table-header-row-scroll">
{headerGroup.headers.map((header) => {
const style = getCommonPinningStyles(header.column, true);
const isNameColumn =
header.column.id === 'fundName' ||
header.column.columnDef?.accessorKey === 'fundName';
const align = isNameColumn ? '' : 'text-center';
return (
<div
key={header.id}
className={`table-header-cell ${align}`}
style={style}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<div
onMouseDown={header.column.getCanResize() ? header.getResizeHandler() : undefined}
onTouchStart={header.column.getCanResize() ? header.getResizeHandler() : undefined}
className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''
} ${header.column.getCanResize() ? '' : 'disabled'}`}
/>
</div>
);
})}
</div>
)}
{/* 表体 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={data.map((item) => item.code)}
strategy={verticalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{table.getRowModel().rows.map((row) => (
<SortableRow key={row.original.code || row.id} row={row} isTableDragging={!!activeId} disabled={sortBy !== 'default'}>
<div
className="table-row table-row-scroll"
>
{row.getVisibleCells().map((cell) => {
const columnId = cell.column.id || cell.column.columnDef?.accessorKey;
const isNameColumn = columnId === 'fundName';
const rightAlignedColumns = new Set([
'latestNav',
'estimateNav',
'yesterdayChangePercent',
'estimateChangePercent',
'holdingAmount',
'todayProfit',
'holdingProfit',
]);
const align = isNameColumn
? ''
: rightAlignedColumns.has(columnId)
? 'text-right'
: 'text-center';
const cellClassName =
(cell.column.columnDef.meta && cell.column.columnDef.meta.cellClassName) || '';
const style = getCommonPinningStyles(cell.column, false);
return (
<div
key={cell.id}
className={`table-cell ${align} ${cellClassName}`}
style={style}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
</div>
</SortableRow>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
{table.getRowModel().rows.length === 0 && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div>
)}
{resetConfirmOpen && (
<ConfirmModal
title="重置列宽"
message="是否重置表格列宽为默认值?"
onConfirm={handleResetSizing}
onCancel={() => setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
<PcTableSettingModal
open={settingModalOpen}
onClose={() => setSettingModalOpen(false)}
columns={columnOrder.map((id) => ({ id, header: COLUMN_HEADERS[id] ?? id }))}
onColumnReorder={(newOrder) => {
setColumnOrder(newOrder);
}}
columnVisibility={columnVisibility}
onToggleColumnVisibility={handleToggleColumnVisibility}
onResetColumnOrder={handleResetColumnOrder}
onResetColumnVisibility={handleResetColumnVisibility}
onResetSizing={() => setResetConfirmOpen(true)}
/>
</div>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion';
import { createPortal } from 'react-dom';
import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
/**
* PC 表格个性化设置侧弹框
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调
* @param {Array<{id: string, header: string}>} props.columns - 非冻结列id + 表头名称)
* @param {Record<string, boolean>} [props.columnVisibility] - 列显示状态映射id => 是否显示)
* @param {(newOrder: string[]) => void} props.onColumnReorder - 列顺序变更回调,参数为新的列 id 顺序
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
*/
export default function PcTableSettingModal({
open,
onClose,
columns = [],
columnVisibility,
onColumnReorder,
onToggleColumnVisibility,
onResetColumnOrder,
onResetColumnVisibility,
onResetSizing,
}) {
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
useEffect(() => {
if (!open) setResetOrderConfirmOpen(false);
}, [open]);
useEffect(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}
}, [open]);
const handleReorder = (newItems) => {
const newOrder = newItems.map((item) => item.id);
onColumnReorder?.(newOrder);
};
const content = (
<AnimatePresence>
{open && (
<motion.div
key="drawer"
className="pc-table-setting-overlay"
role="dialog"
aria-modal="true"
aria-label="个性化设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{ zIndex: 10001 }}
>
<motion.aside
className="pc-table-setting-drawer glass"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
<div className="pc-table-setting-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>个性化设置</span>
</div>
<button
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="pc-table-setting-body">
<h3 className="pc-table-setting-subtitle">表头设置</h3>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
gap: 8,
}}
>
<p className="muted" style={{ fontSize: '13px', margin: 0 }}>
拖拽调整列顺序
</p>
{onResetColumnOrder && (
<button
className="icon-button"
onClick={() => setResetOrderConfirmOpen(true)}
title="重置列顺序"
style={{
border: 'none',
width: '28px',
height: '28px',
backgroundColor: 'transparent',
color: 'var(--muted)',
flexShrink: 0,
}}
>
<ResetIcon width="16" height="16" />
</button>
)}
</div>
{columns.length === 0 ? (
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
暂无可配置列
</div>
) : (
<Reorder.Group
axis="y"
values={columns}
onReorder={handleReorder}
className="pc-table-setting-list"
>
<AnimatePresence mode="popLayout">
{columns.map((item, index) => (
<Reorder.Item
key={item.id || `col-${index}`}
value={item}
className="pc-table-setting-item glass"
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 35,
mass: 1,
layout: { duration: 0.2 },
}}
>
<div
className="drag-handle"
style={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
color: 'var(--muted)',
}}
>
<DragIcon width="18" height="18" />
</div>
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
{onToggleColumnVisibility && (
<button
type="button"
className="icon-button pc-table-column-switch"
onClick={(e) => {
e.stopPropagation();
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
}}
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
style={{
border: 'none',
padding: '0 4px',
backgroundColor: 'transparent',
cursor: 'pointer',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
}}
>
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
<span
className="dca-toggle-thumb"
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
/>
</span>
</button>
)}
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
)}
{onResetSizing && (
<button
className="button secondary"
onClick={() => {
onResetSizing();
}}
style={{
width: '100%',
marginTop: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
<ResetIcon width="16" height="16" />
重置列宽
</button>
)}
</div>
</motion.aside>
</motion.div>
)}
{resetOrderConfirmOpen && (
<ConfirmModal
key="reset-order-confirm"
title="重置表头设置"
message="是否重置表头顺序和显示/隐藏为默认值?"
onConfirm={() => {
onResetColumnOrder?.();
onResetColumnVisibility?.();
setResetOrderConfirmOpen(false);
}}
onCancel={() => setResetOrderConfirmOpen(false)}
confirmText="重置"
/>
)}
</AnimatePresence>
);
if (typeof document === 'undefined') return null;
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect, useState } from "react";
import { RefreshIcon } from './Icons';
export default function RefreshButton({ refreshCycleStartRef, refreshMs, manualRefresh, refreshing, fundsLength }) {
// 刷新周期进度 0~1用于环形进度条
const [refreshProgress, setRefreshProgress] = useState(0);
// 刷新进度条:每 100ms 更新一次进度
useEffect(() => {
if (fundsLength === 0 || refreshMs <= 0) return;
const t = setInterval(() => {
const elapsed = Date.now() - refreshCycleStartRef.current;
const p = Math.min(1, elapsed / refreshMs);
setRefreshProgress(p);
}, 100);
return () => clearInterval(t);
}, [fundsLength, refreshMs]);
return (
<div
className="refresh-btn-wrap"
style={{ '--progress': refreshProgress }}
title={`刷新周期 ${Math.round(refreshMs / 1000)}`}
>
<button
className="icon-button"
aria-label="立即刷新"
onClick={manualRefresh}
disabled={refreshing || fundsLength === 0}
aria-busy={refreshing}
title="立即刷新"
>
<RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" />
</button>
</div>
);
}

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { CloseIcon } from './Icons'; import { CloseIcon } from './Icons';
@@ -9,8 +10,23 @@ export default function ScanImportConfirmModal({
onClose, onClose,
onToggle, onToggle,
onConfirm, onConfirm,
refreshing refreshing,
groups = [],
isOcrScan = false
}) { }) {
const [selectedGroupId, setSelectedGroupId] = useState('all');
const handleConfirm = () => {
onConfirm(selectedGroupId);
};
const formatAmount = (val) => {
if (!val) return null;
const num = parseFloat(String(val).replace(/,/g, ''));
if (isNaN(num)) return null;
return num;
};
return ( return (
<motion.div <motion.div
className="modal-overlay" className="modal-overlay"
@@ -28,7 +44,7 @@ export default function ScanImportConfirmModal({
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ width: 460, maxWidth: '90vw' }} style={{ width: 480, maxWidth: '90vw' }}
> >
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}> <div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<span>确认导入基金</span> <span>确认导入基金</span>
@@ -36,18 +52,27 @@ export default function ScanImportConfirmModal({
<CloseIcon width="20" height="20" /> <CloseIcon width="20" height="20" />
</button> </button>
</div> </div>
{isOcrScan && (
<div className="ocr-warning" style={{ marginBottom: 12 }}>
<span>拍照识别方案目前还在优化请确认识别结果是否正确</span>
</div>
)}
{scannedFunds.length === 0 ? ( {scannedFunds.length === 0 ? (
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}> <div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
未识别到有效的基金代码请尝试更清晰的截图或手动搜索 未识别到有效的基金代码请尝试更清晰的截图或手动搜索
</div> </div>
) : ( ) : (
<div className="search-results pending-list" style={{ maxHeight: 320, overflowY: 'auto' }}> <>
<div className="search-results pending-list" style={{ maxHeight: 360, overflowY: 'auto' }}>
{scannedFunds.map((item) => { {scannedFunds.map((item) => {
const isSelected = selectedScannedCodes.has(item.code); const isSelected = selectedScannedCodes.has(item.code);
const isAlreadyAdded = item.status === 'added'; const isAlreadyAdded = item.status === 'added';
const isInvalid = item.status === 'invalid'; const isInvalid = item.status === 'invalid';
const isDisabled = isAlreadyAdded || isInvalid; const isDisabled = isAlreadyAdded || isInvalid;
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金'); const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
const holdAmounts = formatAmount(item.holdAmounts);
const holdGains = formatAmount(item.holdGains);
const hasHoldingData = holdAmounts !== null && holdGains !== null;
return ( return (
<div <div
key={item.code} key={item.code}
@@ -57,8 +82,9 @@ export default function ScanImportConfirmModal({
if (isDisabled) return; if (isDisabled) return;
onToggle(item.code); onToggle(item.code);
}} }}
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }} style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div className="fund-info"> <div className="fund-info">
<span className="fund-name">{displayName}</span> <span className="fund-name">{displayName}</span>
<span className="fund-code muted">#{item.code}</span> <span className="fund-code muted">#{item.code}</span>
@@ -73,13 +99,46 @@ export default function ScanImportConfirmModal({
</div> </div>
)} )}
</div> </div>
{hasHoldingData && !isDisabled && (
<div style={{ display: 'flex', gap: 16, marginTop: 6, paddingLeft: 0 }}>
{holdAmounts !== null && (
<span className="muted" style={{ fontSize: 12 }}>
持有金额<span style={{ color: 'var(--text)', fontWeight: 500 }}>¥{holdAmounts.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</span>
)}
{holdGains !== null && (
<span className="muted" style={{ fontSize: 12 }}>
持有收益<span style={{ color: holdGains >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight: 500 }}>
{holdGains >= 0 ? '+' : '-'}¥{Math.abs(holdGains).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</span>
)}
</div>
)}
</div>
); );
})} })}
</div> </div>
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组</span>
<select
className="select"
value={selectedGroupId}
onChange={(e) => setSelectedGroupId(e.target.value)}
style={{ flex: 1 }}
>
<option value="all">全部</option>
<option value="fav">自选</option>
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
</>
)} )}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
<button className="button secondary" onClick={onClose}>取消</button> <button className="button secondary" onClick={onClose}>取消</button>
<button className="button" onClick={onConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button> <button className="button" onClick={handleConfirm} disabled={selectedScannedCodes.size === 0 || refreshing}>确认导入</button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>

View File

@@ -40,10 +40,6 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
marginBottom: 12, marginBottom: 12,
padding: '20px 16px', padding: '20px 16px',
borderRadius: 12, borderRadius: 12,
border: `2px dashed ${isDragging ? 'var(--primary)' : 'var(--border)'}`,
background: isDragging
? 'rgba(34, 211, 238, 0.08)'
: 'rgba(255, 255, 255, 0.02)',
transition: 'border-color 0.2s ease, background 0.2s ease', transition: 'border-color 0.2s ease, background 0.2s ease',
cursor: isScanning ? 'not-allowed' : 'pointer', cursor: isScanning ? 'not-allowed' : 'pointer',
pointerEvents: isScanning ? 'none' : 'auto', pointerEvents: isScanning ? 'none' : 'auto',
@@ -64,7 +60,7 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal scan-pick-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ width: 420, maxWidth: '90vw' }} style={{ width: 420, maxWidth: '90vw' }}
> >
@@ -75,7 +71,7 @@ export default function ScanPickModal({ onClose, onPick, onFilesDrop, isScanning
从相册选择一张或多张持仓截图系统将自动识别其中的<span style={{ color: 'var(--primary)' }}>基金代码6位数字</span>并支持批量导入 从相册选择一张或多张持仓截图系统将自动识别其中的<span style={{ color: 'var(--primary)' }}>基金代码6位数字</span>并支持批量导入
</div> </div>
<div <div
className="muted" className={`scan-pick-dropzone muted ${isDragging ? 'dragging' : ''}`}
style={dropZoneStyle} style={dropZoneStyle}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { SettingsIcon } from './Icons'; import { useEffect, useState } from 'react';
import ConfirmModal from './ConfirmModal';
import { ResetIcon, SettingsIcon } from './Icons';
export default function SettingsModal({ export default function SettingsModal({
onClose, onClose,
@@ -10,21 +12,44 @@ export default function SettingsModal({
exportLocalData, exportLocalData,
importFileRef, importFileRef,
handleImportFileChange, handleImportFileChange,
importMsg importMsg,
isMobile,
containerWidth = 1200,
setContainerWidth,
onResetContainerWidth,
}) { }) {
const [sliderDragging, setSliderDragging] = useState(false);
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
useEffect(() => {
if (!sliderDragging) return;
const onPointerUp = () => setSliderDragging(false);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
return () => {
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
};
}, [sliderDragging]);
return ( return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}> <div
className={`modal-overlay ${sliderDragging ? 'modal-overlay-translucent' : ''}`}
role="dialog"
aria-modal="true"
aria-label="设置"
onClick={onClose}
>
<div className="glass card modal" onClick={(e) => e.stopPropagation()}> <div className="glass card modal" onClick={(e) => e.stopPropagation()}>
<div className="title" style={{ marginBottom: 12 }}> <div className="title" style={{ marginBottom: 12 }}>
<SettingsIcon width="20" height="20" /> <SettingsIcon width="20" height="20" />
<span>设置</span> <span>设置</span>
<span className="muted">配置刷新频率</span>
</div> </div>
<div className="form-group" style={{ marginBottom: 16 }}> <div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div> <div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
<div className="chips" style={{ marginBottom: 12 }}> <div className="chips" style={{ marginBottom: 12 }}>
{[10, 30, 60, 120, 300].map((s) => ( {[30, 60, 120, 300].map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
@@ -40,19 +65,65 @@ export default function SettingsModal({
className="input" className="input"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min="10" min="30"
step="5" step="5"
value={tempSeconds} value={tempSeconds}
onChange={(e) => setTempSeconds(Number(e.target.value))} onChange={(e) => setTempSeconds(Number(e.target.value))}
placeholder="自定义秒数" placeholder="自定义秒数"
/> />
{tempSeconds < 10 && ( {tempSeconds < 30 && (
<div className="error-text" style={{ marginTop: 8 }}> <div className="error-text" style={{ marginTop: 8 }}>
最小 10 最小 30
</div> </div>
)} )}
</div> </div>
{!isMobile && setContainerWidth && (
<div className="form-group" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<div className="muted" style={{ fontSize: '0.8rem' }}>页面宽度</div>
{onResetContainerWidth && (
<button
type="button"
className="icon-button"
onClick={() => setResetWidthConfirmOpen(true)}
title="重置页面宽度"
style={{
border: 'none',
width: '24px',
height: '24px',
padding: 0,
backgroundColor: 'transparent',
color: 'var(--muted)',
}}
>
<ResetIcon width="14" height="14" />
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="range"
min={600}
max={2000}
step={10}
value={Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}
onChange={(e) => setContainerWidth(Number(e.target.value))}
onPointerDown={() => setSliderDragging(true)}
className="page-width-slider"
style={{
flex: 1,
height: 6,
accentColor: 'var(--primary)',
}}
/>
<span className="muted" style={{ fontSize: '0.8rem', minWidth: 48 }}>
{Math.min(2000, Math.max(600, Number(containerWidth) || 1200))}px
</span>
</div>
</div>
)}
<div className="form-group" style={{ marginBottom: 16 }}> <div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div> <div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
<div className="row" style={{ gap: 8 }}> <div className="row" style={{ gap: 8 }}>
@@ -77,9 +148,21 @@ export default function SettingsModal({
</div> </div>
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}> <div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
<button className="button" onClick={saveSettings} disabled={tempSeconds < 10}>保存并关闭</button> <button className="button" onClick={saveSettings} disabled={tempSeconds < 30}>保存并关闭</button>
</div> </div>
</div> </div>
{resetWidthConfirmOpen && onResetContainerWidth && (
<ConfirmModal
title="重置页面宽度"
message="是否重置页面宽度为默认值 1200px"
onConfirm={() => {
onResetContainerWidth();
setResetWidthConfirmOpen(false);
}}
onCancel={() => setResetWidthConfirmOpen(false)}
confirmText="重置"
/>
)}
</div> </div>
); );
} }

View File

@@ -168,7 +168,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal trade-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '420px' }} style={{ maxWidth: '420px' }}
> >
@@ -184,19 +184,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && ( {!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
<div <div
style={{ className="trade-pending-alert"
marginBottom: 16,
background: 'rgba(230, 162, 60, 0.1)',
border: '1px solid rgba(230, 162, 60, 0.2)',
borderRadius: 8,
padding: '8px 12px',
fontSize: '12px',
color: '#e6a23c',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer'
}}
onClick={() => setShowPendingList(true)} onClick={() => setShowPendingList(true)}
> >
<span> 当前有 {currentPendingTrades.length} 笔待处理交易</span> <span> 当前有 {currentPendingTrades.length} 笔待处理交易</span>
@@ -206,7 +194,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
{showPendingList ? ( {showPendingList ? (
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}> <div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<div className="pending-list-header" style={{ position: 'sticky', top: 0, zIndex: 1, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(6px)', paddingBottom: 8, marginBottom: 8, borderBottom: '1px solid var(--border)' }}> <div className="pending-list-header trade-pending-header">
<button <button
className="button secondary" className="button secondary"
onClick={() => setShowPendingList(false)} onClick={() => setShowPendingList(false)}
@@ -217,7 +205,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
</div> </div>
<div className="pending-list-items" style={{ paddingTop: 0 }}> <div className="pending-list-items" style={{ paddingTop: 0 }}>
{currentPendingTrades.map((trade, idx) => ( {currentPendingTrades.map((trade, idx) => (
<div key={trade.id || idx} style={{ background: 'rgba(255,255,255,0.05)', padding: 12, borderRadius: 8, marginBottom: 8 }}> <div key={trade.id || idx} className="trade-pending-item">
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}> <div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}> <span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
{trade.type === 'buy' ? '买入' : '卖出'} {trade.type === 'buy' ? '买入' : '卖出'}
@@ -231,17 +219,11 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}> <div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
<span className="muted">状态</span> <span className="muted">状态</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#e6a23c' }}>等待净值更新...</span> <span className="trade-pending-status">等待净值更新...</span>
<button <button
className="button secondary" className="button secondary trade-revoke-btn"
onClick={() => setRevokeTrade(trade)} onClick={() => setRevokeTrade(trade)}
style={{ style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
padding: '2px 8px',
fontSize: '10px',
height: 'auto',
background: 'rgba(255,255,255,0.1)',
color: 'var(--text)'
}}
> >
撤销 撤销
</button> </button>
@@ -263,7 +245,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
{showConfirm ? ( {showConfirm ? (
isBuy ? ( isBuy ? (
<div style={{ fontSize: '14px' }}> <div style={{ fontSize: '14px' }}>
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}> <div className="trade-confirm-card">
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}> <div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">基金名称</span> <span className="muted">基金名称</span>
<span style={{ fontWeight: 600 }}>{fund?.name}</span> <span style={{ fontWeight: 600 }}>{fund?.name}</span>
@@ -288,7 +270,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<span className="muted">买入日期</span> <span className="muted">买入日期</span>
<span>{date}</span> <span>{date}</span>
</div> </div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}> <div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
<span className="muted">交易时段</span> <span className="muted">交易时段</span>
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span> <span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
</div> </div>
@@ -301,7 +283,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div> <div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}> <div className="trade-preview-card" style={{ flex: 1 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div> <div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
<div style={{ fontSize: '12px' }}> <div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span> <span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
@@ -310,7 +292,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
</div> </div>
</div> </div>
{price ? ( {price ? (
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}> <div className="trade-preview-card" style={{ flex: 1 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div> <div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div>
<div style={{ fontSize: '12px' }}> <div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span> <span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
@@ -326,9 +308,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<button <button
type="button" type="button"
className="button secondary" className="button secondary trade-back-btn"
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }} style={{ flex: 1 }}
> >
返回修改 返回修改
</button> </button>
@@ -345,7 +327,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
</div> </div>
) : ( ) : (
<div style={{ fontSize: '14px' }}> <div style={{ fontSize: '14px' }}>
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}> <div className="trade-confirm-card">
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}> <div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">基金名称</span> <span className="muted">基金名称</span>
<span style={{ fontWeight: 600 }}>{fund?.name}</span> <span style={{ fontWeight: 600 }}>{fund?.name}</span>
@@ -370,7 +352,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<span className="muted">卖出日期</span> <span className="muted">卖出日期</span>
<span>{date}</span> <span>{date}</span>
</div> </div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}> <div className="row trade-confirm-divider" style={{ justifyContent: 'space-between', marginBottom: 8, paddingTop: 8 }}>
<span className="muted">预计回款</span> <span className="muted">预计回款</span>
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span> <span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
</div> </div>
@@ -383,7 +365,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div> <div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}> <div className="trade-preview-card" style={{ flex: 1 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div> <div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
<div style={{ fontSize: '12px' }}> <div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span> <span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
@@ -392,7 +374,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
</div> </div>
</div> </div>
{price ? ( {price ? (
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}> <div className="trade-preview-card" style={{ flex: 1 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div> <div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div>
<div style={{ fontSize: '12px' }}> <div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span> <span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
@@ -408,9 +390,9 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<button <button
type="button" type="button"
className="button secondary" className="button secondary trade-back-btn"
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }} style={{ flex: 1 }}
> >
返回修改 返回修改
</button> </button>
@@ -472,36 +454,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段 交易时段
</label> </label>
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}> <div className="trade-time-slot row" style={{ gap: 8 }}>
<button <button
type="button" type="button"
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(false)} onClick={() => setIsAfter3pm(false)}
style={{
flex: 1,
border: 'none',
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
> >
15:00 15:00
</button> </button>
<button <button
type="button" type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)} onClick={() => setIsAfter3pm(true)}
style={{
flex: 1,
border: 'none',
background: isAfter3pm ? 'var(--primary)' : 'transparent',
color: isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
> >
15:00 15:00
</button> </button>
@@ -544,17 +508,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<button <button
key={opt.label} key={opt.label}
type="button" type="button"
className="trade-amount-btn"
onClick={() => handleSetShareFraction(opt.value)} onClick={() => handleSetShareFraction(opt.value)}
style={{
flex: 1,
padding: '4px 8px',
fontSize: '12px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '4px',
color: 'var(--text)',
cursor: 'pointer'
}}
> >
{opt.label} {opt.label}
</button> </button>
@@ -563,7 +518,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
)} )}
{holding && ( {holding && (
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}> <div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
当前持仓: {holding.share.toFixed(2)} {pendingSellShare > 0 && <span style={{ color: '#e6a23c', marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} </span>} 当前持仓: {holding.share.toFixed(2)} {pendingSellShare > 0 && <span className="trade-pending-status" style={{ marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} </span>}
</div> </div>
)} )}
</div> </div>
@@ -614,36 +569,18 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段 交易时段
</label> </label>
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}> <div className="trade-time-slot row" style={{ gap: 8 }}>
<button <button
type="button" type="button"
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(false)} onClick={() => setIsAfter3pm(false)}
style={{
flex: 1,
border: 'none',
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
> >
15:00 15:00
</button> </button>
<button <button
type="button" type="button"
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
onClick={() => setIsAfter3pm(true)} onClick={() => setIsAfter3pm(true)}
style={{
flex: 1,
border: 'none',
background: isAfter3pm ? 'var(--primary)' : 'transparent',
color: isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
> >
15:00 15:00
</button> </button>
@@ -663,7 +600,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
)} )}
<div className="row" style={{ gap: 12, marginTop: 12 }}> <div className="row" style={{ gap: 12, marginTop: 12 }}>
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button> <button type="button" className="button secondary trade-cancel-btn" onClick={onClose} style={{ flex: 1 }}>取消</button>
<button <button
type="submit" type="submit"
className="button" className="button"

View File

@@ -54,7 +54,7 @@ export default function TransactionHistoryModal({
initial={{ opacity: 0, scale: 0.95, y: 20 }} initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }} exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal" className="glass card modal tx-history-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }} style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
> >
@@ -78,7 +78,7 @@ export default function TransactionHistoryModal({
onClick={onAddHistory} onClick={onAddHistory}
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }} style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
> >
添加记录 添加记录
</button> </button>
</div> </div>
@@ -88,22 +88,14 @@ export default function TransactionHistoryModal({
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div> <div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
{pendingTransactions.map((item) => ( {pendingTransactions.map((item) => (
<div key={item.id} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}> <div key={item.id} className="tx-history-pending-item">
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}> <div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}> <span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
{item.type === 'buy' ? '买入' : '卖出'} {item.type === 'buy' ? '买入' : '卖出'}
</span> </span>
{item.type === 'buy' && item.isDca && ( {item.type === 'buy' && item.isDca && (
<span <span className="tx-history-dca-badge">
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 999,
background: 'rgba(34,197,94,0.15)',
color: '#4ade80'
}}
>
定投 定投
</span> </span>
)} )}
@@ -115,11 +107,11 @@ export default function TransactionHistoryModal({
<span>{item.share ? `${Number(item.share).toFixed(2)}` : `¥${Number(item.amount).toFixed(2)}`}</span> <span>{item.share ? `${Number(item.share).toFixed(2)}` : `¥${Number(item.amount).toFixed(2)}`}</span>
</div> </div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}> <div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span style={{ color: '#e6a23c' }}>等待净值更新...</span> <span className="tx-history-pending-status">等待净值更新...</span>
<button <button
className="button secondary" className="button secondary tx-history-action-btn"
onClick={() => handleDeleteClick(item, 'pending')} onClick={() => handleDeleteClick(item, 'pending')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)' }} style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
> >
撤销 撤销
</button> </button>
@@ -136,22 +128,14 @@ export default function TransactionHistoryModal({
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div> <div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
) : ( ) : (
sortedTransactions.map((item) => ( sortedTransactions.map((item) => (
<div key={item.id} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}> <div key={item.id} className="tx-history-record-item">
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}> <div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}> <span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--primary)' : 'var(--danger)' }}>
{item.type === 'buy' ? '买入' : '卖出'} {item.type === 'buy' ? '买入' : '卖出'}
</span> </span>
{item.type === 'buy' && item.isDca && ( {item.type === 'buy' && item.isDca && (
<span <span className="tx-history-dca-badge">
style={{
fontSize: 10,
padding: '2px 6px',
borderRadius: 999,
background: 'rgba(34,197,94,0.15)',
color: '#4ade80'
}}
>
定投 定投
</span> </span>
)} )}
@@ -175,9 +159,9 @@ export default function TransactionHistoryModal({
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}> <div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span className="muted"></span> <span className="muted"></span>
<button <button
className="button secondary" className="button secondary tx-history-action-btn"
onClick={() => handleDeleteClick(item, 'history')} onClick={() => handleDeleteClick(item, 'history')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)', color: 'var(--muted)' }} style={{ padding: '2px 8px', fontSize: '10px', height: 'auto' }}
> >
删除记录 删除记录
</button> </button>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
import { useCallback, useRef } from 'react';
import { cachedRequest, clearCachedRequest } from '../lib/cacheRequest';
const FUND_CODE_SEARCH_URL = 'https://fund.eastmoney.com/js/fundcode_search.js';
const FUND_LIST_CACHE_KEY = 'eastmoney_fundcode_search_list';
const FUND_LIST_CACHE_TIME = 24 * 60 * 60 * 1000;
const formatEastMoneyFundList = (rawList) => {
if (!Array.isArray(rawList)) return [];
return rawList
.map((item) => {
if (!Array.isArray(item)) return null;
const code = String(item[0] ?? '').trim();
const name = String(item[2] ?? '').trim();
if (!code || !name) return null;
return { code, name };
})
.filter(Boolean);
};
export const useFundFuzzyMatcher = () => {
const allFundFuseRef = useRef(null);
const allFundLoadPromiseRef = useRef(null);
const getAllFundFuse = useCallback(async () => {
if (allFundFuseRef.current) return allFundFuseRef.current;
if (allFundLoadPromiseRef.current) return allFundLoadPromiseRef.current;
allFundLoadPromiseRef.current = (async () => {
const [fuseModule, allFundList] = await Promise.all([
import('fuse.js'),
cachedRequest(
() =>
new Promise((resolve, reject) => {
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
reject(new Error('NO_BROWSER_ENV'));
return;
}
const prevR = window.r;
const script = document.createElement('script');
script.src = `${FUND_CODE_SEARCH_URL}?_=${Date.now()}`;
script.async = true;
const cleanup = () => {
if (document.body.contains(script)) {
document.body.removeChild(script);
}
if (prevR === undefined) {
try {
delete window.r;
} catch (e) {
window.r = undefined;
}
} else {
window.r = prevR;
}
};
script.onload = () => {
const snapshot = Array.isArray(window.r) ? JSON.parse(JSON.stringify(window.r)) : [];
cleanup();
const parsed = formatEastMoneyFundList(snapshot);
if (!parsed.length) {
reject(new Error('PARSE_ALL_FUND_FAILED'));
return;
}
resolve(parsed);
};
script.onerror = () => {
cleanup();
reject(new Error('LOAD_ALL_FUND_FAILED'));
};
document.body.appendChild(script);
}),
FUND_LIST_CACHE_KEY,
{ cacheTime: FUND_LIST_CACHE_TIME }
),
]);
const Fuse = fuseModule.default;
const fuse = new Fuse(Array.isArray(allFundList) ? allFundList : [], {
keys: ['name', 'code'],
includeScore: true,
threshold: 0.5,
ignoreLocation: true,
minMatchCharLength: 2,
});
allFundFuseRef.current = fuse;
return fuse;
})();
try {
return await allFundLoadPromiseRef.current;
} catch (e) {
allFundLoadPromiseRef.current = null;
clearCachedRequest(FUND_LIST_CACHE_KEY);
throw e;
}
}, []);
const normalizeFundText = useCallback((value) => {
if (typeof value !== 'string') return '';
return value
.toUpperCase()
.replace(/[(]/g, '(')
.replace(/[)]/g, ')')
.replace(/[·•]/g, '')
.replace(/\s+/g, '')
.replace(/[^\u4e00-\u9fa5A-Z0-9()]/g, '');
}, []);
const parseFundQuerySignals = useCallback((rawName) => {
const normalized = normalizeFundText(rawName);
const hasETF = normalized.includes('ETF');
const hasLOF = normalized.includes('LOF');
const hasLink = normalized.includes('联接');
const shareMatch = normalized.match(/([A-Z])(?:类)?$/i);
const shareClass = shareMatch ? shareMatch[1].toUpperCase() : null;
const core = normalized
.replace(/基金/g, '')
.replace(/ETF联接/g, '')
.replace(/联接[A-Z]?/g, '')
.replace(/ETF/g, '')
.replace(/LOF/g, '')
.replace(/[A-Z](?:类)?$/g, '');
return {
normalized,
core,
hasETF,
hasLOF,
hasLink,
shareClass,
};
}, [normalizeFundText]);
const resolveFundCodeByFuzzy = useCallback(async (name) => {
const querySignals = parseFundQuerySignals(name);
if (!querySignals.normalized) return null;
const len = querySignals.normalized.length;
const strictThreshold = len <= 4 ? 0.16 : len <= 8 ? 0.22 : 0.28;
const relaxedThreshold = Math.min(0.45, strictThreshold + 0.16);
const scoreGapThreshold = len <= 5 ? 0.08 : 0.06;
const fuse = await getAllFundFuse();
const recalled = fuse.search(name, { limit: 50 });
if (!recalled.length) return null;
const stage1 = recalled.filter((item) => (item.score ?? 1) <= relaxedThreshold);
if (!stage1.length) return null;
const ranked = stage1
.map((item) => {
const candidateSignals = parseFundQuerySignals(item?.item?.name || '');
let finalScore = item.score ?? 1;
if (querySignals.hasETF) {
finalScore += candidateSignals.hasETF ? -0.04 : 0.2;
}
if (querySignals.hasLOF) {
finalScore += candidateSignals.hasLOF ? -0.04 : 0.2;
}
if (querySignals.hasLink) {
finalScore += candidateSignals.hasLink ? -0.03 : 0.18;
}
if (querySignals.shareClass) {
finalScore += candidateSignals.shareClass === querySignals.shareClass ? -0.03 : 0.18;
}
if (querySignals.core && candidateSignals.core) {
if (candidateSignals.core.includes(querySignals.core)) {
finalScore -= 0.06;
} else if (!querySignals.core.includes(candidateSignals.core)) {
finalScore += 0.06;
}
}
return { ...item, finalScore };
})
.sort((a, b) => a.finalScore - b.finalScore);
const top1 = ranked[0];
if (!top1 || top1.finalScore > strictThreshold) return null;
const top2 = ranked[1];
if (top2 && (top2.finalScore - top1.finalScore) < scoreGapThreshold) {
return null;
}
return top1?.item?.code || null;
}, [getAllFundFuse, parseFundQuerySignals]);
return {
resolveFundCodeByFuzzy,
};
};
export default useFundFuzzyMatcher;

View File

@@ -11,9 +11,20 @@ export default function RootLayout({ children }) {
const GA_ID = process.env.NEXT_PUBLIC_GA_ID; const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" suppressHydrationWarning>
<head> <head>
<meta name="apple-mobile-web-app-title" content="基估宝" />
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<link rel="apple-touch-icon" href="/Icon-60@3x.png?v=1"/>
<link rel="apple-touch-icon" sizes="180x180" href="/Icon-60@3x.png?v=1"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
{/* 尽早设置 data-theme减少首屏主题闪烁与 suppressHydrationWarning 配合避免服务端/客户端 html 属性不一致报错 */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
}}
/>
</head> </head>
<body> <body>
<AnalyticsGate GA_ID={GA_ID} /> <AnalyticsGate GA_ID={GA_ID} />

View File

@@ -0,0 +1,56 @@
/**
* A股交易日历基于 chinese-days 节假日数据,严格判断某日期是否为交易日
* 交易日 = 周一至周五 且 不在法定节假日
* 调休补班日周末变工作日A股仍休市故不视为交易日
*/
const CDN_BASE = 'https://cdn.jsdelivr.net/npm/chinese-days@1/dist/years';
const yearCache = new Map(); // year -> Set<dateStr> (holidays)
/**
* 加载某年的节假日数据
* @param {number} year
* @returns {Promise<Set<string>>} 节假日日期集合,格式 YYYY-MM-DD
*/
export async function loadHolidaysForYear(year) {
if (yearCache.has(year)) {
return yearCache.get(year);
}
try {
const res = await fetch(`${CDN_BASE}/${year}.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const holidays = new Set(Object.keys(data?.holidays ?? {}));
yearCache.set(year, holidays);
return holidays;
} catch (e) {
console.warn(`[tradingCalendar] 加载 ${year} 年节假日失败:`, e);
yearCache.set(year, new Set());
return yearCache.get(year);
}
}
/**
* 加载多个年份的节假日数据
* @param {number[]} years
*/
export async function loadHolidaysForYears(years) {
await Promise.all([...new Set(years)].map(loadHolidaysForYear));
}
/**
* 判断某日期是否为 A股交易日
* @param {dayjs.Dayjs} date - dayjs 对象
* @param {Map<number, Set<string>>} [cache] - 可选,已加载的年份缓存,默认使用内部 yearCache
* @returns {boolean}
*/
export function isTradingDay(date, cache = yearCache) {
const dayOfWeek = date.day(); // 0=周日, 6=周六
if (dayOfWeek === 0 || dayOfWeek === 6) return false;
const dateStr = date.format('YYYY-MM-DD');
const year = date.year();
const holidays = cache.get(year);
if (!holidays) return true; // 未加载该年数据时,仅排除周末
return !holidays.has(dateStr);
}

File diff suppressed because it is too large Load Diff

135
package-lock.json generated
View File

@@ -1,25 +1,32 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.6", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.6", "version": "0.2.1",
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"fuse.js": "^7.1.0",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"next": "^16.1.5", "next": "^16.1.5",
"react": "18.3.1", "react": "18.3.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"tesseract.js": "^5.1.1" "tesseract.js": "^5.1.1",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
@@ -721,6 +728,73 @@
"@dicebear/core": "^9.0.0" "@dicebear/core": "^9.0.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -1772,6 +1846,39 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -4043,6 +4150,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -6854,6 +6970,19 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/wasm-feature-detect": { "node_modules/wasm-feature-detect": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.6", "version": "0.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,16 +13,23 @@
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"fuse.js": "^7.1.0",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"next": "^16.1.5", "next": "^16.1.5",
"react": "18.3.1", "react": "18.3.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"tesseract.js": "^5.1.1" "tesseract.js": "^5.1.1",
"uuid": "^13.0.0"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"

BIN
public/Icon-60@3x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

104742
public/allFund.json Normal file

File diff suppressed because it is too large Load Diff