60 Commits

Author SHA1 Message Date
hzm
a4e33d23cb feat:发布 0.2.2 2026-03-08 22:56:08 +08:00
hzm
63e7f000df feat:优化基金详情弹框样式 2026-03-08 22:46:02 +08:00
hzm
152059b199 feat:PC端表头固定 2026-03-08 22:39:31 +08:00
hzm
6c685c61e0 feat:当日净值更新则不再展示当日收益百分比 2026-03-08 22:05:51 +08:00
hzm
a176e7d013 feat: 修改移动端列表模式节流时长 2026-03-08 20:08:56 +08:00
hzm
d5df393723 feat: 切换模式自动滚动页面到顶部 2026-03-08 19:08:29 +08:00
hzm
7f3dfb31cf feat: PC端列表模式点击基金名称查看详情 2026-03-08 18:59:17 +08:00
hzm
e97de8744a feat: 移动端列表模式点击基金名称查看详情 2026-03-08 14:52:55 +08:00
hzm
354936c9af feat: 移动端持有收益增加可切换提示 2026-03-08 13:07:20 +08:00
hzm
a8a24605d4 feat: 移动端表头滚动监听增加节流 2026-03-08 12:51:04 +08:00
hzm
b20fd42eec fix: 移动端表头固定考虑合计区域 2026-03-08 12:43:15 +08:00
hzm
a3719c58fb fix: 移动端表头吸附改进 2026-03-08 12:35:06 +08:00
hzm
6d2cf60d21 feat:移动端表头吸附效果优化 2026-03-08 11:58:13 +08:00
hzm
89d938a6c3 feat:移动端表头吸附效果 2026-03-08 11:36:49 +08:00
hzm
86e479c21a feat:移动端固定列样式 2026-03-07 21:33:01 +08:00
hzm
1f3c0bbbc9 feat:移动端标题栏吸附效果 2026-03-07 21:08:40 +08:00
hzm
24eb21fd29 feat:移动端列表左侧固定样式优化 2026-03-07 20:38:34 +08:00
hzm
56e20211e4 fix: 重新登录缺陷 2026-03-07 20:18:32 +08:00
hzm
e5e2e472aa feat: 减少从云端获取数据次数 2026-03-07 19:54:17 +08:00
hzm
dab3ba3142 feat: 重新调整初始化同步方法 2026-03-07 19:47:03 +08:00
hzm
5b86a1c84a Revert "feat: 为减少服务端出口流量,新增边缘函数检查数据库表状态"
This reverts commit f20b852e98.
2026-03-07 19:25:55 +08:00
hzm
e5858df592 feat: PC端表格操作列样式 2026-03-07 11:12:49 +08:00
hzm
f20b852e98 feat: 为减少服务端出口流量,新增边缘函数检查数据库表状态 2026-03-07 11:09:07 +08:00
hzm
792986dd79 feat: 增加 shadcn 样式 2026-03-06 22:34:10 +08:00
hzm
1f9a4ff97a fix: PC端列对齐 2026-03-06 10:56:57 +08:00
hzm
baea6f5107 feat: 估算净值变化触发同步 2026-03-06 10:26:44 +08:00
hzm
f0b469fc93 feat: 估算收益支持展示金额 2026-03-06 09:47:24 +08:00
hzm
d9364ce504 feat: 综合涨幅改名估算收益 2026-03-06 08:59:55 +08:00
hzm
99ec356fbb feat: 新增综合涨幅 2026-03-06 08:44:08 +08:00
hzm
44dfb944c7 feat: 涨跌幅名称调整 2026-03-06 08:28:36 +08:00
hzm
aac5c5003a feat: 基金名称列表页增加是否展示完整基金名称个性化设置 2026-03-06 08:14:07 +08:00
hzm
6580658f55 feat: 禁止主页容器横向滚动 2026-03-06 00:23:24 +08:00
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
39 changed files with 114924 additions and 1263 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 [];
try { };
const parseContent = (content) => {
if (!content) return []; export const parseFundTextWithLLM = async (text) => {
const rows = content.split('<tr>'); const apiKey = 'sk-a72c4e279bc62a03cc105be6263d464c';
const data = []; if (!apiKey || !text) return null;
for (const row of rows) {
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g); try {
if (cells && cells.length >= 2) { const response = await fetch('https://apis.iflow.cn/v1/chat/completions', {
const dateStr = cells[0].replace(/<[^>]+>/g, '').trim(); method: 'POST',
const valStr = cells[1].replace(/<[^>]+>/g, '').trim(); headers: {
const val = parseFloat(valStr); 'Authorization': `Bearer ${apiKey}`,
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) { 'Content-Type': 'application/json'
data.push({ date: dateStr, value: val }); },
} body: JSON.stringify({
} model: 'qwen3-max',
} messages: [
return data; { 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':'}]。除了示例输出的内容外,不要输出任何多余内容"},
}; { role: 'user', content: text }
],
// Fetch first page to get metadata temperature: 0.3,
const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`; max_tokens: 2000
const firstApidata = await loadScript(firstUrl); })
});
if (!firstApidata || !firstApidata.content || firstApidata.content.includes('暂无数据')) {
resolve([]); if (!response.ok) {
return; return null;
} }
// Parse total pages const data = await response.json();
if (firstApidata.pages) { return data?.choices?.[0]?.message?.content || null;
totalPages = parseInt(firstApidata.pages, 10) || 1; } catch (e) {
} return null;
}
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) {
console.error('Fetch history error:', e);
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

@@ -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_v11'; const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v13';
export default function Announcement() { export default function Announcement() {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -16,6 +16,16 @@ export default function Announcement() {
}, []); }, []);
const handleClose = () => { const handleClose = () => {
// 清理历史 ANNOUNCEMENT_KEY
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('hasClosedAnnouncement_v') && key !== ANNOUNCEMENT_KEY) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
localStorage.setItem(ANNOUNCEMENT_KEY, 'true'); localStorage.setItem(ANNOUNCEMENT_KEY, 'true');
setIsVisible(false); setIsVisible(false);
}; };
@@ -52,6 +62,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,19 +74,13 @@ 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.2 版本更新内容如下</p>
target="_blank" <p>1. 新增 ios pwa 应用支持</p>
rel="noopener noreferrer" <p>2. 引入 shadcn ui 组件库逐步调整项目样式</p>
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a> <p>3. 列表模式表头固定</p>
<p>v0.1.9 版本更新内容如下</p> <p>4. 列表模式点击名称展示基金详情弹框</p>
<p>1. 新增亮色主题</p> <p>强烈建议苹果用户通过 Safari 浏览器分享添加应用到主屏幕实现保存网页成APP效果安卓同理</p>
<p>2. PC移动表格模式重构支持自定义布局</p>
<p>3. PC端设置弹框支持修改页面容器宽度</p>
<p>4. 分组下自定义布局数据相互独立旧数据需重新配置</p>
<p>5. 更换随机头像风格</p>
感谢以下用户上月对项目赞助支持排名不分顺序
<p>*M*.S*ob*g*D*A*匿名*🍍*L*.*大大方块先生带火星的小木条F無芯广告制作装饰****</p>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

View File

@@ -1,46 +1,55 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import {
import { createPortal } from 'react-dom'; Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { TrashIcon } from './Icons'; import { TrashIcon } from './Icons';
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) { export default function ConfirmModal({
const content = ( title,
<motion.div message,
className="modal-overlay" onConfirm,
role="dialog" onCancel,
aria-modal="true" confirmText = '确定删除',
onClick={(e) => { }) {
e.stopPropagation(); const handleOpenChange = (open) => {
onCancel(); if (!open) onCancel();
}} };
initial={{ opacity: 0 }}
animate={{ opacity: 1 }} return (
exit={{ opacity: 0 }} <Dialog open onOpenChange={handleOpenChange}>
style={{ zIndex: 10002 }} <DialogContent
> showCloseButton={false}
<motion.div className="max-w-[400px] flex flex-col gap-5 p-6"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
> >
<div className="title" style={{ marginBottom: 12 }}> <DialogHeader className="flex flex-row items-center gap-3 text-left">
<TrashIcon width="20" height="20" className="danger" /> <TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
<span>{title}</span> <DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
</div> </DialogHeader>
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}> <DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
{message} {message}
</p> </DialogDescription>
<div className="row" style={{ gap: 12 }}> <div className="flex flex-col gap-3 sm:flex-row">
<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> type="button"
className="button secondary min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
onClick={onCancel}
>
取消
</button>
<button
type="button"
className="button danger min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
onClick={onConfirm}
>
{confirmText}
</button>
</div> </div>
</motion.div> </DialogContent>
</motion.div> </Dialog>
); );
if (typeof document === 'undefined') return null;
return createPortal(content, document.body);
} }

View File

@@ -0,0 +1,33 @@
'use client';
export default function EmptyStateCard({
fundsLength = 0,
currentTab = 'all',
onAddToGroup,
}) {
const isEmpty = fundsLength === 0;
const isGroupTab = currentTab !== 'all' && currentTab !== 'fav';
return (
<div
className="glass card empty"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 20px',
}}
>
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
<div className="muted" style={{ marginBottom: 20 }}>
{isEmpty ? '尚未添加基金' : '该分组下暂无数据'}
</div>
{isGroupTab && fundsLength > 0 && (
<button className="button" onClick={onAddToGroup}>
添加基金到此分组
</button>
)}
</div>
);
}

465
app/components/FundCard.jsx Normal file
View File

@@ -0,0 +1,465 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import { isNumber, isString } from 'lodash';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Stat } from './Common';
import FundTrendChart from './FundTrendChart';
import FundIntradayChart from './FundIntradayChart';
import {
ChevronIcon,
ExitIcon,
SettingsIcon,
StarIcon,
SwitchIcon,
TrashIcon,
} from './Icons';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isSameOrAfter);
const DEFAULT_TZ = 'Asia/Shanghai';
const getBrowserTimeZone = () => {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || DEFAULT_TZ;
}
return DEFAULT_TZ;
};
const TZ = getBrowserTimeZone();
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
export default function FundCard({
fund: f,
todayStr,
currentTab,
favorites,
dcaPlans,
holdings,
percentModes,
valuationSeries,
collapsedCodes,
collapsedTrends,
transactions,
theme,
isTradingDay,
refreshing,
getHoldingProfit,
onRemoveFromGroup,
onToggleFavorite,
onRemoveFund,
onHoldingClick,
onActionClick,
onPercentModeToggle,
onToggleCollapse,
onToggleTrendCollapse,
layoutMode = 'card', // 'card' | 'drawer'drawer 时前10重仓与业绩走势以 Tabs 展示
}) {
const holding = holdings[f?.code];
const profit = getHoldingProfit?.(f, holding) ?? null;
const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0;
const style = layoutMode === 'drawer' ? {
border: 'none',
boxShadow: 'none',
paddingLeft: 0,
paddingRight: 0,
background: 'transparent',
} : {};
return (
<motion.div
className="glass card"
style={{
position: 'relative',
zIndex: 1,
...style,
}}
>
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
{currentTab !== 'all' && currentTab !== 'fav' ? (
<button
className="icon-button fav-button"
onClick={(e) => {
e.stopPropagation();
onRemoveFromGroup?.(f.code);
}}
title="从当前分组移除"
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button
className={`icon-button fav-button ${favorites?.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(f.code);
}}
title={favorites?.has(f.code) ? '取消自选' : '添加自选'}
>
<StarIcon width="18" height="18" filled={favorites?.has(f.code)} />
</button>
)}
<div className="title-text">
<span
className="name-text"
title={f.jzrq === todayStr ? '今日净值已更新' : ''}
>
{f.name}
</span>
<span className="muted">
#{f.code}
{dcaPlans?.[f.code]?.enabled === true && <span className="dca-indicator"></span>}
{f.jzrq === todayStr && <span className="updated-indicator"></span>}
</span>
</div>
</div>
<div className="actions">
<div className="badge-v">
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
</div>
<div className="row" style={{ gap: 4 }}>
<button
className="icon-button danger"
onClick={() => !refreshing && onRemoveFund?.(f)}
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>
</div>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
{f.noValuation ? (
<Stat
label="涨跌幅"
value={
f.zzl !== undefined && f.zzl !== null
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
: '—'
}
delta={f.zzl}
/>
) : (
<>
{(() => {
const hasTodayData = f.jzrq === todayStr;
let isYesterdayChange = false;
let isPreviousTradingDay = false;
if (!hasTodayData && isString(f.jzrq)) {
const today = toTz(todayStr).startOf('day');
const jzDate = toTz(f.jzrq).startOf('day');
const yesterday = today.clone().subtract(1, 'day');
if (jzDate.isSame(yesterday, 'day')) {
isYesterdayChange = true;
} else if (jzDate.isBefore(yesterday, 'day')) {
isPreviousTradingDay = true;
}
}
const shouldHideChange =
isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
if (shouldHideChange) return null;
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅';
return (
<Stat
label={changeLabel}
value={
f.zzl !== undefined
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
: ''
}
delta={f.zzl}
/>
);
})()}
<Stat
label="估值净值"
value={
f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')
}
/>
<Stat
label="估值涨幅"
value={
f.estPricedCoverage > 0.05
? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%`
: isNumber(f.gszzl)
? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%`
: f.gszzl ?? '—'
}
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0}
/>
</>
)}
</div>
<div className="row" style={{ marginBottom: 12 }}>
{!profit ? (
<div
className="stat"
style={{ flexDirection: 'column', gap: 4 }}
>
<span className="label">持仓金额</span>
<div
className="value muted"
style={{
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: layoutMode === 'drawer' ? 'default' : 'pointer',
}}
onClick={() => layoutMode !== 'drawer' && onHoldingClick?.(f)}
>
未设置 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" />}
</div>
</div>
) : (
<>
<div
className="stat"
style={{ cursor: layoutMode === 'drawer' ? 'default' : 'pointer', flexDirection: 'column', gap: 4 }}
onClick={() => layoutMode !== 'drawer' && onActionClick?.(f)}
>
<span
className="label"
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
持仓金额 {layoutMode !== 'drawer' && <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />}
</span>
<span className="value">¥{profit.amount.toFixed(2)}</span>
</div>
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">当日收益</span>
<span
className={`value ${
profit.profitToday != null
? profit.profitToday > 0
? 'up'
: profit.profitToday < 0
? 'down'
: ''
: 'muted'
}`}
>
{profit.profitToday != null
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
: '--'}
</span>
</div>
{profit.profitTotal !== null && (
<div
className="stat"
onClick={(e) => {
e.stopPropagation();
onPercentModeToggle?.(f.code);
}}
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}
title="点击切换金额/百分比"
>
<span
className="label"
style={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'flex-end' }}
>
持有收益{percentModes?.[f.code] ? '(%)' : ''}
<SwitchIcon />
</span>
<span
className={`value ${
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
}`}
>
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
{percentModes?.[f.code]
? `${Math.abs(
holding?.cost * holding?.share
? (profit.profitTotal / (holding.cost * holding.share)) * 100
: 0,
).toFixed(2)}%`
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
</span>
</div>
)}
</>
)}
</div>
{f.estPricedCoverage > 0.05 && (
<div
style={{
fontSize: '10px',
color: 'var(--muted)',
marginTop: -8,
marginBottom: 10,
textAlign: 'right',
}}
>
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
</div>
)}
{(() => {
const showIntraday =
Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2;
if (!showIntraday) return null;
if (
f.gztime &&
toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))
) {
return null;
}
if (
f.jzrq &&
f.gztime &&
toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))
) {
return null;
}
return (
<FundIntradayChart
key={`${f.code}-intraday-${theme}`}
series={valuationSeries[f.code]}
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
theme={theme}
/>
);
})()}
{layoutMode === 'drawer' ? (
<Tabs defaultValue={hasHoldings ? 'holdings' : 'trend'} className="w-full">
<TabsList className={`w-full ${hasHoldings ? 'grid grid-cols-2' : ''}`}>
{hasHoldings && (
<TabsTrigger value="holdings">前10重仓股票</TabsTrigger>
)}
<TabsTrigger value="trend">业绩走势</TabsTrigger>
</TabsList>
{hasHoldings && (
<TabsContent value="holdings" className="mt-3 outline-none">
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{isNumber(h.change) && (
<span
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
style={{ marginRight: 8 }}
>
{h.change > 0 ? '+' : ''}
{h.change.toFixed(2)}%
</span>
)}
<span className="weight">{h.weight}</span>
</div>
</div>
))}
</div>
</TabsContent>
)}
<TabsContent value="trend" className="mt-3 outline-none">
<FundTrendChart
key={`${f.code}-${theme}`}
code={f.code}
isExpanded
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
theme={theme}
hideHeader
/>
</TabsContent>
</Tabs>
) : (
<>
{hasHoldings && (
<>
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
onClick={() => onToggleCollapse?.(f.code)}
>
<div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>前10重仓股票</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: collapsedCodes?.has(f.code)
? 'rotate(-90deg)'
: 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
/>
</div>
<span className="muted">涨跌幅 / 占比</span>
</div>
</div>
<AnimatePresence>
{!collapsedCodes?.has(f.code) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{isNumber(h.change) && (
<span
className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`}
style={{ marginRight: 8 }}
>
{h.change > 0 ? '+' : ''}
{h.change.toFixed(2)}%
</span>
)}
<span className="weight">{h.weight}</span>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</>
)}
<FundTrendChart
key={`${f.code}-${theme}`}
code={f.code}
isExpanded={!collapsedTrends?.has(f.code)}
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
theme={theme}
/>
</>
)}
</motion.div>
);
}

View File

@@ -97,7 +97,7 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
}, [series, referenceNav, chartColors.danger, chartColors.success]); }, [series, referenceNav, chartColors.danger, chartColors.success]);
const options = useMemo(() => { const options = useMemo(() => {
const colors = getChartThemeColors(); const colors = getChartThemeColors(theme);
return { return {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -260,11 +260,20 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
<span <span
style={{ style={{
fontSize: 9, fontSize: 9,
padding: '1px 5px', padding: '2px 6px',
borderRadius: 4, borderRadius: 4,
background: 'var(--primary)', ...(theme === 'light'
color: '#0f172a', ? {
fontWeight: 600 border: '1px solid',
borderColor: chartColors.primary,
color: chartColors.primary,
background: 'transparent',
}
: {
background: 'var(--primary)',
color: '#0f172a',
}),
fontWeight: 600,
}} }}
title="正在测试中的功能" title="正在测试中的功能"
> >
@@ -273,7 +282,7 @@ export default function FundIntradayChart({ series = [], referenceNav, theme = '
</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

@@ -54,7 +54,7 @@ function getChartThemeColors(theme) {
return CHART_COLORS[theme] || CHART_COLORS.dark; return CHART_COLORS[theme] || CHART_COLORS.dark;
} }
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) { export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
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);
@@ -101,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(() => {
@@ -489,79 +490,102 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}]; }];
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair }, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
return ( const chartBlock = (
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}> <>
<div <div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }} {loading && (
className="title" <div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
onClick={onToggleExpand} <span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
>
<div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>业绩走势</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
/>
</div> </div>
{data.length > 0 && ( )}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span> {!loading && data.length === 0 && (
<span style={{ color: lineColor, fontWeight: 600 }}> <div className="chart-overlay">
{change > 0 ? '+' : ''}{change.toFixed(2)}% <span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
</span> </div>
</div> )}
)}
</div> {data.length > 0 && (
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
)}
</div> </div>
<AnimatePresence> <div className="trend-range-bar">
{isExpanded && ( {ranges.map(r => (
<motion.div <button
initial={{ height: 0, opacity: 0 }} key={r.value}
animate={{ height: 'auto', opacity: 1 }} type="button"
exit={{ height: 0, opacity: 0 }} className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
transition={{ duration: 0.3, ease: 'easeInOut' }} onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
style={{ overflow: 'hidden' }}
> >
<div style={{ position: 'relative', height: 180, width: '100%' }}> {r.label}
{loading && ( </button>
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}> ))}
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span> </div>
</div> </>
)} );
{!loading && data.length === 0 && ( return (
<div className="chart-overlay"> <div style={{ marginTop: hideHeader ? 0 : 16 }} onClick={(e) => e.stopPropagation()}>
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span> {!hideHeader && (
</div> <div
)} style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
{data.length > 0 && ( onClick={onToggleExpand}
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} /> >
)} <div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>业绩走势</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
/>
</div> </div>
{data.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
<span style={{ color: lineColor, fontWeight: 600 }}>
{change > 0 ? '+' : ''}{change.toFixed(2)}%
</span>
</div>
)}
</div>
</div>
)}
<div className="trend-range-bar"> {hideHeader && data.length > 0 && (
{ranges.map(r => ( <div className="row" style={{ marginBottom: 8, justifyContent: 'flex-end' }}>
<button <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
key={r.value} <span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
type="button" <span style={{ color: lineColor, fontWeight: 600 }}>
className={`trend-range-btn ${range === r.value ? 'active' : ''}`} {change > 0 ? '+' : ''}{change.toFixed(2)}%
onClick={(e) => { e.stopPropagation(); setRange(r.value); }} </span>
> </div>
{r.label} </div>
</button> )}
))}
</div> {hideHeader ? (
</motion.div> chartBlock
)} ) : (
</AnimatePresence> <AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{chartBlock}
</motion.div>
)}
</AnimatePresence>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,332 @@
'use client';
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react';
import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons';
// 数字滚动组件(初始化时无动画,后续变更再动画)
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
const [displayValue, setDisplayValue] = useState(value);
const previousValue = useRef(value);
const isFirstChange = useRef(true);
useEffect(() => {
if (previousValue.current === value) return;
if (isFirstChange.current) {
isFirstChange.current = false;
previousValue.current = value;
setDisplayValue(value);
return;
}
const start = previousValue.current;
const end = value;
const duration = 400;
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4);
const current = start + (end - start) * ease;
setDisplayValue(current);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
previousValue.current = value;
}
};
requestAnimationFrame(animate);
}, [value]);
return (
<span className={className} style={style}>
{prefix}
{Math.abs(displayValue).toFixed(decimals)}
{suffix}
</span>
);
}
export default function GroupSummary({
funds,
holdings,
groupName,
getProfit,
stickyTop,
}) {
const [showPercent, setShowPercent] = useState(true);
const [isMasked, setIsMasked] = useState(false);
const [isSticky, setIsSticky] = useState(false);
const rowRef = useRef(null);
const [assetSize, setAssetSize] = useState(24);
const [metricSize, setMetricSize] = useState(18);
const [winW, setWinW] = useState(0);
useEffect(() => {
if (typeof window !== 'undefined') {
setWinW(window.innerWidth);
const onR = () => setWinW(window.innerWidth);
window.addEventListener('resize', onR);
return () => window.removeEventListener('resize', onR);
}
}, []);
const summary = useMemo(() => {
let totalAsset = 0;
let totalProfitToday = 0;
let totalHoldingReturn = 0;
let totalCost = 0;
let hasHolding = false;
let hasAnyTodayData = false;
funds.forEach((fund) => {
const holding = holdings[fund.code];
const profit = getProfit(fund, holding);
if (profit) {
hasHolding = true;
totalAsset += profit.amount;
if (profit.profitToday != null) {
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
hasAnyTodayData = true;
}
if (profit.profitTotal !== null) {
totalHoldingReturn += profit.profitTotal;
if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') {
totalCost += holding.cost * holding.share;
}
}
}
});
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
return {
totalAsset,
totalProfitToday,
totalHoldingReturn,
hasHolding,
returnRate,
hasAnyTodayData,
};
}, [funds, holdings, getProfit]);
useLayoutEffect(() => {
const el = rowRef.current;
if (!el) return;
const height = el.clientHeight;
const tooTall = height > 80;
if (tooTall) {
setAssetSize((s) => Math.max(16, s - 1));
setMetricSize((s) => Math.max(12, s - 1));
}
}, [
winW,
summary.totalAsset,
summary.totalProfitToday,
summary.totalHoldingReturn,
summary.returnRate,
showPercent,
assetSize,
metricSize,
]);
if (!summary.hasHolding) return null;
return (
<div
className={isSticky ? 'group-summary-sticky' : ''}
style={isSticky && stickyTop ? { top: stickyTop } : {}}
>
<div
className="glass card group-summary-card"
style={{
marginBottom: 8,
padding: '16px 20px',
background: 'rgba(255, 255, 255, 0.03)',
position: 'relative',
}}
>
<span
className="sticky-toggle-btn"
onClick={() => setIsSticky(!isSticky)}
style={{
position: 'absolute',
top: 4,
right: 4,
width: 24,
height: 24,
padding: 4,
opacity: 0.6,
zIndex: 10,
color: 'var(--muted)',
}}
>
{isSticky ? (
<PinIcon width="14" height="14" />
) : (
<PinOffIcon width="14" height="14" />
)}
</span>
<div
ref={rowRef}
className="row"
style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}
>
<div>
<div
style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}
>
<div className="muted" style={{ fontSize: '12px' }}>
{groupName}
</div>
<button
className="fav-button"
onClick={() => setIsMasked((value) => !value)}
aria-label={isMasked ? '显示资产' : '隐藏资产'}
style={{
margin: 0,
padding: 2,
display: 'inline-flex',
alignItems: 'center',
}}
>
{isMasked ? (
<EyeOffIcon width="16" height="16" />
) : (
<EyeIcon width="16" height="16" />
)}
</button>
</div>
<div
style={{
fontSize: '24px',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
}}
>
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
{isMasked ? (
<span
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
>
******
</span>
) : (
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
)}
</div>
</div>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ textAlign: 'right' }}>
<div
className="muted"
style={{ fontSize: '12px', marginBottom: 4 }}
>
当日收益
</div>
<div
className={
summary.hasAnyTodayData
? summary.totalProfitToday > 0
? 'up'
: summary.totalProfitToday < 0
? 'down'
: ''
: 'muted'
}
style={{
fontSize: '18px',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
}}
>
{isMasked ? (
<span style={{ fontSize: metricSize }}>******</span>
) : summary.hasAnyTodayData ? (
<>
<span style={{ marginRight: 1 }}>
{summary.totalProfitToday > 0
? '+'
: summary.totalProfitToday < 0
? '-'
: ''}
</span>
<CountUp
value={Math.abs(summary.totalProfitToday)}
style={{ fontSize: metricSize }}
/>
</>
) : (
<span style={{ fontSize: metricSize }}>--</span>
)}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div
className="muted"
style={{
fontSize: '12px',
marginBottom: 4,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 2,
}}
>
持有收益{showPercent ? '(%)' : ''}{' '}
<SwitchIcon style={{ opacity: 0.4 }} />
</div>
<div
className={
summary.totalHoldingReturn > 0
? 'up'
: summary.totalHoldingReturn < 0
? 'down'
: ''
}
style={{
fontSize: '18px',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
cursor: 'pointer',
}}
onClick={() => setShowPercent(!showPercent)}
title="点击切换金额/百分比"
>
{isMasked ? (
<span style={{ fontSize: metricSize }}>******</span>
) : (
<>
<span style={{ marginRight: 1 }}>
{summary.totalHoldingReturn > 0
? '+'
: summary.totalHoldingReturn < 0
? '-'
: ''}
</span>
{showPercent ? (
<CountUp
value={Math.abs(summary.returnRate)}
suffix="%"
style={{ fontSize: metricSize }}
/>
) : (
<CountUp
value={Math.abs(summary.totalHoldingReturn)}
style={{ fontSize: metricSize }}
/>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -260,3 +260,20 @@ export function MoonIcon(props) {
</svg> </svg>
); );
} }
export function SwitchIcon({ props }) {
return (
<svg t="1772945896369" className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="2524" width="13" height="13">
<path
d="M885.247 477.597H132c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.247c17.673 0 32 14.327 32 32s-14.327 32-32 32z"
fill="currentColor" p-id="2525"></path>
<path
d="M893.366 477.392c-8.189 0-16.379-3.124-22.627-9.373L709.954 307.235c-12.497-12.497-12.497-32.758 0-45.255 12.496-12.497 32.758-12.497 45.254 0l160.785 160.785c12.497 12.497 12.497 32.758 0 45.255-6.248 6.248-14.437 9.372-22.627 9.372zM893.366 609.607H140.119c-17.673 0-32-14.327-32-32s14.327-32 32-32h753.248c17.673 0 32 14.327 32 32s-14.328 32-32.001 32z"
fill="currentColor" p-id="2526"></path>
<path
d="M292.784 770.597c-8.189 0-16.379-3.124-22.627-9.373L109.373 600.439c-12.497-12.496-12.497-32.758 0-45.254 12.497-12.498 32.758-12.498 45.255 0L315.412 715.97c12.497 12.496 12.497 32.758 0 45.254-6.249 6.249-14.438 9.373-22.628 9.373z"
fill="currentColor" p-id="2527"></path>
</svg>
)
}

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { MailIcon } from './Icons'; import { MailIcon } from './Icons';
export default function LoginModal({ export default function LoginModal({
@@ -56,15 +57,21 @@ export default function LoginModal({
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}> <div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
请输入邮箱验证码以完成注册/登录 请输入邮箱验证码以完成注册/登录
</div> </div>
<input <InputOTP
className="input"
type="text"
placeholder="输入验证码"
value={loginOtp}
onChange={(e) => setLoginOtp(e.target.value)}
disabled={loginLoading}
maxLength={6} maxLength={6}
/> value={loginOtp}
onChange={(value) => setLoginOtp(value)}
disabled={loginLoading}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div> </div>
)} )}
{loginError && ( {loginError && (

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
flexRender, flexRender,
@@ -22,13 +23,23 @@ import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { throttle } from 'lodash';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import FitText from './FitText'; import FitText from './FitText';
import FundCard from './FundCard';
import MobileSettingModal from './MobileSettingModal'; import MobileSettingModal from './MobileSettingModal';
import { ExitIcon, SettingsIcon, StarIcon } from './Icons'; import { CloseIcon, ExitIcon, SettingsIcon, StarIcon } from './Icons';
const MOBILE_NON_FROZEN_COLUMN_IDS = [ const MOBILE_NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent', 'yesterdayChangePercent',
'estimateChangePercent', 'estimateChangePercent',
'totalChangePercent',
'todayProfit', 'todayProfit',
'holdingProfit', 'holdingProfit',
'latestNav', 'latestNav',
@@ -37,8 +48,9 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
const MOBILE_COLUMN_HEADERS = { const MOBILE_COLUMN_HEADERS = {
latestNav: '最新净值', latestNav: '最新净值',
estimateNav: '估算净值', estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨幅', yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅', estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
todayProfit: '当日收益', todayProfit: '当日收益',
holdingProfit: '持有收益', holdingProfit: '持有收益',
}; };
@@ -91,6 +103,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
* @param {boolean} [props.refreshing] - 是否刷新中 * @param {boolean} [props.refreshing] - 是否刷新中
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序 * @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调 * @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props传入后点击基金名称将用底部弹框展示卡片视图
*/ */
export default function MobileFundTable({ export default function MobileFundTable({
data = [], data = [],
@@ -105,6 +118,10 @@ export default function MobileFundTable({
sortBy = 'default', sortBy = 'default',
onReorder, onReorder,
onCustomSettingsChange, onCustomSettingsChange,
stickyTop = 0,
getFundCardProps,
blockDrawerClose = false,
closeDrawerRef,
}) { }) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -114,11 +131,19 @@ export default function MobileFundTable({
); );
const [activeId, setActiveId] = useState(null); const [activeId, setActiveId] = useState(null);
const ignoreNextDrawerCloseRef = useRef(false);
const onToggleFavoriteRef = useRef(onToggleFavorite); const onToggleFavoriteRef = useRef(onToggleFavorite);
const onRemoveFromGroupRef = useRef(onRemoveFromGroup); const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick); const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
useEffect(() => {
if (closeDrawerRef) {
closeDrawerRef.current = () => setCardSheetRow(null);
return () => { closeDrawerRef.current = null; };
}
}, [closeDrawerRef]);
useEffect(() => { useEffect(() => {
onToggleFavoriteRef.current = onToggleFavorite; onToggleFavoriteRef.current = onToggleFavorite;
onRemoveFromGroupRef.current = onRemoveFromGroup; onRemoveFromGroupRef.current = onRemoveFromGroup;
@@ -192,6 +217,7 @@ export default function MobileFundTable({
return [...valid, ...missing]; return [...valid, ...missing];
})() : null, })() : null,
mobileTableColumnVisibility: visibility, mobileTableColumnVisibility: visibility,
mobileShowFullFundName: group.mobileShowFullFundName === true,
}; };
}); });
return byGroup; return byGroup;
@@ -200,6 +226,7 @@ export default function MobileFundTable({
const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup); const [configByGroup, setConfigByGroup] = useState(getInitialMobileConfigByGroup);
const currentGroupMobile = configByGroup[groupKey]; const currentGroupMobile = configByGroup[groupKey];
const showFullFundName = currentGroupMobile?.mobileShowFullFundName ?? false;
const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS]; const defaultOrder = [...MOBILE_NON_FROZEN_COLUMN_IDS];
const defaultVisibility = (() => { const defaultVisibility = (() => {
const o = {}; const o = {};
@@ -247,9 +274,36 @@ export default function MobileFundTable({
: nextOrUpdater; : nextOrUpdater;
persistMobileGroupConfig({ mobileTableColumnVisibility: next }); persistMobileGroupConfig({ mobileTableColumnVisibility: next });
}; };
const persistShowFullFundName = (show) => {
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] } : {};
group.mobileShowFullFundName = show;
parsed[groupKey] = group;
window.localStorage.setItem('customSettings', JSON.stringify(parsed));
setConfigByGroup((prev) => ({
...prev,
[groupKey]: { ...prev[groupKey], mobileShowFullFundName: show }
}));
onCustomSettingsChange?.();
} catch {}
};
const handleToggleShowFullFundName = (show) => {
persistShowFullFundName(show);
};
const [settingModalOpen, setSettingModalOpen] = useState(false); const [settingModalOpen, setSettingModalOpen] = useState(false);
const [cardSheetRow, setCardSheetRow] = useState(null);
const tableContainerRef = useRef(null); const tableContainerRef = useRef(null);
const portalHeaderRef = useRef(null);
const [tableContainerWidth, setTableContainerWidth] = useState(0); const [tableContainerWidth, setTableContainerWidth] = useState(0);
const [isScrolled, setIsScrolled] = useState(false);
const [showPortalHeader, setShowPortalHeader] = useState(false);
const [effectiveStickyTop, setEffectiveStickyTop] = useState(stickyTop);
useEffect(() => { useEffect(() => {
const el = tableContainerRef.current; const el = tableContainerRef.current;
@@ -261,6 +315,87 @@ export default function MobileFundTable({
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const getEffectiveStickyTop = () => {
const stickySummaryCard = document.querySelector('.group-summary-sticky .group-summary-card');
if (!stickySummaryCard) return stickyTop;
const stickySummaryWrapper = stickySummaryCard.closest('.group-summary-sticky');
if (!stickySummaryWrapper) return stickyTop;
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
const isSummaryStuck = wrapperRect.top <= stickyTop + 1;
return isSummaryStuck ? stickyTop + stickySummaryWrapper.offsetHeight : stickyTop;
};
const updateVerticalState = () => {
const nextStickyTop = getEffectiveStickyTop();
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
const tableRect = tableContainerRef.current?.getBoundingClientRect();
if (!tableRect) {
setShowPortalHeader(window.scrollY >= nextStickyTop);
return;
}
setShowPortalHeader(tableRect.top <= nextStickyTop);
};
const throttledVerticalUpdate = throttle(updateVerticalState, 1000/60, { leading: true, trailing: true });
updateVerticalState();
window.addEventListener('scroll', throttledVerticalUpdate, { passive: true });
window.addEventListener('resize', throttledVerticalUpdate, { passive: true });
return () => {
window.removeEventListener('scroll', throttledVerticalUpdate);
window.removeEventListener('resize', throttledVerticalUpdate);
throttledVerticalUpdate.cancel();
};
}, [stickyTop]);
useEffect(() => {
const tableEl = tableContainerRef.current;
if (!tableEl) return;
const handleScroll = () => {
setIsScrolled(tableEl.scrollLeft > 0);
};
handleScroll();
tableEl.addEventListener('scroll', handleScroll, { passive: true });
return () => {
tableEl.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const tableEl = tableContainerRef.current;
const portalEl = portalHeaderRef.current;
if (!tableEl || !portalEl) return;
const syncScrollToPortal = () => {
portalEl.scrollLeft = tableEl.scrollLeft;
};
const syncScrollToTable = () => {
tableEl.scrollLeft = portalEl.scrollLeft;
};
syncScrollToPortal();
const handleTableScroll = () => syncScrollToPortal();
const handlePortalScroll = () => syncScrollToTable();
tableEl.addEventListener('scroll', handleTableScroll, { passive: true });
return () => {
tableEl.removeEventListener('scroll', handleTableScroll);
};
}, [showPortalHeader]);
const NAME_CELL_WIDTH = 140; const NAME_CELL_WIDTH = 140;
const GAP = 12; const GAP = 12;
const LAST_COLUMN_EXTRA = 12; const LAST_COLUMN_EXTRA = 12;
@@ -270,6 +405,7 @@ export default function MobileFundTable({
estimateNav: 64, estimateNav: 64,
yesterdayChangePercent: 72, yesterdayChangePercent: 72,
estimateChangePercent: 80, estimateChangePercent: 80,
totalChangePercent: 80,
todayProfit: 80, todayProfit: 80,
holdingProfit: 80, holdingProfit: 80,
}; };
@@ -305,11 +441,12 @@ export default function MobileFundTable({
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible })); setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
}; };
// 移动端名称列:无拖拽把手,长按整行触发排序 // 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps
const MobileFundNameCell = ({ info }) => { const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet }) => {
const original = info.row.original || {}; const original = info.row.original || {};
const code = original.code; const code = original.code;
const isUpdated = original.isUpdated; const isUpdated = original.isUpdated;
const hasDca = original.hasDca;
const hasHoldingAmount = original.holdingAmountValue != null; const hasHoldingAmount = original.holdingAmountValue != null;
const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null; const holdingAmountDisplay = hasHoldingAmount ? (original.holdingAmount ?? '—') : null;
const isFavorites = favorites?.has?.(code); const isFavorites = favorites?.has?.(code);
@@ -343,7 +480,25 @@ export default function MobileFundTable({
</button> </button>
)} )}
<div className="title-text"> <div className="title-text">
<span className="name-text" title={isUpdated ? '今日净值已更新' : ''}> <span
className={`name-text ${showFullFundName ? 'show-full' : ''}`}
title={isUpdated ? '今日净值已更新' : onOpenCardSheet ? '点击查看卡片' : ''}
role={onOpenCardSheet ? 'button' : undefined}
tabIndex={onOpenCardSheet ? 0 : undefined}
style={onOpenCardSheet ? { cursor: 'pointer' } : undefined}
onClick={(e) => {
if (onOpenCardSheet) {
e.stopPropagation?.();
onOpenCardSheet(original);
}
}}
onKeyDown={(e) => {
if (onOpenCardSheet && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onOpenCardSheet(original);
}
}}
>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
{holdingAmountDisplay ? ( {holdingAmountDisplay ? (
@@ -365,6 +520,7 @@ export default function MobileFundTable({
}} }}
> >
{holdingAmountDisplay} {holdingAmountDisplay}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>} {isUpdated && <span className="updated-indicator"></span>}
</span> </span>
) : code ? ( ) : code ? (
@@ -386,6 +542,7 @@ export default function MobileFundTable({
}} }}
> >
#{code} #{code}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>} {isUpdated && <span className="updated-indicator"></span>}
</span> </span>
) : null} ) : null}
@@ -426,7 +583,13 @@ export default function MobileFundTable({
</button> </button>
</div> </div>
), ),
cell: (info) => <MobileFundNameCell info={info} />, cell: (info) => (
<MobileFundNameCell
info={info}
showFullFundName={showFullFundName}
onOpenCardSheet={getFundCardProps ? (row) => setCardSheetRow(row) : undefined}
/>
),
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName }, meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
}, },
{ {
@@ -455,7 +618,7 @@ export default function MobileFundTable({
}, },
{ {
accessorKey: 'yesterdayChangePercent', accessorKey: 'yesterdayChangePercent',
header: '昨日涨幅', header: '昨日涨幅',
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const value = original.yesterdayChangeValue; const value = original.yesterdayChangeValue;
@@ -474,24 +637,55 @@ export default function MobileFundTable({
}, },
{ {
accessorKey: 'estimateChangePercent', accessorKey: 'estimateChangePercent',
header: '估值涨幅', header: '估值涨幅',
cell: (info) => { cell: (info) => {
const original = info.row.original || {}; const original = info.row.original || {};
const value = original.estimateChangeValue; const value = original.estimateChangeValue;
const isMuted = original.estimateChangeMuted; const isMuted = original.estimateChangeMuted;
const time = original.estimateTime ?? '-'; 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' : ''; const cls = isMuted ? 'muted' : value > 0 ? 'up' : value < 0 ? 'down' : '';
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 0 }}>
<span className={cls} style={{ fontWeight: 700 }}> <span className={cls} style={{ fontWeight: 700 }}>
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
<span className="muted" style={{ fontSize: '10px' }}>{time}</span> <span className="muted" style={{ fontSize: '10px' }}>{displayTime}</span>
</div> </div>
); );
}, },
meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent }, meta: { align: 'right', cellClassName: 'est-change-cell', width: columnWidthMap.estimateChangePercent },
}, },
{
accessorKey: 'totalChangePercent',
header: '估算收益',
cell: (info) => {
const original = info.row.original || {};
const value = original.estimateProfitValue;
const hasProfit = value != null;
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (original.estimateProfit ?? '') : '—';
const percentStr = original.estimateProfitPercent ?? '';
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} estimate-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: 'total-change-cell', width: columnWidthMap.totalChangePercent },
},
{ {
accessorKey: 'todayProfit', accessorKey: 'todayProfit',
header: '当日收益', header: '当日收益',
@@ -502,6 +696,7 @@ export default function MobileFundTable({
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—'; const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? ''; const percentStr = original.todayProfitPercent ?? '';
const isUpdated = original.isUpdated;
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}> <span className={cls} style={{ display: 'block', width: '100%', fontWeight: 700 }}>
@@ -509,7 +704,7 @@ export default function MobileFundTable({
{amountStr} {amountStr}
</FitText> </FitText>
</span> </span>
{percentStr ? ( {percentStr && !isUpdated ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} today-profit-percent`} style={{ display: 'block', width: '100%', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}> <FitText maxFontSize={11} minFontSize={9}>
{percentStr} {percentStr}
@@ -551,7 +746,7 @@ export default function MobileFundTable({
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit }, meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
}, },
], ],
[currentTab, favorites, refreshing, columnWidthMap] [currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps]
); );
const table = useReactTable({ const table = useReactTable({
@@ -652,124 +847,209 @@ export default function MobileFundTable({
})(); })();
const getPinClass = (columnId, isHeader) => { const getPinClass = (columnId, isHeader) => {
if (columnId === 'fundName') return isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left'; if (columnId === 'fundName') {
const baseClass = isHeader ? 'table-header-cell-pin-left' : 'table-cell-pin-left';
const scrolledClass = isScrolled ? 'is-scrolled' : '';
return `${baseClass} ${scrolledClass}`.trim();
}
return ''; return '';
}; };
const getAlignClass = (columnId) => { const getAlignClass = (columnId) => {
if (columnId === 'fundName') return ''; if (columnId === 'fundName') return '';
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right'; if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
return 'text-right'; return 'text-right';
}; };
return ( const renderTableHeader = ()=>{
<div className="mobile-fund-table" ref={tableContainerRef}> if(!headerGroup) return null;
return (
<div <div
className="mobile-fund-table-scroll" className="table-header-row mobile-fund-table-header"
style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined} style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined}
> >
{headerGroup && ( {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>
)
}
const renderContent = (onlyShowHeader) => {
if (onlyShowHeader) {
return (
<div style={{position: 'fixed', top: effectiveStickyTop}} className="mobile-fund-table mobile-fund-table-portal-header" ref={portalHeaderRef}>
<div <div
className="table-header-row mobile-fund-table-header" className="mobile-fund-table-scroll"
style={mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : undefined} style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
> >
{headerGroup.headers.map((header, headerIndex) => { {renderTableHeader()}
const columnId = header.column.id; </div>
const pinClass = getPinClass(columnId, true); </div>
const alignClass = getAlignClass(columnId); );
const isLastColumn = headerIndex === headerGroup.headers.length - 1; }
return (
<div return (
key={header.id} <div className="mobile-fund-table" ref={tableContainerRef}>
className={`table-header-cell ${alignClass} ${pinClass}`} <div
style={isLastColumn ? { paddingRight: LAST_COLUMN_EXTRA } : undefined} className="mobile-fund-table-scroll"
> style={mobileGridLayout.minWidth != null ? { minWidth: mobileGridLayout.minWidth } : undefined}
{header.isPlaceholder >
? null {renderTableHeader()}
: flexRender(header.column.columnDef.header, header.getContext())}
</div> {!onlyShowHeader && (
); <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 && !onlyShowHeader && (
<div className="table-row empty-row">
<div className="table-cell" style={{ textAlign: 'center' }}>
<span className="muted">暂无数据</span>
</div>
</div> </div>
)} )}
<DndContext {!onlyShowHeader && (
sensors={sensors} <MobileSettingModal
collisionDetection={closestCenter} open={settingModalOpen}
onDragStart={handleDragStart} onClose={() => setSettingModalOpen(false)}
onDragEnd={handleDragEnd} columns={mobileColumnOrder.map((id) => ({ id, header: MOBILE_COLUMN_HEADERS[id] ?? id }))}
onDragCancel={handleDragCancel} columnVisibility={mobileColumnVisibility}
modifiers={[restrictToVerticalAxis, restrictToParentElement]} onColumnReorder={(newOrder) => {
setMobileColumnOrder(newOrder);
}}
onToggleColumnVisibility={handleToggleMobileColumnVisibility}
onResetColumnOrder={handleResetMobileColumnOrder}
onResetColumnVisibility={handleResetMobileColumnVisibility}
showFullFundName={showFullFundName}
onToggleShowFullFundName={handleToggleShowFullFundName}
/>
)}
<Drawer
open={!!(cardSheetRow && getFundCardProps)}
onOpenChange={(open) => {
if (!open) {
if (ignoreNextDrawerCloseRef.current) {
ignoreNextDrawerCloseRef.current = false;
return;
}
if (!blockDrawerClose) setCardSheetRow(null);
}
}}
> >
<SortableContext <DrawerContent
items={data.map((item) => item.code)} className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
strategy={verticalListSortingStrategy} onPointerDownOutside={(e) => {
if (blockDrawerClose) return;
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
ignoreNextDrawerCloseRef.current = true;
return;
}
setCardSheetRow(null);
}}
> >
<AnimatePresence mode="popLayout"> <DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
{table.getRowModel().rows.map((row) => ( <DrawerTitle className="text-base font-semibold text-[var(--text)]">
<SortableRow 基金详情
key={row.original.code || row.id} </DrawerTitle>
row={row} <DrawerClose
isTableDragging={!!activeId} className="icon-button border-none bg-transparent p-1"
disabled={sortBy !== 'default'} title="关闭"
> style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
{(setActivatorNodeRef, listeners) => ( >
<div <CloseIcon width="20" height="20" />
ref={sortBy === 'default' ? setActivatorNodeRef : undefined} </DrawerClose>
className="table-row" </DrawerHeader>
style={{ <div
background: 'var(--bg)', className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
position: 'relative', style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
zIndex: 1, >
...(mobileGridLayout.gridTemplateColumns ? { gridTemplateColumns: mobileGridLayout.gridTemplateColumns } : {}), {cardSheetRow && getFundCardProps ? (
}} <FundCard {...getFundCardProps(cardSheetRow)} />
{...(sortBy === 'default' ? listeners : {})} ) : null}
> </div>
{row.getVisibleCells().map((cell, cellIndex) => { </DrawerContent>
const columnId = cell.column.id; </Drawer>
const pinClass = getPinClass(columnId, false);
const alignClass = getAlignClass(columnId); {!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
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> </div>
);
};
{table.getRowModel().rows.length === 0 && ( return (
<div className="table-row empty-row"> <>
<div className="table-cell" style={{ textAlign: 'center' }}> {renderContent()}
<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

@@ -1,13 +1,20 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion'; import { AnimatePresence, Reorder } from 'framer-motion';
import { createPortal } from 'react-dom'; import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerClose,
} from '@/components/ui/drawer';
import { Switch } from '@/components/ui/switch';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons'; import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
/** /**
* 移动端表格个性化设置弹框(底部抽屉) * 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件
* @param {Object} props * @param {Object} props
* @param {boolean} props.open - 是否打开 * @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调 * @param {() => void} props.onClose - 关闭回调
@@ -17,6 +24,8 @@ import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
* @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调 * @param {(id: string, visible: boolean) => void} props.onToggleColumnVisibility - 列显示/隐藏切换回调
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调 * @param {() => void} props.onResetColumnOrder - 重置列顺序回调
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调 * @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
*/ */
export default function MobileSettingModal({ export default function MobileSettingModal({
open, open,
@@ -27,6 +36,8 @@ export default function MobileSettingModal({
onToggleColumnVisibility, onToggleColumnVisibility,
onResetColumnOrder, onResetColumnOrder,
onResetColumnVisibility, onResetColumnVisibility,
showFullFundName,
onToggleShowFullFundName,
}) { }) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false); const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
@@ -34,187 +45,169 @@ export default function MobileSettingModal({
if (!open) setResetConfirmOpen(false); if (!open) setResetConfirmOpen(false);
}, [open]); }, [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 handleReorder = (newItems) => {
const newOrder = newItems.map((item) => item.id); const newOrder = newItems.map((item) => item.id);
onColumnReorder?.(newOrder); onColumnReorder?.(newOrder);
}; };
const content = ( return (
<AnimatePresence> <>
{open && ( <Drawer
<motion.div open={open}
key="mobile-setting-overlay" onOpenChange={(v) => {
className="mobile-setting-overlay" if (!v) onClose();
role="dialog" }}
aria-modal="true" direction="bottom"
aria-label="个性化设置" >
initial={{ opacity: 0 }} <DrawerContent
animate={{ opacity: 1 }} className="glass"
exit={{ opacity: 0 }} defaultHeight="77vh"
transition={{ duration: 0.2 }} minHeight="40vh"
onClick={onClose} maxHeight="90vh"
style={{ zIndex: 10001 }}
> >
<motion.div <DrawerHeader className="mobile-setting-header flex-row items-center justify-between gap-2 py-5 pt-5 text-base font-semibold">
className="mobile-setting-drawer glass" <DrawerTitle className="flex items-center gap-2.5 text-left">
initial={{ y: '100%' }} <SettingsIcon width="20" height="20" />
animate={{ y: 0 }} <span>个性化设置</span>
exit={{ y: '100%' }} </DrawerTitle>
transition={{ type: 'spring', damping: 30, stiffness: 300 }} <DrawerClose
onClick={(e) => e.stopPropagation()} className="icon-button border-none bg-transparent p-1"
> title="关闭"
<div className="mobile-setting-header"> style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> >
<SettingsIcon width="20" height="20" /> <CloseIcon width="20" height="20" />
<span>个性化设置</span> </DrawerClose>
</div> </DrawerHeader>
<button
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: 'none', background: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="mobile-setting-body"> <div className="mobile-setting-body flex flex-1 flex-col overflow-y-auto">
<h3 className="mobile-setting-subtitle">表头设置</h3> {onToggleShowFullFundName && (
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 12, padding: '12px 0',
gap: 8, borderBottom: '1px solid var(--border)',
marginBottom: 16,
}} }}
> >
<p className="muted" style={{ fontSize: '13px', margin: 0 }}> <span style={{ fontSize: '14px' }}>展示完整基金名称</span>
拖拽调整列顺序 <Switch
</p> checked={!!showFullFundName}
{(onResetColumnOrder || onResetColumnVisibility) && ( onCheckedChange={(checked) => {
<button onToggleShowFullFundName?.(!!checked);
className="icon-button" }}
onClick={() => setResetConfirmOpen(true)} title={showFullFundName ? '关闭' : '开启'}
title="重置表头设置" />
style={{
border: 'none',
width: '28px',
height: '28px',
backgroundColor: 'transparent',
color: 'var(--muted)',
flexShrink: 0,
}}
>
<ResetIcon width="16" height="16" />
</button>
)}
</div> </div>
{columns.length === 0 ? ( )}
<div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}> <h3 className="mobile-setting-subtitle">表头设置</h3>
暂无可配置列 <div
</div> style={{
) : ( display: 'flex',
<Reorder.Group alignItems: 'center',
axis="y" justifyContent: 'space-between',
values={columns} marginBottom: 12,
onReorder={handleReorder} gap: 8,
className="mobile-setting-list" }}
>
<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,
}}
> >
<AnimatePresence mode="popLayout"> <ResetIcon width="16" height="16" />
{columns.map((item, index) => ( </button>
<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> </div>
</motion.div> {columns.length === 0 ? (
</motion.div> <div className="muted" style={{ textAlign: 'center', padding: '24px 0', fontSize: '14px' }}>
)} 暂无可配置列
{resetConfirmOpen && ( </div>
<ConfirmModal ) : (
key="mobile-reset-confirm" <Reorder.Group
title="重置表头设置" axis="y"
message="是否重置表头顺序和显示/隐藏为默认值?" values={columns}
onConfirm={() => { onReorder={handleReorder}
onResetColumnOrder?.(); className="mobile-setting-list"
onResetColumnVisibility?.(); >
setResetConfirmOpen(false); <AnimatePresence mode="popLayout">
}} {columns.map((item, index) => (
onCancel={() => setResetConfirmOpen(false)} <Reorder.Item
confirmText="重置" key={item.id || `col-${index}`}
/> value={item}
)} className="mobile-setting-item glass"
</AnimatePresence> 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 && (
<Switch
checked={columnVisibility?.[item.id] !== false}
onCheckedChange={(checked) => {
onToggleColumnVisibility(item.id, !!checked);
}}
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
/>
)}
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
)}
</div>
</DrawerContent>
</Drawer>
if (typeof document === 'undefined') return null; <AnimatePresence>
return createPortal(content, document.body); {resetConfirmOpen && (
<ConfirmModal
key="mobile-reset-confirm"
title="重置表头设置"
message="是否重置表头顺序和显示/隐藏为默认值?"
onConfirm={() => {
onResetColumnOrder?.();
onResetColumnVisibility?.();
setResetConfirmOpen(false);
}}
onCancel={() => setResetConfirmOpen(false)}
confirmText="重置"
/>
)}
</AnimatePresence>
</>
);
} }

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import ReactDOM from 'react-dom';
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from 'lodash';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
flexRender, flexRender,
@@ -25,11 +27,19 @@ import { CSS } from '@dnd-kit/utilities';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import FitText from './FitText'; import FitText from './FitText';
import PcTableSettingModal from './PcTableSettingModal'; import PcTableSettingModal from './PcTableSettingModal';
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons'; import FundCard from './FundCard';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon } from './Icons';
const NON_FROZEN_COLUMN_IDS = [ const NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent', 'yesterdayChangePercent',
'estimateChangePercent', 'estimateChangePercent',
'totalChangePercent',
'holdingAmount', 'holdingAmount',
'todayProfit', 'todayProfit',
'holdingProfit', 'holdingProfit',
@@ -39,8 +49,9 @@ const NON_FROZEN_COLUMN_IDS = [
const COLUMN_HEADERS = { const COLUMN_HEADERS = {
latestNav: '最新净值', latestNav: '最新净值',
estimateNav: '估算净值', estimateNav: '估算净值',
yesterdayChangePercent: '昨日涨幅', yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅', estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
holdingAmount: '持仓金额', holdingAmount: '持仓金额',
todayProfit: '当日收益', todayProfit: '当日收益',
holdingProfit: '持有收益', holdingProfit: '持有收益',
@@ -103,8 +114,8 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
* code?: string; // 基金代码(可选,只用于展示在名称下方) * code?: string; // 基金代码(可选,只用于展示在名称下方)
* latestNav: string|number; // 最新净值 * latestNav: string|number; // 最新净值
* estimateNav: string|number; // 估算净值 * estimateNav: string|number; // 估算净值
* yesterdayChangePercent: string|number; // 昨日涨 * yesterdayChangePercent: string|number; // 昨日涨幅
* estimateChangePercent: string|number; // 估值涨 * estimateChangePercent: string|number; // 估值涨幅
* holdingAmount: string|number; // 持仓金额 * holdingAmount: string|number; // 持仓金额
* todayProfit: string|number; // 当日收益 * todayProfit: string|number; // 当日收益
* holdingProfit: string|number; // 持有收益 * holdingProfit: string|number; // 持有收益
@@ -116,6 +127,10 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
* @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除 * @param {(row: any) => void} [props.onRemoveFromGroup] - 从当前分组移除
* @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额 * @param {(row: any, meta: { hasHolding: boolean }) => void} [props.onHoldingAmountClick] - 点击持仓金额
* @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态) * @param {boolean} [props.refreshing] - 是否处于刷新状态(控制删除按钮禁用态)
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props传入后点击基金名称将用弹框展示卡片详情
* @param {React.MutableRefObject<(() => void) | null>} [props.closeDialogRef] - 注入关闭弹框的方法,用于确认删除时关闭
* @param {boolean} [props.blockDialogClose] - 为 true 时阻止点击遮罩关闭弹框(如删除确认弹框打开时)
* @param {number} [props.stickyTop] - 表头固定时的 top 偏移(与 MobileFundTable 一致,用于适配导航栏、筛选栏等)
*/ */
export default function PcFundTable({ export default function PcFundTable({
data = [], data = [],
@@ -130,6 +145,10 @@ export default function PcFundTable({
sortBy = 'default', sortBy = 'default',
onReorder, onReorder,
onCustomSettingsChange, onCustomSettingsChange,
getFundCardProps,
closeDialogRef,
blockDialogClose = false,
stickyTop = 0,
}) { }) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -141,6 +160,12 @@ export default function PcFundTable({
); );
const [activeId, setActiveId] = useState(null); const [activeId, setActiveId] = useState(null);
const [cardDialogRow, setCardDialogRow] = useState(null);
const tableContainerRef = useRef(null);
const portalHeaderRef = useRef(null);
const [showPortalHeader, setShowPortalHeader] = useState(false);
const [effectiveStickyTop, setEffectiveStickyTop] = useState(stickyTop);
const [portalHorizontal, setPortalHorizontal] = useState({ left: 0, right: 0 });
const handleDragStart = (event) => { const handleDragStart = (event) => {
setActiveId(event.active.id); setActiveId(event.active.id);
@@ -234,6 +259,7 @@ export default function PcFundTable({
})() : null, })() : null,
pcTableColumnVisibility: pc.visibility, pcTableColumnVisibility: pc.visibility,
pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null, pcTableColumns: Object.keys(pc.sizing).length ? pc.sizing : null,
pcShowFullFundName: group.pcShowFullFundName === true,
}; };
} }
}); });
@@ -243,6 +269,7 @@ export default function PcFundTable({
const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup); const [configByGroup, setConfigByGroup] = useState(getInitialConfigByGroup);
const currentGroupPc = configByGroup[groupKey]; const currentGroupPc = configByGroup[groupKey];
const showFullFundName = currentGroupPc?.pcShowFullFundName ?? false;
const defaultPc = getDefaultPcGroupConfig(); const defaultPc = getDefaultPcGroupConfig();
const columnOrder = (() => { const columnOrder = (() => {
const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order; const order = currentGroupPc?.pcTableColumnOrder ?? defaultPc.order;
@@ -280,6 +307,7 @@ export default function PcFundTable({
if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder; if (updates.pcTableColumnOrder !== undefined) group.pcTableColumnOrder = updates.pcTableColumnOrder;
if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility; if (updates.pcTableColumnVisibility !== undefined) group.pcTableColumnVisibility = updates.pcTableColumnVisibility;
if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns; if (updates.pcTableColumns !== undefined) group.pcTableColumns = updates.pcTableColumns;
if (updates.pcShowFullFundName !== undefined) group.pcShowFullFundName = updates.pcShowFullFundName;
parsed[groupKey] = group; parsed[groupKey] = group;
window.localStorage.setItem('customSettings', JSON.stringify(parsed)); window.localStorage.setItem('customSettings', JSON.stringify(parsed));
setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } })); setConfigByGroup((prev) => ({ ...prev, [groupKey]: { ...prev[groupKey], ...updates } }));
@@ -287,6 +315,10 @@ export default function PcFundTable({
} catch { } } catch { }
}; };
const handleToggleShowFullFundName = (show) => {
persistPcGroupConfig({ pcShowFullFundName: show });
};
const setColumnOrder = (nextOrderOrUpdater) => { const setColumnOrder = (nextOrderOrUpdater) => {
const next = typeof nextOrderOrUpdater === 'function' const next = typeof nextOrderOrUpdater === 'function'
? nextOrderOrUpdater(columnOrder) ? nextOrderOrUpdater(columnOrder)
@@ -332,6 +364,13 @@ export default function PcFundTable({
const onRemoveFromGroupRef = useRef(onRemoveFromGroup); const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
const onHoldingAmountClickRef = useRef(onHoldingAmountClick); const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
useEffect(() => {
if (closeDialogRef) {
closeDialogRef.current = () => setCardDialogRow(null);
return () => { closeDialogRef.current = null; };
}
}, [closeDialogRef]);
useEffect(() => { useEffect(() => {
onRemoveFundRef.current = onRemoveFund; onRemoveFundRef.current = onRemoveFund;
onToggleFavoriteRef.current = onToggleFavorite; onToggleFavoriteRef.current = onToggleFavorite;
@@ -344,22 +383,104 @@ export default function PcFundTable({
onHoldingAmountClick, onHoldingAmountClick,
]); ]);
const FundNameCell = ({ info }) => { useEffect(() => {
if (typeof window === 'undefined') return;
const getEffectiveStickyTop = () => {
const stickySummaryCard = document.querySelector('.group-summary-sticky .group-summary-card');
if (!stickySummaryCard) return stickyTop;
const stickySummaryWrapper = stickySummaryCard.closest('.group-summary-sticky');
if (!stickySummaryWrapper) return stickyTop;
const wrapperRect = stickySummaryWrapper.getBoundingClientRect();
const isSummaryStuck = wrapperRect.top <= stickyTop + 1;
return isSummaryStuck ? stickyTop + stickySummaryWrapper.offsetHeight : stickyTop;
};
const updateVerticalState = () => {
const nextStickyTop = getEffectiveStickyTop();
setEffectiveStickyTop((prev) => (prev === nextStickyTop ? prev : nextStickyTop));
const tableEl = tableContainerRef.current;
const scrollEl = tableEl?.closest('.table-scroll-area');
const targetEl = scrollEl || tableEl;
const rect = targetEl?.getBoundingClientRect();
if (!rect) {
setShowPortalHeader(window.scrollY >= nextStickyTop);
return;
}
setShowPortalHeader(rect.top <= nextStickyTop);
setPortalHorizontal((prev) => {
const next = {
left: rect.left,
right: typeof window !== 'undefined' ? Math.max(0, window.innerWidth - rect.right) : 0,
};
if (prev.left === next.left && prev.right === next.right) return prev;
return next;
});
};
const throttledVerticalUpdate = throttle(updateVerticalState, 1000 / 60, { leading: true, trailing: true });
updateVerticalState();
window.addEventListener('scroll', throttledVerticalUpdate, { passive: true });
window.addEventListener('resize', throttledVerticalUpdate, { passive: true });
return () => {
window.removeEventListener('scroll', throttledVerticalUpdate);
window.removeEventListener('resize', throttledVerticalUpdate);
throttledVerticalUpdate.cancel();
};
}, [stickyTop]);
useEffect(() => {
const tableEl = tableContainerRef.current;
const portalEl = portalHeaderRef.current;
const scrollEl = tableEl?.closest('.table-scroll-area');
if (!scrollEl || !portalEl) return;
const syncScrollToPortal = () => {
portalEl.scrollLeft = scrollEl.scrollLeft;
};
const syncScrollToTable = () => {
scrollEl.scrollLeft = portalEl.scrollLeft;
};
syncScrollToPortal();
const handleTableScroll = () => syncScrollToPortal();
const handlePortalScroll = () => syncScrollToTable();
scrollEl.addEventListener('scroll', handleTableScroll, { passive: true });
portalEl.addEventListener('scroll', handlePortalScroll, { passive: true });
return () => {
scrollEl.removeEventListener('scroll', handleTableScroll);
portalEl.removeEventListener('scroll', handlePortalScroll);
};
}, [showPortalHeader]);
const FundNameCell = ({ info, showFullFundName, onOpenCardDialog }) => {
const original = info.row.original || {}; const original = info.row.original || {};
const code = original.code; const code = original.code;
const isUpdated = original.isUpdated; const isUpdated = original.isUpdated;
const hasDca = original.hasDca;
const isFavorites = favorites?.has?.(code); const isFavorites = favorites?.has?.(code);
const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav'; const isGroupTab = currentTab && currentTab !== 'all' && currentTab !== 'fav';
const rowContext = useContext(SortableRowContext); const rowContext = useContext(SortableRowContext);
return ( return (
<div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="name-cell-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 8 }}>
{sortBy === 'default' && ( {sortBy === 'default' && (
<button <button
className="icon-button drag-handle" className="icon-button drag-handle"
ref={rowContext?.setActivatorNodeRef} ref={rowContext?.setActivatorNodeRef}
{...rowContext?.listeners} {...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' }} style={{ cursor: 'grab', width: 20, height: 20, padding: 2, margin: '0', flexShrink: 0, color: 'var(--muted)', background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="拖拽排序" title="拖拽排序"
onClick={(e) => e.stopPropagation?.()} onClick={(e) => e.stopPropagation?.()}
> >
@@ -390,15 +511,24 @@ export default function PcFundTable({
<StarIcon width="18" height="18" filled={isFavorites} /> <StarIcon width="18" height="18" filled={isFavorites} />
</button> </button>
)} )}
<div className="title-text"> <div
className="title-text"
role={onOpenCardDialog ? 'button' : undefined}
tabIndex={onOpenCardDialog ? 0 : undefined}
onClick={onOpenCardDialog ? (e) => { e.stopPropagation?.(); onOpenCardDialog(original); } : undefined}
onKeyDown={onOpenCardDialog ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenCardDialog(original); } } : undefined}
style={onOpenCardDialog ? { cursor: 'pointer' } : undefined}
title={onOpenCardDialog ? '查看基金详情' : (original.isUpdated ? '今日净值已更新' : undefined)}
>
<span <span
className={`name-text`} className={`name-text ${showFullFundName ? 'show-full' : ''}`}
title={isUpdated ? '今日净值已更新' : ''} title={isUpdated ? '今日净值已更新' : ''}
> >
{info.getValue() ?? '—'} {info.getValue() ?? '—'}
</span> </span>
{code ? <span className="muted code-text"> {code ? <span className="muted code-text">
#{code} #{code}
{hasDca && <span className="dca-indicator"></span>}
{isUpdated && <span className="updated-indicator"></span>} {isUpdated && <span className="updated-indicator"></span>}
</span> : null} </span> : null}
</div> </div>
@@ -414,7 +544,13 @@ export default function PcFundTable({
size: 265, size: 265,
minSize: 140, minSize: 140,
enablePinning: true, enablePinning: true,
cell: (info) => <FundNameCell info={info} />, cell: (info) => (
<FundNameCell
info={info}
showFullFundName={showFullFundName}
onOpenCardDialog={getFundCardProps ? (row) => setCardDialogRow(row) : undefined}
/>
),
meta: { meta: {
align: 'left', align: 'left',
cellClassName: 'name-cell', cellClassName: 'name-cell',
@@ -452,7 +588,7 @@ export default function PcFundTable({
}, },
{ {
accessorKey: 'yesterdayChangePercent', accessorKey: 'yesterdayChangePercent',
header: '昨日涨幅', header: '昨日涨幅',
size: 135, size: 135,
minSize: 100, minSize: 100,
cell: (info) => { cell: (info) => {
@@ -478,7 +614,7 @@ export default function PcFundTable({
}, },
{ {
accessorKey: 'estimateChangePercent', accessorKey: 'estimateChangePercent',
header: '估值涨幅', header: '估值涨幅',
size: 135, size: 135,
minSize: 100, minSize: 100,
cell: (info) => { cell: (info) => {
@@ -503,6 +639,39 @@ export default function PcFundTable({
cellClassName: 'est-change-cell', cellClassName: 'est-change-cell',
}, },
}, },
{
accessorKey: 'totalChangePercent',
header: '估算收益',
size: 135,
minSize: 100,
cell: (info) => {
const original = info.row.original || {};
const value = original.estimateProfitValue;
const hasProfit = value != null;
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (original.estimateProfit ?? '') : '—';
const percentStr = original.estimateProfitPercent ?? '';
return (
<div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr}
</FitText>
{percentStr ? (
<span className={`${cls} estimate-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: 'total-change-cell',
},
},
{ {
accessorKey: 'holdingAmount', accessorKey: 'holdingAmount',
header: '持仓金额', header: '持仓金额',
@@ -578,12 +747,13 @@ export default function PcFundTable({
const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted'; const cls = hasProfit ? (value > 0 ? 'up' : value < 0 ? 'down' : '') : 'muted';
const amountStr = hasProfit ? (info.getValue() ?? '') : '—'; const amountStr = hasProfit ? (info.getValue() ?? '') : '—';
const percentStr = original.todayProfitPercent ?? ''; const percentStr = original.todayProfitPercent ?? '';
const isUpdated = original.isUpdated;
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}> <FitText className={cls} style={{ fontWeight: 700, display: 'block' }} maxFontSize={14} minFontSize={10}>
{amountStr} {amountStr}
</FitText> </FitText>
{percentStr ? ( {percentStr && !isUpdated ? (
<span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}> <span className={`${cls} today-profit-percent`} style={{ display: 'block', fontSize: '0.75em', opacity: 0.9, fontWeight: 500 }}>
<FitText maxFontSize={11} minFontSize={9}> <FitText maxFontSize={11} minFontSize={9}>
{percentStr} {percentStr}
@@ -668,7 +838,7 @@ export default function PcFundTable({
}; };
return ( return (
<div className="row" style={{ justifyContent: 'center', gap: 4 }}> <div className="row" style={{ justifyContent: 'center', gap: 4, padding: '8px 0' }}>
<button <button
className="icon-button danger" className="icon-button danger"
onClick={handleClick} onClick={handleClick}
@@ -688,7 +858,7 @@ export default function PcFundTable({
}, },
}, },
], ],
[currentTab, favorites, refreshing, sortBy], [currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps],
); );
const table = useReactTable({ const table = useReactTable({
@@ -754,8 +924,47 @@ export default function PcFundTable({
}; };
}; };
const renderTableHeader = (forPortal = false) => {
if (!headerGroup) return null;
return (
<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(),
)}
{!forPortal && (
<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>
);
};
const totalHeaderWidth = headerGroup?.headers?.reduce((acc, h) => acc + h.column.getSize(), 0) ?? 0;
return ( return (
<div className="pc-fund-table"> <div className="pc-fund-table" ref={tableContainerRef}>
<style>{` <style>{`
.table-row-scroll { .table-row-scroll {
--row-bg: var(--bg); --row-bg: var(--bg);
@@ -830,37 +1039,7 @@ export default function PcFundTable({
} }
`}</style> `}</style>
{/* 表头 */} {/* 表头 */}
{headerGroup && ( {renderTableHeader(false)}
<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 <DndContext
@@ -889,6 +1068,7 @@ export default function PcFundTable({
'estimateNav', 'estimateNav',
'yesterdayChangePercent', 'yesterdayChangePercent',
'estimateChangePercent', 'estimateChangePercent',
'totalChangePercent',
'holdingAmount', 'holdingAmount',
'todayProfit', 'todayProfit',
'holdingProfit', 'holdingProfit',
@@ -949,7 +1129,87 @@ export default function PcFundTable({
onResetColumnOrder={handleResetColumnOrder} onResetColumnOrder={handleResetColumnOrder}
onResetColumnVisibility={handleResetColumnVisibility} onResetColumnVisibility={handleResetColumnVisibility}
onResetSizing={() => setResetConfirmOpen(true)} onResetSizing={() => setResetConfirmOpen(true)}
showFullFundName={showFullFundName}
onToggleShowFullFundName={handleToggleShowFullFundName}
/> />
<Dialog
open={!!(cardDialogRow && getFundCardProps)}
onOpenChange={(open) => {
if (!open && !blockDialogClose) setCardDialogRow(null);
}}
>
<DialogContent
className="sm:max-w-2xl max-h-[88vh] flex flex-col p-0 overflow-hidden"
showCloseButton={false}
onPointerDownOutside={blockDialogClose ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-6 pb-4 pt-6 text-left border-b border-[var(--border)]">
<DialogTitle className="text-base font-semibold text-[var(--text)]">
基金详情
</DialogTitle>
<button
type="button"
className="icon-button rounded-lg"
aria-label="关闭"
onClick={() => setCardDialogRow(null)}
style={{ padding: 4, borderColor: 'transparent' }}
>
<CloseIcon width="20" height="20" />
</button>
</DialogHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
>
{cardDialogRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
) : null}
</div>
</DialogContent>
</Dialog>
{showPortalHeader && ReactDOM.createPortal(
<div
className="pc-fund-table pc-fund-table-portal-header"
ref={portalHeaderRef}
style={{
position: 'fixed',
top: effectiveStickyTop,
left: portalHorizontal.left,
right: portalHorizontal.right,
zIndex: 10,
overflowX: 'auto',
scrollbarWidth: 'none',
}}
>
<div
className="table-header-row table-header-row-scroll"
style={{ minWidth: totalHeaderWidth, width: 'fit-content' }}
>
{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>
);
})}
</div>
</div>,
document.body
)}
</div> </div>
); );
} }

View File

@@ -18,6 +18,8 @@ import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
* @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认 * @param {() => void} props.onResetColumnOrder - 重置列顺序回调,需二次确认
* @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调 * @param {() => void} props.onResetColumnVisibility - 重置列显示/隐藏回调
* @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框) * @param {() => void} props.onResetSizing - 点击重置列宽时的回调(通常用于打开确认弹框)
* @param {boolean} [props.showFullFundName] - 是否展示完整基金名称
* @param {(show: boolean) => void} [props.onToggleShowFullFundName] - 切换是否展示完整基金名称回调
*/ */
export default function PcTableSettingModal({ export default function PcTableSettingModal({
open, open,
@@ -29,6 +31,8 @@ export default function PcTableSettingModal({
onResetColumnOrder, onResetColumnOrder,
onResetColumnVisibility, onResetColumnVisibility,
onResetSizing, onResetSizing,
showFullFundName,
onToggleShowFullFundName,
}) { }) {
const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false); const [resetOrderConfirmOpen, setResetOrderConfirmOpen] = useState(false);
@@ -91,6 +95,45 @@ export default function PcTableSettingModal({
</div> </div>
<div className="pc-table-setting-body"> <div className="pc-table-setting-body">
{onToggleShowFullFundName && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 0',
borderBottom: '1px solid var(--border)',
marginBottom: 16,
}}
>
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
<button
type="button"
className="icon-button pc-table-column-switch"
onClick={(e) => {
e.stopPropagation();
onToggleShowFullFundName(!showFullFundName);
}}
title={showFullFundName ? '关闭' : '开启'}
style={{
border: 'none',
padding: '0 4px',
backgroundColor: 'transparent',
cursor: 'pointer',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
}}
>
<span className={`dca-toggle-track ${showFullFundName ? 'enabled' : ''}`}>
<span
className="dca-toggle-thumb"
style={{ left: showFullFundName ? 16 : 2 }}
/>
</span>
</button>
</div>
)}
<h3 className="pc-table-setting-subtitle">表头设置</h3> <h3 className="pc-table-setting-subtitle">表头设置</h3>
<div <div
style={{ style={{

View File

@@ -1,7 +1,15 @@
'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';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function ScanImportConfirmModal({ export default function ScanImportConfirmModal({
scannedFunds, scannedFunds,
@@ -9,8 +17,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 +51,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,50 +59,93 @@ 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' }}> <>
{scannedFunds.map((item) => { <div className="search-results pending-list" style={{ maxHeight: 360, overflowY: 'auto' }}>
const isSelected = selectedScannedCodes.has(item.code); {scannedFunds.map((item) => {
const isAlreadyAdded = item.status === 'added'; const isSelected = selectedScannedCodes.has(item.code);
const isInvalid = item.status === 'invalid'; const isAlreadyAdded = item.status === 'added';
const isDisabled = isAlreadyAdded || isInvalid; const isInvalid = item.status === 'invalid';
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金'); const isDisabled = isAlreadyAdded || isInvalid;
return ( const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
<div const holdAmounts = formatAmount(item.holdAmounts);
key={item.code} const holdGains = formatAmount(item.holdGains);
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`} const hasHoldingData = holdAmounts !== null && holdGains !== null;
onMouseDown={(e) => e.preventDefault()} return (
onClick={() => { <div
if (isDisabled) return; key={item.code}
onToggle(item.code); className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
}} onMouseDown={(e) => e.preventDefault()}
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }} onClick={() => {
> if (isDisabled) return;
<div className="fund-info"> onToggle(item.code);
<span className="fund-name">{displayName}</span> }}
<span className="fund-code muted">#{item.code}</span> style={{ cursor: isDisabled ? 'not-allowed' : 'pointer', flexDirection: 'column', alignItems: 'stretch' }}
</div> >
{isAlreadyAdded ? ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span className="added-label">已添加</span> <div className="fund-info">
) : isInvalid ? ( <span className="fund-name">{displayName}</span>
<span className="added-label">未找到</span> <span className="fund-code muted">#{item.code}</span>
) : ( </div>
<div className="checkbox"> {isAlreadyAdded ? (
{isSelected && <div className="checked-mark" />} <span className="added-label">已添加</span>
) : isInvalid ? (
<span className="added-label">未找到</span>
) : (
<div className="checkbox">
{isSelected && <div className="checked-mark" />}
</div>
)}
</div> </div>
)} {hasHoldingData && !isDisabled && (
</div> <div style={{ display: 'flex', gap: 16, marginTop: 6, paddingLeft: 0 }}>
); {holdAmounts !== null && (
})} <span className="muted" style={{ fontSize: 12 }}>
</div> 持有金额<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 style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: 13, whiteSpace: 'nowrap' }}>添加到分组</span>
<Select value={selectedGroupId} onValueChange={(value) => setSelectedGroupId(value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择分组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="fav">自选</SelectItem>
{groups.filter(g => g.id !== 'all' && g.id !== 'fav').map(g => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</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

@@ -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>

View File

@@ -1,3 +1,9 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root { :root {
--bg: #0f172a; --bg: #0f172a;
--card: #111827; --card: #111827;
@@ -10,6 +16,40 @@
--border: #1f2937; --border: #1f2937;
--table-pinned-header-bg: #2a394b; --table-pinned-header-bg: #2a394b;
--table-row-hover-bg: #2a394b; --table-row-hover-bg: #2a394b;
--radius: 0.625rem;
--background: #0f172a;
--foreground: #e5e7eb;
--card-foreground: #e5e7eb;
--popover: #111827;
--popover-foreground: #e5e7eb;
--primary-foreground: #0f172a;
--secondary: #1f2937;
--secondary-foreground: #e5e7eb;
--muted-foreground: #9ca3af;
--accent-foreground: #e5e7eb;
--destructive: #f87171;
--input: #1f2937;
--ring: #22d3ee;
--chart-1: #22d3ee;
--chart-2: #60a5fa;
--chart-3: #34d399;
--chart-4: #f472b6;
--chart-5: #fbbf24;
--sidebar: #111827;
--sidebar-foreground: #e5e7eb;
--sidebar-primary: #22d3ee;
--sidebar-primary-foreground: #0f172a;
--sidebar-accent: #1f2937;
--sidebar-accent-foreground: #e5e7eb;
--sidebar-border: #1f2937;
--sidebar-ring: #22d3ee;
--drawer-overlay: rgba(2, 6, 23, 0.5);
--dialog-overlay: rgba(2, 6, 23, 0.6);
--tabs-list-bg: rgba(255, 255, 255, 0.04);
--tabs-list-border: transparent;
--tabs-trigger-active-bg: rgba(34, 211, 238, 0.12);
--tabs-trigger-active-text: var(--primary);
--switch-thumb: var(--foreground);
} }
/* 亮色主题ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */ /* 亮色主题ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */
@@ -25,6 +65,39 @@
--border: #e2e8f0; --border: #e2e8f0;
--table-pinned-header-bg: #e2e8f0; --table-pinned-header-bg: #e2e8f0;
--table-row-hover-bg: #e2e8f0; --table-row-hover-bg: #e2e8f0;
--background: #ffffff;
--foreground: #0f172a;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted-foreground: #475569;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--input: #e2e8f0;
--ring: #0891b2;
--chart-1: #0891b2;
--chart-2: #2563eb;
--chart-3: #059669;
--chart-4: #db2777;
--chart-5: #ca8a04;
--sidebar: #f8fafc;
--sidebar-foreground: #0f172a;
--sidebar-primary: #0891b2;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #e2e8f0;
--sidebar-ring: #0891b2;
--drawer-overlay: rgba(15, 23, 42, 0.25);
--dialog-overlay: rgba(15, 23, 42, 0.35);
--tabs-list-bg: rgba(0, 0, 0, 0.04);
--tabs-list-border: var(--border);
--tabs-trigger-active-bg: rgba(8, 145, 178, 0.15);
--tabs-trigger-active-text: var(--primary);
--switch-thumb: var(--background);
} }
* { * {
@@ -34,6 +107,7 @@
html, html,
body { body {
height: 100%; height: 100%;
overflow-x: clip;
} }
body { body {
@@ -86,7 +160,7 @@ body::before {
} }
.container { .container {
max-width: 90%; max-width: 100%;
width: 1200px; width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
@@ -697,7 +771,6 @@ body::before {
display: none; display: none;
} }
.navbar-input-field { .navbar-input-field {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
@@ -725,6 +798,7 @@ body::before {
@media (max-width: 640px) { @media (max-width: 640px) {
.content { .content {
padding-top: 140px; padding-top: 140px;
overflow-x: clip;
} }
.navbar { .navbar {
@@ -890,6 +964,7 @@ input[type="number"] {
padding: 12px; padding: 12px;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: clip;
} }
.grid { .grid {
@@ -1197,6 +1272,16 @@ input[type="number"] {
background: var(--primary); background: var(--primary);
} }
/* PC 表头固定时的 portal 容器:与 table-scroll-area 横向滚动同步,但隐藏自身滚动条 */
.pc-fund-table-portal-header {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / Edge legacy */
}
.pc-fund-table-portal-header::-webkit-scrollbar {
display: none; /* Chrome / Safari */
}
/* 纵向滚动条通用样式(与项目整体规范一致,供弹窗、列表等使用) */ /* 纵向滚动条通用样式(与项目整体规范一致,供弹窗、列表等使用) */
.scrollbar-y-styled { .scrollbar-y-styled {
scrollbar-width: thin; scrollbar-width: thin;
@@ -1231,7 +1316,7 @@ input[type="number"] {
min-width: 900px; min-width: 900px;
} }
/* 基金名称 净值 涨跌幅 估值涨幅 估值时间 持仓金额 当日收益 持有收益(三列同宽) */ /* 基金名称 净值 涨跌幅 估值涨幅 估值时间 持仓金额 当日收益 持有收益(三列同宽) */
.table-header-row-scroll, .table-header-row-scroll,
.table-row-scroll { .table-row-scroll {
display: grid; display: grid;
@@ -1273,9 +1358,30 @@ input[type="number"] {
} }
.table-row-scroll .name-cell .name-text { .table-row-scroll .name-cell .name-text {
display: block;
max-width: 100%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: normal;
}
.table-row-scroll .name-cell .name-cell-content {
min-width: 0;
width: 100%;
}
.table-row-scroll .name-cell .title-text {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
}
.table-row-scroll .name-cell .name-text.show-full {
white-space: normal;
overflow: visible;
text-overflow: clip;
word-break: break-all;
} }
.table-row-scroll { .table-row-scroll {
@@ -1412,6 +1518,28 @@ input[type="number"] {
transform: translateY(-1px); transform: translateY(-1px);
} }
.dca-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: rgba(34, 211, 238, 0.15);
color: #22d3ee;
font-size: 10px;
font-weight: 500;
margin-left: 6px;
vertical-align: middle;
transform: translateY(-1px);
}
[data-theme="light"] .dca-indicator {
background: rgba(6, 182, 212, 0.15);
color: #0891b2;
}
.code-text { .code-text {
font-size: 11px; font-size: 11px;
} }
@@ -1431,6 +1559,22 @@ input[type="number"] {
/* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */ /* min-width 由 MobileFundTable 根据 columns meta.width 动态设置 */
} }
.mobile-fund-table-portal-header {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
left: 13px;
right: 13px;
box-sizing: border-box;
}
.mobile-fund-table-portal-header::-webkit-scrollbar {
display: none;
}
.mobile-fund-table-portal-header .mobile-fund-table-scroll {
padding: 0;
}
.mobile-fund-table .table-header-row { .mobile-fund-table .table-header-row {
display: grid; display: grid;
/* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */ /* grid-template-columns 由 MobileFundTable 根据当前列顺序动态设置 */
@@ -1482,13 +1626,17 @@ input[type="number"] {
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 2; z-index: 2;
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.12);
border-right: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: none; border-bottom: none;
} }
[data-theme="light"] .mobile-fund-table .table-header-cell-pin-left, .mobile-fund-table .is-scrolled,
[data-theme="light"] .mobile-fund-table .table-cell-pin-left { .mobile-fund-table .is-scrolled {
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.12);
border-right: 1px solid rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .mobile-fund-table .is-scrolled,
[data-theme="light"] .mobile-fund-table .is-scrolled {
box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.08); box-shadow: 4px 0 10px -2px rgba(0, 0, 0, 0.08);
border-right-color: rgba(0, 0, 0, 0.06); border-right-color: rgba(0, 0, 0, 0.06);
} }
@@ -1555,6 +1703,17 @@ input[type="number"] {
.mobile-fund-table .table-row .name-cell .name-text { .mobile-fund-table .table-row .name-cell .name-text {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-fund-table .table-row .name-cell .name-text.show-full {
-webkit-line-clamp: unset;
overflow: visible;
text-overflow: clip;
} }
.mobile-fund-table .table-row .name-cell .code-text { .mobile-fund-table .table-row .name-cell .code-text {
@@ -1898,6 +2057,65 @@ input[type="number"] {
justify-content: center; justify-content: center;
} }
/* shadcn Drawer符合项目规范适配亮/暗主题 */
.drawer-shadow-bottom {
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3);
}
.drawer-shadow-top {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
[data-theme="light"] .drawer-shadow-bottom {
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.12);
}
[data-theme="light"] .drawer-shadow-top {
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
}
/* shadcn Dialog符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
[data-slot="dialog-content"] {
background: rgba(17, 24, 39, 0.96);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
[data-theme="light"] [data-slot="dialog-content"] {
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.dialog-content-shadow {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
}
[data-theme="light"] .dialog-content-shadow {
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08);
}
[data-slot="dialog-content"] [data-slot="dialog-close"] {
cursor: pointer;
color: var(--muted-foreground);
transition: color 0.2s ease, opacity 0.2s ease;
}
[data-slot="dialog-content"] [data-slot="dialog-close"]:hover {
color: var(--foreground);
opacity: 1;
}
[data-slot="dialog-content"] [data-slot="dialog-close"]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
[data-slot="dialog-title"] {
color: var(--foreground);
}
[data-slot="dialog-description"] {
color: var(--muted-foreground);
}
.mobile-setting-drawer { .mobile-setting-drawer {
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
@@ -2628,6 +2846,28 @@ input[type="number"] {
color: var(--muted); color: var(--muted);
} }
.ocr-warning {
padding: 8px 12px;
background: rgba(251, 191, 36, 0.15);
border-radius: 8px;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.ocr-warning span {
font-size: 13px;
color: #fbbf24;
line-height: 1.5;
}
[data-theme="light"] .ocr-warning {
background: rgba(180, 130, 30, 0.1);
border-color: rgba(180, 130, 30, 0.25);
}
[data-theme="light"] .ocr-warning span {
color: #b4821e;
}
.no-results { .no-results {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@@ -3035,6 +3275,26 @@ input[type="number"] {
box-shadow: none; box-shadow: none;
} }
.button.danger {
background: linear-gradient(180deg, #ef4444, #f87171);
color: #2b0b0b;
border-color: rgba(248, 113, 113, 0.4);
}
.button.danger:hover {
box-shadow: 0 10px 20px rgba(248, 113, 113, 0.25);
}
[data-theme="light"] .button.danger {
background: linear-gradient(180deg, #ef4444, #dc2626);
color: #fff;
border-color: rgba(220, 38, 38, 0.4);
}
[data-theme="light"] .button.danger:hover {
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.25);
}
/* ========== 移动端响应式 ========== */ /* ========== 移动端响应式 ========== */
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -3052,3 +3312,161 @@ input[type="number"] {
margin: 16px; margin: 16px;
} }
} }
/* ========== 下拉选择框样式 ========== */
.select {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(11, 18, 32, 0.9);
color: var(--text);
font-size: 14px;
cursor: pointer;
outline: none;
transition: border-color 200ms ease, box-shadow 200ms ease;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.select:hover {
border-color: var(--accent);
}
.select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
.select option {
background: var(--card);
color: var(--text);
padding: 8px;
}
.select option:hover,
.select option:checked {
background: rgba(34, 211, 238, 0.15);
color: var(--primary);
}
/* 亮色主题:下拉选择框 */
[data-theme="light"] .select {
background-color: rgba(255, 255, 255, 0.95);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
border-color: var(--border);
}
[data-theme="light"] .select:hover {
border-color: var(--accent);
background: linear-gradient(180deg, #fff, #f8fafc);
}
[data-theme="light"] .select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
[data-theme="light"] .select option {
background: #fff;
color: var(--text);
}
[data-theme="light"] .select option {
background: #fff;
color: var(--text);
}
[data-theme="light"] .select option:hover,
[data-theme="light"] .select option:checked {
background: rgba(8, 145, 178, 0.12);
color: var(--primary);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: #020617;
--foreground: #f8fafc;
--card: #0f172a;
--card-foreground: #f8fafc;
--popover: #0f172a;
--popover-foreground: #f8fafc;
--primary: #22d3ee;
--primary-foreground: #0f172a;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3af;
--accent: #60a5fa;
--accent-foreground: #f8fafc;
--destructive: #f87171;
--border: #1f2937;
--input: #1e293b;
--ring: #22d3ee;
--chart-1: #22d3ee;
--chart-2: #60a5fa;
--chart-3: #34d399;
--chart-4: #f472b6;
--chart-5: #fbbf24;
--sidebar: #0f172a;
--sidebar-foreground: #f8fafc;
--sidebar-primary: #22d3ee;
--sidebar-primary-foreground: #0f172a;
--sidebar-accent: #1e293b;
--sidebar-accent-foreground: #f8fafc;
--sidebar-border: #1f2937;
--sidebar-ring: #22d3ee;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

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

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

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

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

148
components/ui/dialog.jsx Normal file
View File

@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-[var(--dialog-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<button type="button" className="button secondary px-4 h-11 rounded-xl cursor-pointer">
Close
</button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold text-[var(--foreground)]", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

222
components/ui/drawer.jsx Normal file
View File

@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function parseVhToPx(vhStr) {
if (typeof vhStr === "number") return vhStr
const match = String(vhStr).match(/^([\d.]+)\s*vh$/)
if (!match) return null
return (window.innerHeight * Number(match[1])) / 100
}
function Drawer({
...props
}) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-[var(--drawer-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
}
function DrawerContent({
className,
children,
defaultHeight = "77vh",
minHeight = "20vh",
maxHeight = "90vh",
...props
}) {
const [heightPx, setHeightPx] = React.useState(() =>
typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null
);
const [isDragging, setIsDragging] = React.useState(false);
const dragRef = React.useRef({ startY: 0, startHeight: 0 });
const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]);
const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]);
React.useEffect(() => {
const px = parseVhToPx(defaultHeight);
if (px != null) setHeightPx(px);
}, [defaultHeight]);
React.useEffect(() => {
const sync = () => {
const max = parseVhToPx(maxHeight);
const min = parseVhToPx(minHeight);
setHeightPx((prev) => {
if (prev == null) return parseVhToPx(defaultHeight);
const clamped = Math.min(prev, max ?? prev);
return Math.max(clamped, min ?? clamped);
});
};
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, [defaultHeight, minHeight, maxHeight]);
const handlePointerDown = React.useCallback(
(e) => {
e.preventDefault();
setIsDragging(true);
dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 };
},
[heightPx, defaultHeight]
);
React.useEffect(() => {
if (!isDragging) return;
const move = (e) => {
const clientY = e.clientY ?? e.touches?.[0]?.clientY;
const { startY, startHeight } = dragRef.current;
const delta = startY - clientY;
const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta));
setHeightPx(next);
};
const up = () => setIsDragging(false);
document.addEventListener("mousemove", move, { passive: true });
document.addEventListener("mouseup", up);
document.addEventListener("touchmove", move, { passive: true });
document.addEventListener("touchend", up);
return () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.removeEventListener("touchmove", move);
document.removeEventListener("touchend", up);
};
}, [isDragging, minPx, maxPx]);
const contentStyle = React.useMemo(() => {
if (heightPx == null) return undefined;
return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined };
}, [heightPx, maxPx]);
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
style={contentStyle}
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-[var(--card)] text-[var(--text)] border-[var(--border)]",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-[var(--radius)] data-[vaul-drawer-direction=top]:border-b drawer-shadow-top",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[88vh] data-[vaul-drawer-direction=bottom]:rounded-t-[20px] data-[vaul-drawer-direction=bottom]:border-t drawer-shadow-bottom",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
"drawer-content-theme",
className
)}
{...props}>
<div
role="separator"
aria-label="拖动调整高度"
onMouseDown={handlePointerDown}
onTouchStart={handlePointerDown}
className={cn(
"mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-[var(--muted)] cursor-n-resize touch-none select-none",
"group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
"hover:bg-[var(--muted-foreground)/0.4] active:bg-[var(--muted-foreground)/0.6]"
)}
/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({
className,
...props
}) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 border-b border-[var(--border)] group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
"drawer-header-theme",
className
)}
{...props} />
);
}
function DrawerFooter({
className,
...props
}) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} />
);
}
function DrawerTitle({
className,
...props
}) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-[var(--text)]", className)}
{...props} />
);
}
function DrawerDescription({
className,
...props
}) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-[var(--muted)]", className)}
{...props} />
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,79 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed disabled:opacity-50", className)}
{...props} />
);
}
function InputOTPGroup({
className,
...props
}) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props} />
);
}
function InputOTPSlot({
index,
className,
...props
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex h-12 w-10 items-center justify-center rounded-md border-2 bg-background text-lg font-semibold shadow-sm transition-all duration-200",
"border-input/60 dark:border-input/80",
"text-foreground dark:text-foreground",
"first:rounded-l-md last:rounded-r-md",
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary",
"data-[active=true]:border-primary data-[active=true]:ring-2 data-[active=true]:ring-primary/30 dark:data-[active=true]:ring-primary/40",
"aria-invalid:border-destructive aria-invalid:text-destructive",
"dark:bg-slate-900/50 dark:data-[active=true]:bg-slate-800/50",
className
)}
{...props}>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-6 w-px animate-caret-blink bg-primary duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({
...props
}) {
return (
<div data-slot="input-otp-separator" role="separator" className="text-muted-foreground dark:text-muted-foreground/50" {...props}>
<MinusIcon className="h-4 w-4" />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

197
components/ui/select.jsx Normal file
View File

@@ -0,0 +1,197 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium whitespace-nowrap shadow-sm transition-all duration-200 outline-none",
"border-input bg-background text-foreground",
"hover:border-primary/60 hover:ring-1 hover:ring-primary/30",
"focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/50",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-input disabled:hover:ring-0",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"data-[placeholder]:text-muted-foreground",
"data-[size=default]:h-11 data-[size=sm]:h-10",
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-60 transition-transform duration-200" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
sideOffset = 4,
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-[100] max-h-(--radix-select-content-available-height) min-w-[var(--radix-select-trigger-width)] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-xl border shadow-2xl",
"bg-popover/80 text-popover-foreground dark:bg-popover/70",
"backdrop-blur-xl backdrop-saturate-[180%]",
"border-border/60",
"ring-1 ring-black/5 dark:ring-white/10",
"shadow-black/5 dark:shadow-black/60",
"animate-in fade-in zoom-in-95 duration-200 ease-out",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
sideOffset={sideOffset}
{...props}>
<SelectScrollUpButton className="bg-transparent text-muted-foreground/50" />
<SelectPrimitive.Viewport
className={cn("p-1.5", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton className="bg-transparent text-muted-foreground/50" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props} />
);
}
function SelectItem({
className,
children,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-lg py-2.5 px-3 text-sm font-medium transition-colors duration-150 outline-none",
"text-foreground",
"hover:bg-primary/10 dark:hover:bg-primary/20",
"focus:bg-primary/10 dark:focus:bg-primary/20",
"data-[highlighted]:bg-primary/10 dark:data-[highlighted]:bg-primary/20",
"data-[state=checked]:bg-primary/10 dark:data-[state=checked]:bg-primary/20",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}>
<span
data-slot="select-item-indicator"
className="absolute right-3 flex size-4 items-center justify-center text-primary">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border/60", className)}
{...props} />
);
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

42
components/ui/switch.jsx Normal file
View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, size = "default", ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 cursor-pointer items-center rounded-full border shadow-xs outline-none",
"border-[var(--border)]",
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
"disabled:cursor-not-allowed disabled:opacity-50",
"hover:data-[state=unchecked]:bg-[var(--input)] hover:data-[state=unchecked]:border-[var(--muted)]",
"data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
"data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--primary)]",
"data-[state=unchecked]:bg-[var(--input)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full ring-0",
"bg-[var(--background)]",
"transition-transform duration-200 ease-out",
"group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
"data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=checked]:bg-[var(--primary-foreground)]",
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--switch-thumb)]"
)}
/>
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }

89
components/ui/tabs.jsx Normal file
View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
{...props} />
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius)] p-[3px] text-[var(--muted)] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none border border-[var(--tabs-list-border)]",
{
variants: {
variant: {
default: "bg-[var(--tabs-list-bg)]",
line: "gap-1 bg-transparent border-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props} />
)
}
function TabsTrigger({
className,
...props
}) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200",
"text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--tabs-list-bg)]",
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)]/50 focus-visible:outline-1 focus-visible:outline-[var(--ring)]",
"disabled:pointer-events-none disabled:opacity-50",
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
"group-data-[variant=default]/tabs-list:data-[state=active]:bg-[var(--tabs-trigger-active-bg)] group-data-[variant=default]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)] group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
"group-data-[variant=line]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)]",
"after:absolute after:h-0.5 after:bg-[var(--tabs-trigger-active-text)] after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props} />
)
}
function TabsContent({
className,
...props
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none text-[var(--text)]", className)}
{...props} />
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"jsx": "react"
}
}

6
lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

5903
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.0", "version": "0.2.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -20,24 +20,39 @@
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"fuse.js": "^7.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.577.0",
"next": "^16.1.5", "next": "^16.1.5",
"radix-ui": "^1.4.3",
"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" "tailwind-merge": "^3.5.0",
"tesseract.js": "^5.1.1",
"uuid": "^13.0.0",
"vaul": "^1.1.2"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"autoprefixer": "^10.4.27",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-next": "^16.1.5", "eslint-config-next": "^16.1.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7" "lint-staged": "^16.2.7",
"postcss": "^8.5.8",
"shadcn": "^3.8.5",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": [ "*.{js,jsx,ts,tsx}": [

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

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