Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82bdecca0b | ||
|
|
cc605fb45b | ||
|
|
e8bd65e499 | ||
|
|
12229e8eeb | ||
|
|
fb0dc25341 | ||
|
|
b489677d3e | ||
|
|
104a847d2a | ||
|
|
0a97b80499 | ||
|
|
7c48e94a5d | ||
|
|
02669020bc | ||
|
|
ba1687bf97 | ||
|
|
ac591c54c4 | ||
|
|
26bb966f90 | ||
|
|
a7eb537e67 | ||
|
|
5d97f8f83e | ||
|
|
e80ee0cad1 | ||
|
|
139116a0d3 | ||
|
|
ab9e8a5072 | ||
|
|
ce559664f1 | ||
|
|
d05002fd86 | ||
|
|
1a59087cd9 | ||
|
|
d8a4db34fe |
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
*.log
|
||||
|
||||
.cursor
|
||||
.trae
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
|
||||
*.env
|
||||
.env.*
|
||||
|
||||
*.md
|
||||
*.txt
|
||||
47
Dockerfile
47
Dockerfile
@@ -1,33 +1,36 @@
|
||||
# ===== 构建阶段 =====
|
||||
FROM node:22-bullseye AS builder
|
||||
# ===== 构建阶段(Alpine 减小体积)=====
|
||||
# 未传入的 build-arg 使用占位符,便于运行阶段用环境变量替换
|
||||
# Supabase 构建时会校验 URL,故使用合法占位 URL,运行时再替换
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ARG NEXT_PUBLIC_GA_ID
|
||||
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL=https://runtime-replace.supabase.co
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__
|
||||
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__
|
||||
ARG NEXT_PUBLIC_GA_ID=__NEXT_PUBLIC_GA_ID__
|
||||
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=__NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL__
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npx next build
|
||||
# ===== 运行阶段 =====
|
||||
FROM node:22-bullseye AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
|
||||
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
|
||||
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
|
||||
# ===== 运行阶段(仅静态资源 + nginx,启动时替换占位符)=====
|
||||
FROM nginx:alpine AS runner
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=builder /app/out .
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000 || exit 1
|
||||
CMD ["npm", "start"]
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
42
README.md
42
README.md
@@ -111,18 +111,27 @@ npm run build
|
||||
|
||||
### Docker运行
|
||||
|
||||
需先配置环境变量(与本地开发一致),否则构建出的镜像中 Supabase 等配置为空。可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
||||
镜像支持两种配置方式:
|
||||
|
||||
1. 构建镜像(构建时会读取当前环境或同目录 `.env` 中的变量)
|
||||
- **构建时写入**:构建时通过 `--build-arg` 或 `.env` 传入 `NEXT_PUBLIC_*`,值会打进镜像,运行时无需再传。
|
||||
- **运行时替换**:构建时不传(或使用默认占位符),启动容器时通过 `-e` 或 `--env-file` 传入,入口脚本会在启动 Nginx 前替换静态资源中的占位符。
|
||||
|
||||
可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
|
||||
|
||||
1. 构建镜像
|
||||
```bash
|
||||
# 方式 A:运行时再注入配置(镜像内为占位符)
|
||||
docker build -t real-time-fund .
|
||||
# 或通过 --build-arg 传入,例如:
|
||||
# docker build -t real-time-fund --build-arg NEXT_PUBLIC_Supabase_URL=xxx --build-arg NEXT_PUBLIC_Supabase_ANON_KEY=xxx --build-arg NEXT_PUBLIC_GA_ID=G-xxxx .
|
||||
|
||||
# 方式 B:构建时写入配置
|
||||
docker build -t real-time-fund --build-arg NEXT_PUBLIC_SUPABASE_URL=xxx --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx .
|
||||
# 或依赖同目录 .env:docker compose build
|
||||
```
|
||||
|
||||
2. 启动容器
|
||||
```bash
|
||||
docker run -d -p 3000:3000 --name fund real-time-fund
|
||||
# 若构建时未写入配置,可在此注入(与 --env-file .env 二选一)
|
||||
docker run -d -p 3000:3000 --name fund --env-file .env real-time-fund
|
||||
```
|
||||
|
||||
#### docker-compose(会读取同目录 `.env` 作为 build-arg 与运行环境)
|
||||
@@ -131,6 +140,29 @@ docker run -d -p 3000:3000 --name fund real-time-fund
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Docker Hub
|
||||
|
||||
镜像已发布至 Docker Hub,可直接拉取运行,无需本地构建。
|
||||
|
||||
1. **拉取镜像**
|
||||
```bash
|
||||
docker pull hzm0321/real-time-fund:latest
|
||||
```
|
||||
|
||||
2. **启动容器**
|
||||
访问 [http://localhost:3000](http://localhost:3000) 即可使用。
|
||||
```bash
|
||||
docker run -d -p 3000:3000 --name real-time-fund --restart always hzm0321/real-time-fund:latest
|
||||
```
|
||||
|
||||
3. **使用自定义环境变量(运行时替换)**
|
||||
镜像内已预置占位符,启动时通过环境变量即可覆盖,无需重新构建。例如使用本地 `.env`:
|
||||
```bash
|
||||
docker run -d -p 3000:3000 --name real-time-fund --restart always --env-file .env hzm0321/real-time-fund:latest
|
||||
```
|
||||
或单独指定变量:`-e NEXT_PUBLIC_SUPABASE_URL=xxx -e NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx`。
|
||||
变量名与本地开发一致:`NEXT_PUBLIC_SUPABASE_URL`、`NEXT_PUBLIC_SUPABASE_ANON_KEY`、`NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`、`NEXT_PUBLIC_GA_ID`、`NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL`。
|
||||
|
||||
## 📖 使用说明
|
||||
|
||||
1. **添加基金**:在顶部输入框输入 6 位基金代码(如 `110022`),点击“添加”。
|
||||
|
||||
184
app/api/fund.js
184
app/api/fund.js
@@ -20,6 +20,35 @@ dayjs.tz.setDefault(TZ);
|
||||
const nowInTz = () => dayjs().tz(TZ);
|
||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 获取基金「关联板块/跟踪标的」信息(走本地 API,并做 1 天缓存)
|
||||
* 接口:/api/related-sectors?code=xxxxxx
|
||||
* 返回:{ code: string, relatedSectors: string }
|
||||
*/
|
||||
export const fetchRelatedSectors = async (code, { cacheTime = ONE_DAY_MS } = {}) => {
|
||||
if (!code) return '';
|
||||
const normalized = String(code).trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
const url = `/api/related-sectors?code=${encodeURIComponent(normalized)}`;
|
||||
const cacheKey = `relatedSectors:${normalized}`;
|
||||
|
||||
try {
|
||||
const data = await cachedRequest(async () => {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}, cacheKey, { cacheTime });
|
||||
|
||||
const relatedSectors = data?.relatedSectors;
|
||||
return relatedSectors ? String(relatedSectors).trim() : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScript = (url) => {
|
||||
if (typeof document === 'undefined' || !document.body) return Promise.resolve(null);
|
||||
|
||||
@@ -341,8 +370,12 @@ export const fetchFundData = async (c) => {
|
||||
let name = '';
|
||||
let weight = '';
|
||||
if (idxCode >= 0 && tds[idxCode]) {
|
||||
const m = tds[idxCode].match(/(\d{6})/);
|
||||
code = m ? m[1] : tds[idxCode];
|
||||
const raw = String(tds[idxCode] || '').trim();
|
||||
const mA = raw.match(/(\d{6})/);
|
||||
const mHK = raw.match(/(\d{5})/);
|
||||
// 海外股票常见为英文代码(如 AAPL / usAAPL / TSLA.US / 0700.HK)
|
||||
const mAlpha = raw.match(/\b([A-Za-z]{1,10})\b/);
|
||||
code = mA ? mA[1] : (mHK ? mHK[1] : (mAlpha ? mAlpha[1].toUpperCase() : raw));
|
||||
} else {
|
||||
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
|
||||
if (codeIdx >= 0) code = tds[codeIdx];
|
||||
@@ -365,20 +398,67 @@ export const fetchFundData = async (c) => {
|
||||
}
|
||||
}
|
||||
holdings = holdings.slice(0, 10);
|
||||
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
|
||||
const normalizeTencentCode = (input) => {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
// already normalized tencent styles (normalize prefix casing)
|
||||
const mPref = raw.match(/^(us|hk|sh|sz|bj)(.+)$/i);
|
||||
if (mPref) {
|
||||
const p = mPref[1].toLowerCase();
|
||||
const rest = String(mPref[2] || '').trim();
|
||||
// usAAPL / usIXIC: rest use upper; hk00700 keep digits
|
||||
return `${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||
}
|
||||
const mSPref = raw.match(/^s_(sh|sz|bj|hk)(.+)$/i);
|
||||
if (mSPref) {
|
||||
const p = mSPref[1].toLowerCase();
|
||||
const rest = String(mSPref[2] || '').trim();
|
||||
return `s_${p}${/^\d+$/.test(rest) ? rest : rest.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// A股/北证
|
||||
if (/^\d{6}$/.test(raw)) {
|
||||
const pfx =
|
||||
raw.startsWith('6') || raw.startsWith('9')
|
||||
? 'sh'
|
||||
: raw.startsWith('4') || raw.startsWith('8')
|
||||
? 'bj'
|
||||
: 'sz';
|
||||
return `s_${pfx}${raw}`;
|
||||
}
|
||||
// 港股(数字)
|
||||
if (/^\d{5}$/.test(raw)) return `s_hk${raw}`;
|
||||
|
||||
// 形如 0700.HK / 00001.HK
|
||||
const mHkDot = raw.match(/^(\d{4,5})\.(?:HK)$/i);
|
||||
if (mHkDot) return `s_hk${mHkDot[1].padStart(5, '0')}`;
|
||||
|
||||
// 形如 AAPL / TSLA.US / AAPL.O / BRK.B(腾讯接口对“.”支持不稳定,优先取主代码)
|
||||
const mUsDot = raw.match(/^([A-Za-z]{1,10})(?:\.[A-Za-z]{1,6})$/);
|
||||
if (mUsDot) return `us${mUsDot[1].toUpperCase()}`;
|
||||
if (/^[A-Za-z]{1,10}$/.test(raw)) return `us${raw.toUpperCase()}`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTencentVarName = (tencentCode) => {
|
||||
const cd = String(tencentCode || '').trim();
|
||||
if (!cd) return '';
|
||||
// s_* uses v_s_*
|
||||
if (/^s_/i.test(cd)) return `v_${cd}`;
|
||||
// us/hk/sh/sz/bj uses v_{code}
|
||||
return `v_${cd}`;
|
||||
};
|
||||
|
||||
const needQuotes = holdings
|
||||
.map((h) => ({
|
||||
h,
|
||||
tencentCode: normalizeTencentCode(h.code),
|
||||
}))
|
||||
.filter((x) => Boolean(x.tencentCode));
|
||||
if (needQuotes.length) {
|
||||
try {
|
||||
const tencentCodes = needQuotes.map(h => {
|
||||
const cd = String(h.code || '');
|
||||
if (/^\d{6}$/.test(cd)) {
|
||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||
return `s_${pfx}${cd}`;
|
||||
}
|
||||
if (/^\d{5}$/.test(cd)) {
|
||||
return `s_hk${cd}`;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean).join(',');
|
||||
const tencentCodes = needQuotes.map((x) => x.tencentCode).join(',');
|
||||
if (!tencentCodes) {
|
||||
resolveH(holdings);
|
||||
return;
|
||||
@@ -388,22 +468,15 @@ export const fetchFundData = async (c) => {
|
||||
const scriptQuote = document.createElement('script');
|
||||
scriptQuote.src = quoteUrl;
|
||||
scriptQuote.onload = () => {
|
||||
needQuotes.forEach(h => {
|
||||
const cd = String(h.code || '');
|
||||
let varName = '';
|
||||
if (/^\d{6}$/.test(cd)) {
|
||||
const pfx = cd.startsWith('6') || cd.startsWith('9') ? 'sh' : ((cd.startsWith('4') || cd.startsWith('8')) ? 'bj' : 'sz');
|
||||
varName = `v_s_${pfx}${cd}`;
|
||||
} else if (/^\d{5}$/.test(cd)) {
|
||||
varName = `v_s_hk${cd}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const dataStr = window[varName];
|
||||
needQuotes.forEach(({ h, tencentCode }) => {
|
||||
const varName = getTencentVarName(tencentCode);
|
||||
const dataStr = varName ? window[varName] : null;
|
||||
if (dataStr) {
|
||||
const parts = dataStr.split('~');
|
||||
if (parts.length > 5) {
|
||||
h.change = parseFloat(parts[5]);
|
||||
const isUS = /^us/i.test(String(tencentCode || ''));
|
||||
const idx = isUS ? 32 : 5;
|
||||
if (parts.length > idx) {
|
||||
h.change = parseFloat(parts[idx]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -676,7 +749,7 @@ const snapshotPingzhongdataGlobals = (fundCode) => {
|
||||
};
|
||||
};
|
||||
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 10000) => {
|
||||
const jsonpLoadPingzhongdata = (fundCode, timeoutMs = 20000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
reject(new Error('无浏览器环境'));
|
||||
@@ -770,23 +843,62 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
||||
default: start = start.subtract(1, 'month');
|
||||
}
|
||||
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend,
|
||||
// 同时附带 Data_grandTotal(若存在,格式为 [{ name, data: [[ts, val], ...] }, ...])
|
||||
try {
|
||||
const pz = await fetchFundPingzhongdata(code);
|
||||
const trend = pz?.Data_netWorthTrend;
|
||||
const grandTotal = pz?.Data_grandTotal;
|
||||
|
||||
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)
|
||||
|
||||
// 若起始日没有净值,则往前推到最近一日有净值的数据作为有效起始
|
||||
const validTrend = trend
|
||||
.filter((d) => d && typeof d.x === 'number' && Number.isFinite(Number(d.y)) && d.x <= endMs)
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const startDayEndMs = startMs + 24 * 60 * 60 * 1000 - 1;
|
||||
const hasPointOnStartDay = validTrend.some((d) => d.x >= startMs && d.x <= startDayEndMs);
|
||||
let effectiveStartMs = startMs;
|
||||
if (!hasPointOnStartDay) {
|
||||
const lastBeforeStart = validTrend.filter((d) => d.x < startMs).pop();
|
||||
if (lastBeforeStart) effectiveStartMs = lastBeforeStart.x;
|
||||
}
|
||||
|
||||
const out = validTrend
|
||||
.filter((d) => d.x >= effectiveStartMs && 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);
|
||||
});
|
||||
|
||||
// 解析 Data_grandTotal 为多条对比曲线,使用同一有效起始日
|
||||
if (Array.isArray(grandTotal) && grandTotal.length) {
|
||||
const grandTotalSeries = grandTotal
|
||||
.map((series) => {
|
||||
if (!series || !series.data || !Array.isArray(series.data)) return null;
|
||||
const name = series.name || '';
|
||||
const points = series.data
|
||||
.filter((item) => Array.isArray(item) && typeof item[0] === 'number')
|
||||
.map(([ts, val]) => {
|
||||
if (ts < effectiveStartMs || ts > endMs) return null;
|
||||
const numVal = Number(val);
|
||||
if (!Number.isFinite(numVal)) return null;
|
||||
const date = dayjs(ts).tz(TZ).format('YYYY-MM-DD');
|
||||
return { ts, date, value: numVal };
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!points.length) return null;
|
||||
return { name, points };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (grandTotalSeries.length) {
|
||||
out.grandTotalSeries = grandTotalSeries;
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length) return out;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v17';
|
||||
const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v19';
|
||||
|
||||
export default function Announcement() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -75,14 +75,13 @@ export default function Announcement() {
|
||||
<span>公告</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
|
||||
<p>v0.2.6 更新内容:</p>
|
||||
<p>1. 新增大盘指数并支持个性化。</p>
|
||||
<p>2. 新增持仓金额排序以及排序个性化设置。</p>
|
||||
<p>3. 新增历史净值。</p>
|
||||
<p>4. 表格视图斑马纹。</p>
|
||||
<p>v0.2.8 更新内容:</p>
|
||||
<p>1. 增加关联板块列。</p>
|
||||
<p>2. 设置持仓支持今日首次买入。</p>
|
||||
<p>3. 加仓自动获取费率。</p>
|
||||
<br/>
|
||||
<p>下一版本更新内容:</p>
|
||||
<p>1. 关联板块。</p>
|
||||
<p>1. 关联板块实时估值。</p>
|
||||
<p>2. 收益曲线。</p>
|
||||
<p>3. 估值差异列。</p>
|
||||
<p>如有建议和问题,欢迎进用户支持群反馈。</p>
|
||||
|
||||
@@ -382,6 +382,15 @@ export default function FundCard({
|
||||
</TabsList>
|
||||
{hasHoldings && (
|
||||
<TabsContent value="holdings" className="mt-3 outline-none">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<span className="muted">涨跌幅 / 占比</span>
|
||||
</div>
|
||||
<div className="list">
|
||||
{f.holdings.map((h, idx) => (
|
||||
<div className="item" key={idx}>
|
||||
@@ -409,7 +418,8 @@ export default function FundCard({
|
||||
code={f.code}
|
||||
isExpanded
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||
theme={theme}
|
||||
hideHeader
|
||||
/>
|
||||
@@ -480,7 +490,8 @@ export default function FundCard({
|
||||
code={f.code}
|
||||
isExpanded={!collapsedTrends?.has(f.code)}
|
||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||
transactions={transactions?.[f.code] || []}
|
||||
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -56,12 +56,19 @@ function getChartThemeColors(theme) {
|
||||
}
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||
const [range, setRange] = useState('1m');
|
||||
const [range, setRange] = useState('3m');
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const chartRef = useRef(null);
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const clearActiveIndexRef = useRef(null);
|
||||
const [hiddenGrandSeries, setHiddenGrandSeries] = useState(() => new Set());
|
||||
const [activeIndex, setActiveIndex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
clearActiveIndexRef.current = () => setActiveIndex(null);
|
||||
});
|
||||
|
||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
||||
|
||||
@@ -119,10 +126,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
const lineColor = change >= 0 ? upColor : downColor;
|
||||
const primaryColor = chartColors.primary;
|
||||
|
||||
const percentageData = useMemo(() => {
|
||||
if (!data.length) return [];
|
||||
const firstValue = data[0].value ?? 1;
|
||||
return data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Calculate percentage change based on the first data point
|
||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
||||
const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
||||
// Data_grandTotal:在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
|
||||
const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
|
||||
|
||||
// Map transaction dates to chart indices
|
||||
const dateToIndex = new Map(data.map((d, i) => [d.date, i]));
|
||||
@@ -143,12 +155,65 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Data_grandTotal 的多条曲线按日期对齐到主 labels 上
|
||||
const labels = data.map(d => d.date);
|
||||
// 对比线颜色:避免与主线红/绿(upColor/downColor)重复
|
||||
// 第三条对比线需要在亮/暗主题下都足够清晰,因此使用高对比的橙色强调
|
||||
const grandAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
|
||||
const grandColors = [
|
||||
primaryColor,
|
||||
chartColors.muted,
|
||||
grandAccent3,
|
||||
chartColors.text,
|
||||
];
|
||||
// 隐藏第一条对比线(数据与图示);第二条用原第一条颜色,第三条用原第二条,顺延
|
||||
const visibleGrandSeries = grandTotalSeries.filter((_, idx) => idx > 0);
|
||||
const grandDatasets = visibleGrandSeries.map((series, displayIdx) => {
|
||||
const color = grandColors[displayIdx % grandColors.length];
|
||||
const idx = displayIdx + 1; // 原始索引,用于 hiddenGrandSeries 的 key
|
||||
const key = `${series.name || 'series'}_${idx}`;
|
||||
const isHidden = hiddenGrandSeries.has(key);
|
||||
const pointsByDate = new Map(series.points.map(p => [p.date, p.value]));
|
||||
|
||||
// 方案 2:将对比线同样归一到当前区间首日,展示为“相对本区间首日的累计收益率(百分点变化)”
|
||||
let baseValue = null;
|
||||
for (const date of labels) {
|
||||
const v = pointsByDate.get(date);
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
baseValue = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const seriesData = labels.map(date => {
|
||||
if (isHidden || baseValue == null) return null;
|
||||
const v = pointsByDate.get(date);
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
||||
// Data_grandTotal 中的 value 已是百分比,这里按区间首日做“差值”,保持同一坐标含义(相对区间首日的收益率变化)
|
||||
return v - baseValue;
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
label: series.name || '累计收益率',
|
||||
data: seriesData,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
order: 2,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
type: 'line',
|
||||
label: '涨跌幅',
|
||||
label: '本基金',
|
||||
data: percentageData,
|
||||
borderColor: lineColor,
|
||||
backgroundColor: (context) => {
|
||||
@@ -165,9 +230,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
tension: 0.2,
|
||||
order: 2
|
||||
},
|
||||
...grandDatasets,
|
||||
{
|
||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||
label: '买入',
|
||||
isTradePoint: true,
|
||||
data: buyPoints,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
@@ -181,6 +248,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
{
|
||||
type: 'line',
|
||||
label: '卖出',
|
||||
isTradePoint: true,
|
||||
data: sellPoints,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
@@ -193,7 +261,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, transactions, lineColor, primaryColor, upColor]);
|
||||
}, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const colors = getChartThemeColors(theme);
|
||||
@@ -265,9 +333,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
||||
}
|
||||
|
||||
// 记录当前激活的横轴索引,用于图示下方展示对应百分比
|
||||
if (Array.isArray(chartElement) && chartElement.length > 0) {
|
||||
const idx = chartElement[0].index;
|
||||
setActiveIndex(typeof idx === 'number' ? idx : null);
|
||||
} else {
|
||||
setActiveIndex(null);
|
||||
}
|
||||
|
||||
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
|
||||
},
|
||||
onClick: () => {}
|
||||
onClick: (_event, elements) => {
|
||||
if (Array.isArray(elements) && elements.length > 0) {
|
||||
const idx = elements[0].index;
|
||||
setActiveIndex(typeof idx === 'number' ? idx : null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
@@ -286,14 +367,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
afterEvent: (chart, args) => {
|
||||
const { event, replay } = args || {};
|
||||
if (!event || replay) return; // 忽略动画重放
|
||||
|
||||
|
||||
const type = event.type;
|
||||
if (type === 'mousemove' || type === 'click') {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
if (!chart) return;
|
||||
chart.setActiveElements([]);
|
||||
@@ -301,6 +382,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
chart.tooltip.setActiveElements([], { x: 0, y: 0 });
|
||||
}
|
||||
chart.update();
|
||||
clearActiveIndexRef.current?.();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
@@ -374,27 +456,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
activeElements = chart.getActiveElements();
|
||||
}
|
||||
|
||||
const isBuyOrSellDataset = (ds) =>
|
||||
!!ds && (ds.isTradePoint === true || ds.label === '买入' || ds.label === '卖出');
|
||||
|
||||
// 1. Draw default labels for first buy and sell points only when NOT focused/hovering
|
||||
// Index 1 is Buy, Index 2 is Sell
|
||||
if (!activeElements?.length && datasets[1] && datasets[1].data) {
|
||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstBuyIndex !== -1) {
|
||||
let sellIndex = -1;
|
||||
if (datasets[2] && datasets[2].data) {
|
||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
}
|
||||
const isCollision = (firstBuyIndex === sellIndex);
|
||||
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
||||
// datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
|
||||
const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
|
||||
const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
|
||||
|
||||
if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
|
||||
const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstBuyIndex !== -1) {
|
||||
let sellIndex = -1;
|
||||
if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||
sellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
}
|
||||
const isCollision = (firstBuyIndex === sellIndex);
|
||||
drawPointLabel(buyDatasetIndex, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
||||
}
|
||||
}
|
||||
if (!activeElements?.length && datasets[2] && datasets[2].data) {
|
||||
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstSellIndex !== -1) {
|
||||
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
||||
}
|
||||
|
||||
if (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||
const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||
if (firstSellIndex !== -1) {
|
||||
drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle active elements (hover crosshair)
|
||||
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
|
||||
if (activeElements && activeElements.length) {
|
||||
const activePoint = activeElements[0];
|
||||
const x = activePoint.element.x;
|
||||
@@ -425,64 +515,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Draw Axis Labels based on the first point (main line)
|
||||
const datasetIndex = activePoint.datasetIndex;
|
||||
const index = activePoint.index;
|
||||
|
||||
// Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0)作为数值来源,
|
||||
// 避免对比线在悬停时显示自己的数值标签
|
||||
const baseIndex = activePoint.index;
|
||||
const labels = chart.data.labels;
|
||||
const mainDataset = datasets[0];
|
||||
|
||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
||||
const dateStr = labels[index];
|
||||
const value = datasets[datasetIndex].data[index];
|
||||
if (labels && mainDataset && Array.isArray(mainDataset.data)) {
|
||||
const dateStr = labels[baseIndex];
|
||||
const value = mainDataset.data[baseIndex];
|
||||
|
||||
if (dateStr !== undefined && value !== undefined) {
|
||||
// X axis label (date) with boundary clamping
|
||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||
const chartLeft = chart.scales.x.left;
|
||||
const chartRight = chart.scales.x.right;
|
||||
let labelLeft = x - textWidth / 2;
|
||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||
const labelCenterX = labelLeft + textWidth / 2;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||
if (dateStr !== undefined && value !== undefined) {
|
||||
// X axis label (date) with boundary clamping
|
||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||
const chartLeft = chart.scales.x.left;
|
||||
const chartRight = chart.scales.x.right;
|
||||
let labelLeft = x - textWidth / 2;
|
||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||
const labelCenterX = labelLeft + textWidth / 2;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||
|
||||
// Y axis label (value)
|
||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||
}
|
||||
// Y axis label (value) — 始终基于主线百分比
|
||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||
ctx.fillStyle = primaryColor;
|
||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||
ctx.fillStyle = colors.crosshairText;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for collision between Buy (1) and Sell (2) in active elements
|
||||
const activeBuy = activeElements.find(e => e.datasetIndex === 1);
|
||||
const activeSell = activeElements.find(e => e.datasetIndex === 2);
|
||||
// Check for collision between Buy and Sell in active elements
|
||||
const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
|
||||
const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
|
||||
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index;
|
||||
|
||||
// Iterate through all active points to find transaction points and draw their labels
|
||||
// Iterate through active points,仅为买入/卖出绘制标签
|
||||
activeElements.forEach(element => {
|
||||
const dsIndex = element.datasetIndex;
|
||||
// Only for transaction datasets (index > 0)
|
||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
||||
const label = datasets[dsIndex].label;
|
||||
// Determine background color based on dataset index
|
||||
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
|
||||
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
|
||||
const dsIndex = element.datasetIndex;
|
||||
const ds = datasets?.[dsIndex];
|
||||
if (!isBuyOrSellDataset(ds)) return;
|
||||
|
||||
// If collision, offset Buy label upwards
|
||||
let yOffset = 0;
|
||||
if (isCollision && dsIndex === 1) {
|
||||
yOffset = -20;
|
||||
}
|
||||
const label = ds.label;
|
||||
const bgColor = label === '买入' ? primaryColor : colors.danger;
|
||||
|
||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||
}
|
||||
// 如果买入/卖出在同一天,买入标签上移避免遮挡
|
||||
let yOffset = 0;
|
||||
if (isCollision && label === '买入') {
|
||||
yOffset = -20;
|
||||
}
|
||||
|
||||
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
@@ -491,8 +579,166 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}];
|
||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||
|
||||
const lastIndex = data.length > 0 ? data.length - 1 : null;
|
||||
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
|
||||
|
||||
const chartBlock = (
|
||||
<>
|
||||
{/* 顶部图示:说明不同颜色/标记代表的含义 */}
|
||||
<div
|
||||
className="row"
|
||||
style={{ marginBottom: 8, gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11 }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 2,
|
||||
borderRadius: 999,
|
||||
backgroundColor: lineColor
|
||||
}}
|
||||
/>
|
||||
<span className="muted">本基金</span>
|
||||
</div>
|
||||
{currentIndex != null && percentageData[currentIndex] !== undefined && (
|
||||
<span
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
paddingLeft: 14,
|
||||
}}
|
||||
>
|
||||
{percentageData[currentIndex].toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Array.isArray(data.grandTotalSeries) &&
|
||||
data.grandTotalSeries
|
||||
.filter((_, idx) => idx > 0)
|
||||
.map((series, displayIdx) => {
|
||||
const idx = displayIdx + 1;
|
||||
const legendAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
|
||||
const legendColors = [
|
||||
primaryColor,
|
||||
chartColors.muted,
|
||||
legendAccent3,
|
||||
chartColors.text,
|
||||
];
|
||||
const color = legendColors[displayIdx % legendColors.length];
|
||||
const key = `${series.name || 'series'}_${idx}`;
|
||||
const isHidden = hiddenGrandSeries.has(key);
|
||||
let valueText = '--';
|
||||
if (!isHidden && currentIndex != null && data[currentIndex]) {
|
||||
const targetDate = data[currentIndex].date;
|
||||
|
||||
// 与折线一致:对比线显示“相对当前区间首日”的累计收益率变化
|
||||
const pointsArray = Array.isArray(series.points) ? series.points : [];
|
||||
const pointsByDate = new Map(pointsArray.map(p => [p.date, p.value]));
|
||||
|
||||
let baseValue = null;
|
||||
for (const d of data) {
|
||||
const v = pointsByDate.get(d.date);
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
baseValue = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const rawPoint = pointsByDate.get(targetDate);
|
||||
if (baseValue != null && typeof rawPoint === 'number' && Number.isFinite(rawPoint)) {
|
||||
const normalized = rawPoint - baseValue;
|
||||
valueText = `${normalized.toFixed(2)}%`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={series.name || idx}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHiddenGrandSeries(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 2,
|
||||
borderRadius: 999,
|
||||
backgroundColor: isHidden ? '#4b5563' : color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="muted"
|
||||
style={{ opacity: isHidden ? 0.5 : 1 }}
|
||||
>
|
||||
{series.name}
|
||||
</span>
|
||||
<button
|
||||
className="muted"
|
||||
type="button"
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
style={{ opacity: isHidden ? 0.4 : 0.9 }}
|
||||
>
|
||||
<path
|
||||
d="M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
{isHidden && (
|
||||
<line
|
||||
x1="4"
|
||||
y1="20"
|
||||
x2="20"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
paddingLeft: 14,
|
||||
minHeight: 14,
|
||||
visibility: isHidden || valueText === '--' ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
{valueText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||
{loading && (
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
|
||||
@@ -117,7 +117,8 @@ export default function GroupSummary({
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
if (profit.profitToday != null) {
|
||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||
// 先累加原始当日收益,最后统一做一次四舍五入,避免逐笔四舍五入造成的总计误差
|
||||
totalProfitToday += profit.profitToday;
|
||||
hasAnyTodayData = true;
|
||||
}
|
||||
if (profit.profitTotal !== null) {
|
||||
@@ -129,11 +130,14 @@ export default function GroupSummary({
|
||||
}
|
||||
});
|
||||
|
||||
// 将当日收益总和四舍五入到两位小数,和卡片展示保持一致
|
||||
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAsset,
|
||||
totalProfitToday,
|
||||
totalProfitToday: roundedTotalProfitToday,
|
||||
totalHoldingReturn,
|
||||
hasHolding,
|
||||
returnRate,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) {
|
||||
export default function HoldingActionModal({ fund, onClose, onAction, hasHistory, pendingCount }) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
@@ -39,11 +39,26 @@ export default function HoldingActionModal({ fund, onClose, onAction, hasHistory
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'relative',
|
||||
}}
|
||||
title="查看交易记录"
|
||||
>
|
||||
<span>📜</span>
|
||||
<span>交易记录</span>
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ef4444',
|
||||
border: '2px solid var(--background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) {
|
||||
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
|
||||
|
||||
const dwjz = fund?.dwjz || fund?.gsz || 0;
|
||||
@@ -124,6 +124,23 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>设置持仓</span>
|
||||
{typeof onOpenTrade === 'function' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTrade}
|
||||
className="button secondary"
|
||||
style={{
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
color: 'var(--primary)',
|
||||
}}
|
||||
>
|
||||
今日买入?去加仓。
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||
<CloseIcon width="20" height="20" />
|
||||
|
||||
@@ -198,6 +198,7 @@ export default function MarketIndexAccordion({
|
||||
onHeightChange,
|
||||
isMobile,
|
||||
onCustomSettingsChange,
|
||||
refreshing = false,
|
||||
}) {
|
||||
const [indices, setIndices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -206,6 +207,7 @@ export default function MarketIndexAccordion({
|
||||
const [settingOpen, setSettingOpen] = useState(false);
|
||||
const [tickerIndex, setTickerIndex] = useState(0);
|
||||
const rootRef = useRef(null);
|
||||
const hasInitializedSelectedCodes = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
@@ -222,8 +224,9 @@ export default function MarketIndexAccordion({
|
||||
};
|
||||
}, [onHeightChange, loading, indices.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadIndices = () => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchMarketIndices()
|
||||
.then((data) => {
|
||||
if (!cancelled) setIndices(Array.isArray(data) ? data : []);
|
||||
@@ -234,12 +237,28 @@ export default function MarketIndexAccordion({
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 初次挂载时加载一次指数
|
||||
const cleanup = loadIndices();
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 跟随基金刷新节奏:每次开始刷新时重新拉取指数
|
||||
if (!refreshing) return;
|
||||
const cleanup = loadIndices();
|
||||
return cleanup;
|
||||
}, [refreshing]);
|
||||
|
||||
// 初始化选中指数(本地偏好 > 默认集合)
|
||||
useEffect(() => {
|
||||
if (!indices.length || typeof window === 'undefined') return;
|
||||
if (hasInitializedSelectedCodes.current) return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem('marketIndexSelected');
|
||||
const availableCodes = new Set(indices.map((it) => it.code));
|
||||
@@ -249,6 +268,7 @@ export default function MarketIndexAccordion({
|
||||
const filtered = parsed.filter((c) => availableCodes.has(c));
|
||||
if (filtered.length) {
|
||||
setSelectedCodes(filtered);
|
||||
hasInitializedSelectedCodes.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -344,7 +364,7 @@ export default function MarketIndexAccordion({
|
||||
display: none;
|
||||
}
|
||||
:global([data-theme='dark'] .market-index-accordion-root) {
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
background-color: rgba(15, 23, 42);
|
||||
}
|
||||
.market-index-ticker {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function MobileFundCardDrawer({
|
||||
{children}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
|
||||
className="h-[85vh] max-h-[90vh] mt-0 flex flex-col"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (blockDrawerClose) return;
|
||||
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
||||
|
||||
@@ -28,8 +28,10 @@ import FitText from './FitText';
|
||||
import MobileFundCardDrawer from './MobileFundCardDrawer';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { DragIcon, ExitIcon, SettingsIcon, SortIcon, StarIcon } from './Icons';
|
||||
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'relatedSector',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
@@ -39,6 +41,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'estimateNav',
|
||||
];
|
||||
const MOBILE_COLUMN_HEADERS = {
|
||||
relatedSector: '关联板块',
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨幅',
|
||||
@@ -233,6 +236,8 @@ export default function MobileFundTable({
|
||||
const defaultVisibility = (() => {
|
||||
const o = {};
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
o.relatedSector = false;
|
||||
return o;
|
||||
})();
|
||||
|
||||
@@ -245,7 +250,11 @@ export default function MobileFundTable({
|
||||
})();
|
||||
const mobileColumnVisibility = (() => {
|
||||
const vis = currentGroupMobile?.mobileTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
return next;
|
||||
}
|
||||
return defaultVisibility;
|
||||
})();
|
||||
|
||||
@@ -422,6 +431,7 @@ export default function MobileFundTable({
|
||||
const LAST_COLUMN_EXTRA = 12;
|
||||
const FALLBACK_WIDTHS = {
|
||||
fundName: 140,
|
||||
relatedSector: 120,
|
||||
latestNav: 64,
|
||||
estimateNav: 64,
|
||||
yesterdayChangePercent: 72,
|
||||
@@ -431,6 +441,49 @@ export default function MobileFundTable({
|
||||
holdingProfit: 80,
|
||||
};
|
||||
|
||||
const relatedSectorEnabled = mobileColumnVisibility?.relatedSector !== false;
|
||||
const relatedSectorCacheRef = useRef(new Map());
|
||||
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
|
||||
|
||||
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
|
||||
|
||||
const runWithConcurrency = async (items, limit, worker) => {
|
||||
const queue = [...items];
|
||||
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item == null) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!relatedSectorEnabled) return;
|
||||
if (!Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await runWithConcurrency(missing, 4, async (code) => {
|
||||
const value = await fetchRelatedSector(code);
|
||||
relatedSectorCacheRef.current.set(code, value);
|
||||
if (cancelled) return;
|
||||
setRelatedSectorByCode((prev) => {
|
||||
if (prev[code] === value) return prev;
|
||||
return { ...prev, [code]: value };
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [relatedSectorEnabled, data]);
|
||||
|
||||
const columnWidthMap = useMemo(() => {
|
||||
const visibleNonNameIds = mobileColumnOrder.filter((id) => mobileColumnVisibility[id] !== false);
|
||||
const nonNameCount = visibleNonNameIds.length;
|
||||
@@ -456,6 +509,7 @@ export default function MobileFundTable({
|
||||
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
setMobileColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleMobileColumnVisibility = (columnId, visible) => {
|
||||
@@ -654,6 +708,22 @@ export default function MobileFundTable({
|
||||
),
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||
},
|
||||
{
|
||||
id: 'relatedSector',
|
||||
header: '关联板块',
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||
const display = value || '—';
|
||||
return (
|
||||
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '12px' }}>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { align: 'left', cellClassName: 'related-sector-cell', width: columnWidthMap.relatedSector ?? 120 },
|
||||
},
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
@@ -834,7 +904,7 @@ export default function MobileFundTable({
|
||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy]
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps, isNameSortMode, sortBy, relatedSectorByCode]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -187,6 +187,11 @@ export default function MobileSettingModal({
|
||||
估值涨幅与持有收益的汇总
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'relatedSector' && (
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
仅 fund.cc.cd 地址支持
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{onToggleColumnVisibility && (
|
||||
<Switch
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CloseIcon, DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||
import { DragIcon, ExitIcon, SettingsIcon, StarIcon, TrashIcon, ResetIcon } from './Icons';
|
||||
import { fetchRelatedSectors } from '@/app/api/fund';
|
||||
|
||||
const NON_FROZEN_COLUMN_IDS = [
|
||||
'relatedSector',
|
||||
'yesterdayChangePercent',
|
||||
'estimateChangePercent',
|
||||
'totalChangePercent',
|
||||
@@ -47,6 +49,7 @@ const NON_FROZEN_COLUMN_IDS = [
|
||||
'estimateNav',
|
||||
];
|
||||
const COLUMN_HEADERS = {
|
||||
relatedSector: '关联板块',
|
||||
latestNav: '最新净值',
|
||||
estimateNav: '估算净值',
|
||||
yesterdayChangePercent: '昨日涨幅',
|
||||
@@ -282,9 +285,15 @@ export default function PcFundTable({
|
||||
})();
|
||||
const columnVisibility = (() => {
|
||||
const vis = currentGroupPc?.pcTableColumnVisibility ?? null;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) return vis;
|
||||
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
|
||||
const next = { ...vis };
|
||||
if (next.relatedSector === undefined) next.relatedSector = false;
|
||||
return next;
|
||||
}
|
||||
const allVisible = {};
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
|
||||
// 新增列:默认隐藏(用户可在表格设置中开启)
|
||||
allVisible.relatedSector = false;
|
||||
return allVisible;
|
||||
})();
|
||||
const columnSizing = (() => {
|
||||
@@ -356,6 +365,7 @@ export default function PcFundTable({
|
||||
NON_FROZEN_COLUMN_IDS.forEach((id) => {
|
||||
allVisible[id] = true;
|
||||
});
|
||||
allVisible.relatedSector = false;
|
||||
setColumnVisibility(allVisible);
|
||||
};
|
||||
const handleToggleColumnVisibility = (columnId, visible) => {
|
||||
@@ -443,6 +453,51 @@ export default function PcFundTable({
|
||||
};
|
||||
}, [stickyTop]);
|
||||
|
||||
const relatedSectorEnabled = columnVisibility?.relatedSector !== false;
|
||||
const relatedSectorCacheRef = useRef(new Map());
|
||||
const [relatedSectorByCode, setRelatedSectorByCode] = useState({});
|
||||
|
||||
const fetchRelatedSector = async (code) => fetchRelatedSectors(code);
|
||||
|
||||
const runWithConcurrency = async (items, limit, worker) => {
|
||||
const queue = [...items];
|
||||
const results = [];
|
||||
const runners = Array.from({ length: Math.max(1, limit) }, async () => {
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item == null) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
results.push(await worker(item));
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!relatedSectorEnabled) return;
|
||||
if (!Array.isArray(data) || data.length === 0) return;
|
||||
|
||||
const codes = Array.from(new Set(data.map((d) => d?.code).filter(Boolean)));
|
||||
const missing = codes.filter((code) => !relatedSectorCacheRef.current.has(code));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
await runWithConcurrency(missing, 4, async (code) => {
|
||||
const value = await fetchRelatedSector(code);
|
||||
relatedSectorCacheRef.current.set(code, value);
|
||||
if (cancelled) return;
|
||||
setRelatedSectorByCode((prev) => {
|
||||
if (prev[code] === value) return prev;
|
||||
return { ...prev, [code]: value };
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [relatedSectorEnabled, data]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableEl = tableContainerRef.current;
|
||||
const portalEl = portalHeaderRef.current;
|
||||
@@ -563,6 +618,27 @@ export default function PcFundTable({
|
||||
cellClassName: 'name-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'relatedSector',
|
||||
header: '关联板块',
|
||||
size: 180,
|
||||
minSize: 120,
|
||||
cell: (info) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const value = (code && (relatedSectorByCode?.[code] ?? relatedSectorCacheRef.current.get(code))) || '';
|
||||
const display = value || '—';
|
||||
return (
|
||||
<div style={{ width: '100%', textAlign: value ? 'left' : 'right', fontSize: '14px' }}>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
align: 'right',
|
||||
cellClassName: 'related-sector-cell',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'latestNav',
|
||||
header: '最新净值',
|
||||
@@ -895,7 +971,7 @@ export default function PcFundTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked],
|
||||
[currentTab, favorites, refreshing, sortBy, showFullFundName, getFundCardProps, masked, relatedSectorByCode],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -1007,40 +1083,31 @@ export default function PcFundTable({
|
||||
--row-bg: var(--bg);
|
||||
background-color: var(--row-bg) !important;
|
||||
}
|
||||
|
||||
/* 斑马纹行背景(非 hover 状态) */
|
||||
.table-row-scroll:nth-child(even),
|
||||
.table-row-scroll.row-even {
|
||||
background-color: var(--table-row-alt-bg) !important;
|
||||
}
|
||||
.table-row-scroll:hover {
|
||||
--row-bg: var(--table-row-hover-bg);
|
||||
background-color: var(--row-bg) !important;
|
||||
}
|
||||
|
||||
/* Pinned cells inherit from parent row */
|
||||
/* Pinned cells 继承所在行的背景(非 hover 状态) */
|
||||
.table-row-scroll .pinned-cell {
|
||||
background-color: var(--row-bg) !important;
|
||||
}
|
||||
.table-row-scroll:nth-child(even) .pinned-cell,
|
||||
.table-row-scroll.row-even .pinned-cell {
|
||||
background-color: var(--table-row-alt-bg) !important;
|
||||
}
|
||||
.table-row-scroll:hover .pinned-cell {
|
||||
background-color: var(--table-row-hover-bg) !important;
|
||||
}
|
||||
.table-row-scroll:nth-child(even) {
|
||||
background-color: var(--table-row-alt-bg);
|
||||
}
|
||||
.table-row-scroll:hover {
|
||||
--row-bg: var(--table-row-hover-bg);
|
||||
}
|
||||
|
||||
/* Pinned cells in even rows */
|
||||
.table-row-scroll.row-even .pinned-cell,
|
||||
.row-even .pinned-cell {
|
||||
background-color: var(--table-row-alt-bg) !important;
|
||||
}
|
||||
|
||||
/* Pinned cells on hover */
|
||||
.table-row-scroll:hover .pinned-cell {
|
||||
/* Hover 状态优先级最高,覆盖斑马纹和 pinned 背景 */
|
||||
.table-row-scroll:hover,
|
||||
.table-row-scroll.row-even:hover {
|
||||
--row-bg: var(--table-row-hover-bg);
|
||||
background-color: var(--table-row-hover-bg) !important;
|
||||
}
|
||||
.table-row-scroll:hover .pinned-cell,
|
||||
.table-row-scroll.row-even:hover .pinned-cell {
|
||||
background-color: var(--table-row-hover-bg) !important;
|
||||
}
|
||||
|
||||
@@ -1221,7 +1288,7 @@ export default function PcFundTable({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
|
||||
className="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-y-styled"
|
||||
>
|
||||
{cardDialogRow && getFundCardProps ? (
|
||||
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
|
||||
|
||||
@@ -213,6 +213,11 @@ export default function PcTableSettingModal({
|
||||
估值涨幅与持有收益的汇总
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'relatedSector' && (
|
||||
<span className="muted" style={{ fontSize: '12px' }}>
|
||||
仅 fund.cc.cd 地址支持
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{onToggleColumnVisibility && (
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { isNumber } from 'lodash';
|
||||
import { fetchSmartFundNetValue } from '../api/fund';
|
||||
import { fetchFundPingzhongdata, fetchSmartFundNetValue } from '../api/fund';
|
||||
import { DatePicker, NumericInput } from './Common';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon } from './Icons';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import PendingTradesModal from './PendingTradesModal';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -39,12 +40,65 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
const [share, setShare] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [feeRate, setFeeRate] = useState('0');
|
||||
const [minBuyAmount, setMinBuyAmount] = useState(0);
|
||||
const [loadingBuyMeta, setLoadingBuyMeta] = useState(false);
|
||||
const [buyMetaError, setBuyMetaError] = useState(null);
|
||||
const [date, setDate] = useState(() => {
|
||||
return formatDate();
|
||||
});
|
||||
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
|
||||
const [calcShare, setCalcShare] = useState(null);
|
||||
|
||||
const parseNumberish = (input) => {
|
||||
if (input === null || typeof input === 'undefined') return null;
|
||||
if (typeof input === 'number') return Number.isFinite(input) ? input : null;
|
||||
const cleaned = String(input).replace(/[^\d.]/g, '');
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBuy || !fund?.code) return;
|
||||
let cancelled = false;
|
||||
|
||||
setLoadingBuyMeta(true);
|
||||
setBuyMetaError(null);
|
||||
|
||||
fetchFundPingzhongdata(fund.code)
|
||||
.then((pz) => {
|
||||
if (cancelled) return;
|
||||
const rate = parseNumberish(pz?.fund_Rate);
|
||||
const minsg = parseNumberish(pz?.fund_minsg);
|
||||
|
||||
if (Number.isFinite(minsg)) {
|
||||
setMinBuyAmount(minsg);
|
||||
} else {
|
||||
setMinBuyAmount(0);
|
||||
}
|
||||
|
||||
if (Number.isFinite(rate)) {
|
||||
setFeeRate((prev) => {
|
||||
const prevNum = parseNumberish(prev);
|
||||
const shouldOverride = prev === '' || prev === '0' || prevNum === 0 || prevNum === null;
|
||||
return shouldOverride ? rate.toFixed(2) : prev;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setBuyMetaError(e?.message || '买入信息加载失败');
|
||||
setMinBuyAmount(0);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoadingBuyMeta(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isBuy, fund?.code]);
|
||||
|
||||
const currentPendingTrades = useMemo(() => {
|
||||
return pendingTrades.filter(t => t.fundCode === fund?.code);
|
||||
}, [pendingTrades, fund]);
|
||||
@@ -148,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
};
|
||||
|
||||
const isValid = isBuy
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null)
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
|
||||
: (!!share && !!date);
|
||||
|
||||
const handleSetShareFraction = (fraction) => {
|
||||
@@ -372,72 +426,112 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<form onSubmit={handleSubmit}>
|
||||
{isBuy ? (
|
||||
<>
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<NumericInput
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={0}
|
||||
placeholder="请输入加仓金额"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ pointerEvents: loadingBuyMeta ? 'none' : 'auto', opacity: loadingBuyMeta ? 0.55 : 1 }}>
|
||||
<div className="form-group" style={{ marginBottom: 16 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
|
||||
? '1px solid var(--danger)'
|
||||
: '1px solid var(--border)',
|
||||
borderRadius: 12
|
||||
}}
|
||||
>
|
||||
<NumericInput
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={Number(minBuyAmount) || 0}
|
||||
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
|
||||
/>
|
||||
</div>
|
||||
{(Number(minBuyAmount) || 0) > 0 && (
|
||||
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
|
||||
最小加仓金额:¥{Number(minBuyAmount)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<NumericInput
|
||||
value={feeRate}
|
||||
onChange={setFeeRate}
|
||||
step={0.01}
|
||||
min={0}
|
||||
placeholder="0.12"
|
||||
/>
|
||||
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
|
||||
<NumericInput
|
||||
value={feeRate}
|
||||
onChange={setFeeRate}
|
||||
step={0.01}
|
||||
min={0}
|
||||
placeholder="0.12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<DatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12, fontSize: '12px' }}>
|
||||
{buyMetaError ? (
|
||||
<span className="muted" style={{ color: 'var(--danger)' }}>{buyMetaError}</span>
|
||||
) : null}
|
||||
{loadingPrice ? (
|
||||
<span className="muted">正在查询净值数据...</span>
|
||||
) : price === 0 ? null : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
|
||||
</label>
|
||||
<DatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
|
||||
交易时段
|
||||
</label>
|
||||
<div className="trade-time-slot row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={!isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(false)}
|
||||
{loadingBuyMeta && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
WebkitBackdropFilter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
15:00前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isAfter3pm ? 'trade-time-btn active' : 'trade-time-btn'}
|
||||
onClick={() => setIsAfter3pm(true)}
|
||||
>
|
||||
15:00后
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12, fontSize: '12px' }}>
|
||||
{loadingPrice ? (
|
||||
<span className="muted">正在查询净值数据...</span>
|
||||
) : price === 0 ? null : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
|
||||
<Spinner className="size-5" />
|
||||
<span className="muted" style={{ fontSize: 12 }}>正在加载买入费率/最小金额...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -564,8 +658,8 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
<button
|
||||
type="submit"
|
||||
className="button"
|
||||
disabled={!isValid || loadingPrice}
|
||||
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
|
||||
disabled={!isValid || loadingPrice || (isBuy && loadingBuyMeta)}
|
||||
style={{ flex: 1, opacity: (!isValid || loadingPrice || (isBuy && loadingBuyMeta)) ? 0.6 : 1 }}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { UpdateIcon } from './Icons';
|
||||
|
||||
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
|
||||
export default function UpdatePromptModal({ updateContent, open, onClose, onRefresh }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="更新提示"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 10002 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="glass card modal"
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose?.()}>
|
||||
<DialogContent
|
||||
className="glass card"
|
||||
style={{ maxWidth: '400px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
showCloseButton={false}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="更新提示"
|
||||
>
|
||||
<div className="title" style={{ marginBottom: 12 }}>
|
||||
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
||||
<span>更新提示</span>
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
|
||||
<span>更新提示</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
|
||||
检测到新版本,是否刷新浏览器以更新?
|
||||
@@ -36,7 +29,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
</p>
|
||||
{updateContent && (
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
background: 'var(--card)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
@@ -44,13 +37,14 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
{updateContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
|
||||
<div className="flex-row" style={{ gap: 12, display: 'flex' }}>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={onClose}
|
||||
@@ -66,7 +60,7 @@ export default function UpdatePromptModal({ updateContent, onClose, onRefresh })
|
||||
刷新浏览器
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2093,10 +2093,12 @@ input[type="number"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 亮色主题下,PC 右侧抽屉里的 Switch 拇指使用浅色,以保证对比度 */
|
||||
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* 移动端表格设置底部抽屉 */
|
||||
.mobile-setting-overlay {
|
||||
position: fixed;
|
||||
@@ -2538,6 +2540,13 @@ input[type="number"] {
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
/* 亮色主题下:所有使用 dca-toggle 的拇指在浅底上统一用白色,保证对比度
|
||||
- PC 右侧排序设置抽屉
|
||||
- 移动端排序个性化设置 Drawer(以及其它区域) */
|
||||
[data-theme="light"] .dca-toggle-thumb {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dca-option-group {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
|
||||
123
app/page.jsx
123
app/page.jsx
@@ -217,12 +217,37 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
if (rulesFromSettings && rulesFromSettings.length) {
|
||||
const merged = DEFAULT_SORT_RULES.map((rule) => {
|
||||
const found = rulesFromSettings.find((r) => r.id === rule.id);
|
||||
return found
|
||||
? { ...rule, enabled: found.enabled !== false }
|
||||
: rule;
|
||||
// 1)先按本地存储的顺序还原(包含 alias、enabled 等字段)
|
||||
const defaultMap = new Map(
|
||||
DEFAULT_SORT_RULES.map((rule) => [rule.id, rule])
|
||||
);
|
||||
const merged = [];
|
||||
|
||||
// 先遍历本地配置,保持用户自定义的顺序和别名/开关
|
||||
for (const stored of rulesFromSettings) {
|
||||
const base = defaultMap.get(stored.id);
|
||||
if (!base) continue;
|
||||
merged.push({
|
||||
...base,
|
||||
// 只用本地的 enabled / alias 等个性化字段,基础 label 仍以内置为准
|
||||
enabled:
|
||||
typeof stored.enabled === "boolean"
|
||||
? stored.enabled
|
||||
: base.enabled,
|
||||
alias:
|
||||
typeof stored.alias === "string" && stored.alias.trim()
|
||||
? stored.alias.trim()
|
||||
: base.alias,
|
||||
});
|
||||
}
|
||||
|
||||
// 再把本次版本新增、但本地还没记录过的规则追加到末尾
|
||||
DEFAULT_SORT_RULES.forEach((rule) => {
|
||||
if (!merged.some((r) => r.id === rule.id)) {
|
||||
merged.push(rule);
|
||||
}
|
||||
});
|
||||
|
||||
setSortRules(merged);
|
||||
}
|
||||
|
||||
@@ -858,7 +883,21 @@ export default function HomePage() {
|
||||
|
||||
const handleClearConfirm = () => {
|
||||
if (clearConfirm?.fund) {
|
||||
handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null });
|
||||
const code = clearConfirm.fund.code;
|
||||
handleSaveHolding(code, { share: null, cost: null });
|
||||
|
||||
setTransactions(prev => {
|
||||
const next = { ...(prev || {}) };
|
||||
delete next[code];
|
||||
storageHelper.setItem('transactions', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
|
||||
setPendingTrades(prev => {
|
||||
const next = prev.filter(trade => trade.fundCode !== code);
|
||||
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setClearConfirm(null);
|
||||
};
|
||||
@@ -1015,6 +1054,11 @@ export default function HomePage() {
|
||||
setPendingTrades(next);
|
||||
storageHelper.setItem('pendingTrades', JSON.stringify(next));
|
||||
|
||||
// 如果该基金没有持仓数据,初始化持仓金额为 0
|
||||
if (!holdings[fund.code]) {
|
||||
handleSaveHolding(fund.code, { share: 0, cost: 0 });
|
||||
}
|
||||
|
||||
setTradeModal({ open: false, fund: null, type: 'buy' });
|
||||
showToast('净值暂未更新,已加入待处理队列', 'info');
|
||||
return;
|
||||
@@ -1624,7 +1668,9 @@ export default function HomePage() {
|
||||
if (key === 'funds') {
|
||||
const prevSig = getFundCodesSignature(prevValue);
|
||||
const nextSig = getFundCodesSignature(nextValue);
|
||||
if (prevSig === nextSig) return;
|
||||
if (prevSig === nextSig) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!skipSyncRef.current) {
|
||||
const now = nowInTz().toISOString();
|
||||
@@ -3138,9 +3184,10 @@ export default function HomePage() {
|
||||
const fetchCloudConfig = async (userId, checkConflict = false) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
// 一次查询同时拿到 meta 与 data,方便两种模式复用
|
||||
const { data: meta, error: metaError } = await supabase
|
||||
.from('user_configs')
|
||||
.select(`id, updated_at${checkConflict ? ', data' : ''}`)
|
||||
.select('id, data, updated_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -3154,44 +3201,19 @@ export default function HomePage() {
|
||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 冲突检查模式:使用 meta.data 弹出冲突确认弹窗
|
||||
if (checkConflict) {
|
||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data });
|
||||
return;
|
||||
}
|
||||
|
||||
const localUpdatedAt = window.localStorage.getItem('localUpdatedAt');
|
||||
if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) {
|
||||
// 非冲突检查模式:直接复用上方查询到的 meta 数据,覆盖本地
|
||||
if (meta.data && isPlainObject(meta.data) && Object.keys(meta.data).length > 0) {
|
||||
await applyCloudConfig(meta.data, meta.updated_at);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_configs')
|
||||
.select('id, data, updated_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) {
|
||||
const localPayload = collectLocalPayload();
|
||||
const localComparable = getComparablePayload(localPayload);
|
||||
const cloudComparable = getComparablePayload(data.data);
|
||||
|
||||
if (localComparable !== cloudComparable) {
|
||||
// 如果数据不一致
|
||||
if (checkConflict) {
|
||||
// 只有明确要求检查冲突时才提示(例如刚登录时)
|
||||
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
|
||||
return;
|
||||
}
|
||||
// 否则直接覆盖本地(例如已登录状态下的刷新)
|
||||
await applyCloudConfig(data.data, data.updated_at);
|
||||
return;
|
||||
}
|
||||
|
||||
await applyCloudConfig(data.data, data.updated_at);
|
||||
return;
|
||||
}
|
||||
setCloudConfigModal({ open: true, userId, type: 'empty' });
|
||||
} catch (e) {
|
||||
console.error('获取云端配置失败', e);
|
||||
@@ -3692,7 +3714,7 @@ export default function HomePage() {
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="search-dropdown glass"
|
||||
className="search-dropdown glass scrollbar-y-styled"
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="search-results">
|
||||
@@ -3923,6 +3945,7 @@ export default function HomePage() {
|
||||
onHeightChange={setMarketIndexAccordionHeight}
|
||||
isMobile={isMobile}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
<div className="grid">
|
||||
<div className="col-12">
|
||||
@@ -4485,6 +4508,7 @@ export default function HomePage() {
|
||||
onClose={() => setActionModal({ open: false, fund: null })}
|
||||
onAction={(type) => handleAction(type, actionModal.fund)}
|
||||
hasHistory={!!transactions[actionModal.fund?.code]?.length}
|
||||
pendingCount={pendingTrades.filter(t => t.fundCode === actionModal.fund?.code).length}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -4593,6 +4617,12 @@ export default function HomePage() {
|
||||
holding={holdings[holdingModal.fund?.code]}
|
||||
onClose={() => setHoldingModal({ open: false, fund: null })}
|
||||
onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)}
|
||||
onOpenTrade={() => {
|
||||
const f = holdingModal.fund;
|
||||
if (!f) return;
|
||||
setHoldingModal({ open: false, fund: null });
|
||||
setTradeModal({ open: true, fund: f, type: 'buy' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -4699,15 +4729,12 @@ export default function HomePage() {
|
||||
)}
|
||||
|
||||
{/* 更新提示弹窗 */}
|
||||
<AnimatePresence>
|
||||
{updateModalOpen && (
|
||||
<UpdatePromptModal
|
||||
updateContent={updateContent}
|
||||
onClose={() => setUpdateModalOpen(false)}
|
||||
onRefresh={() => window.location.reload()}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<UpdatePromptModal
|
||||
open={updateModalOpen}
|
||||
updateContent={updateContent}
|
||||
onClose={() => setUpdateModalOpen(false)}
|
||||
onRefresh={() => window.location.reload()}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isScanning && (
|
||||
|
||||
21
components/ui/spinner.jsx
Normal file
21
components/ui/spinner.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn(
|
||||
"size-4 animate-spin text-muted-foreground motion-reduce:animate-none",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
26
entrypoint.sh
Normal file
26
entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# 在启动 Nginx 前,将静态资源中的占位符替换为运行时环境变量
|
||||
set -e
|
||||
|
||||
HTML_ROOT="/usr/share/nginx/html"
|
||||
|
||||
# 转义 sed 替换串中的特殊字符:\ & |
|
||||
escape_sed() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/&/\\&/g; s/|/\\|/g'
|
||||
}
|
||||
|
||||
# 占位符与环境变量对应(占位符名 = 变量名)
|
||||
replace_var() {
|
||||
placeholder="$1"
|
||||
value=$(escape_sed "${2:-}")
|
||||
find "$HTML_ROOT" -type f \( -name '*.js' -o -name '*.html' \) -exec sed -i "s|${placeholder}|${value}|g" {} \;
|
||||
}
|
||||
|
||||
# URL 构建时使用合法占位,此处替换为运行时环境变量
|
||||
replace_var "https://runtime-replace.supabase.co" "${NEXT_PUBLIC_SUPABASE_URL}"
|
||||
replace_var "__NEXT_PUBLIC_SUPABASE_ANON_KEY__" "${NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
replace_var "__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__" "${NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY}"
|
||||
replace_var "__NEXT_PUBLIC_GA_ID__" "${NEXT_PUBLIC_GA_ID}"
|
||||
replace_var "__NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL__" "${NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL}"
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ /index.html =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
@@ -5923,7 +5923,7 @@
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-time-fund",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user