14 Commits

Author SHA1 Message Date
hzm
7c48e94a5d feat: 发布 0.2.7 2026-03-17 08:56:37 +08:00
hzm
02669020bc fix:修复业绩走势折线图展示问题 2026-03-17 08:53:49 +08:00
hzm
ba1687bf97 feat:业绩走势默认值改为近3月 2026-03-16 22:48:05 +08:00
hzm
ac591c54c4 feat:业绩走势对比线数据格式化问题 2026-03-16 21:44:49 +08:00
hzm
26bb966f90 feat: 业绩走势增加对比线 2026-03-16 21:04:04 +08:00
hzm
a7eb537e67 fix: 排序别名存储问题 2026-03-16 19:28:24 +08:00
hzm
5d97f8f83e fix: PC 斑马纹 hover 2026-03-16 13:28:10 +08:00
hzm
e80ee0cad1 Revert "fix: 修复同步方法"
This reverts commit ab9e8a5072.
2026-03-16 13:27:21 +08:00
hzm
139116a0d3 fix: 修复同步问题 2026-03-16 12:32:43 +08:00
hzm
ab9e8a5072 fix: 修复同步方法 2026-03-16 11:37:04 +08:00
hzm
ce559664f1 feat: 大盘指数刷新问题 2026-03-16 10:02:35 +08:00
hzm
d05002fd86 feat: 更新群聊图片 2026-03-16 09:45:04 +08:00
hzm
1a59087cd9 feat: 新增 docker hub 2026-03-16 09:03:14 +08:00
hzm
d8a4db34fe feat: 新增 dockerignore 2026-03-15 22:42:11 +08:00
18 changed files with 592 additions and 149 deletions

23
.dockerignore Normal file
View 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

View File

@@ -1,33 +1,36 @@
# ===== 构建阶段 ===== # ===== 构建阶段Alpine 减小体积)=====
FROM node:22-bullseye AS builder # 未传入的 build-arg 使用占位符,便于运行阶段用环境变量替换
# Supabase 构建时会校验 URL故使用合法占位 URL运行时再替换
FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY ARG NEXT_PUBLIC_SUPABASE_URL=https://runtime-replace.supabase.co
ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__
ARG NEXT_PUBLIC_GA_ID ARG NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=__NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY__
ARG NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL 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_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY 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_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL
COPY package*.json ./ COPY package*.json ./
RUN npm install --legacy-peer-deps RUN npm ci --legacy-peer-deps
COPY . . COPY . .
RUN npx next build RUN npx next build
# ===== 运行阶段 =====
FROM node:22-bullseye AS runner # ===== 运行阶段(仅静态资源 + nginx启动时替换占位符=====
WORKDIR /app FROM nginx:alpine AS runner
ENV NODE_ENV=production WORKDIR /usr/share/nginx/html
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY COPY --from=builder /app/out .
ENV NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=$NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY COPY nginx.conf /etc/nginx/conf.d/default.conf
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID COPY entrypoint.sh /entrypoint.sh
ENV NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL=$NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL RUN chmod +x /entrypoint.sh
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000 || exit 1 CMD curl -f http://localhost:3000/ || exit 1
CMD ["npm", "start"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -111,18 +111,27 @@ npm run build
### Docker运行 ### Docker运行
需先配置环境变量(与本地开发一致),否则构建出的镜像中 Supabase 等配置为空。可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。 镜像支持两种配置方式:
1. 构建镜像(构建时会读取当前环境或同目录 `.env` 中的变量) - **构建时写入**:构建时通过 `--build-arg` 或 `.env` 传入 `NEXT_PUBLIC_*`,值会打进镜像,运行时无需再传。
- **运行时替换**:构建时不传(或使用默认占位符),启动容器时通过 `-e` 或 `--env-file` 传入,入口脚本会在启动 Nginx 前替换静态资源中的占位符。
可复制 `env.example` 为 `.env` 并填入实际值;若不用登录/反馈功能可留空。
1. 构建镜像
```bash ```bash
# 方式 A运行时再注入配置镜像内为占位符
docker build -t real-time-fund . 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 .
# 或依赖同目录 .envdocker compose build
``` ```
2. 启动容器 2. 启动容器
```bash ```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 与运行环境) #### 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 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`),点击“添加”。 1. **添加基金**:在顶部输入框输入 6 位基金代码(如 `110022`),点击“添加”。

View File

@@ -770,23 +770,62 @@ export const fetchFundHistory = async (code, range = '1m') => {
default: start = start.subtract(1, 'month'); default: start = start.subtract(1, 'month');
} }
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend // 业绩走势统一走 pingzhongdata.Data_netWorthTrend
// 同时附带 Data_grandTotal若存在格式为 [{ name, data: [[ts, val], ...] }, ...]
try { try {
const pz = await fetchFundPingzhongdata(code); const pz = await fetchFundPingzhongdata(code);
const trend = pz?.Data_netWorthTrend; const trend = pz?.Data_netWorthTrend;
const grandTotal = pz?.Data_grandTotal;
if (Array.isArray(trend) && trend.length) { if (Array.isArray(trend) && trend.length) {
const startMs = start.startOf('day').valueOf(); const startMs = start.startOf('day').valueOf();
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
const endMs = end.endOf('day').valueOf(); 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) => { .map((d) => {
const value = Number(d.y); const value = Number(d.y);
if (!Number.isFinite(value)) return null;
const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD'); const date = dayjs(d.x).tz(TZ).format('YYYY-MM-DD');
return { date, value }; 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; if (out.length) return out;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 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_v17'; const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v18';
export default function Announcement() { export default function Announcement() {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -75,11 +75,11 @@ export default function Announcement() {
<span>公告</span> <span>公告</span>
</div> </div>
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}> <div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px', overflowY: 'auto', minHeight: 0, flex: 1, paddingRight: '4px' }}>
<p>v0.2.6 更新内容</p> <p>v0.2.7 更新内容</p>
<p>1. 新增大盘指数并支持个性化</p> <p>1. 业绩走势增加对比线</p>
<p>2. 新增持仓金额排序以及排序个性化设置</p> <p>2. 修复排序存储别名问题</p>
<p>3. 新增历史净值</p> <p>3. PC端斑马纹 hover 样式问题</p>
<p>4. 表格视图斑马纹</p> <p>4. 修复大盘指数刷新及用户数据同步问题</p>
<br/> <br/>
<p>下一版本更新内容:</p> <p>下一版本更新内容:</p>
<p>1. 关联板块</p> <p>1. 关联板块</p>

View File

@@ -382,6 +382,15 @@ export default function FundCard({
</TabsList> </TabsList>
{hasHoldings && ( {hasHoldings && (
<TabsContent value="holdings" className="mt-3 outline-none"> <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"> <div className="list">
{f.holdings.map((h, idx) => ( {f.holdings.map((h, idx) => (
<div className="item" key={idx}> <div className="item" key={idx}>
@@ -409,7 +418,8 @@ export default function FundCard({
code={f.code} code={f.code}
isExpanded isExpanded
onToggleExpand={() => onToggleTrendCollapse?.(f.code)} onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []} // 未设置持仓金额时不展示买入/卖出标记与标签
transactions={profit ? (transactions?.[f.code] || []) : []}
theme={theme} theme={theme}
hideHeader hideHeader
/> />
@@ -480,7 +490,8 @@ export default function FundCard({
code={f.code} code={f.code}
isExpanded={!collapsedTrends?.has(f.code)} isExpanded={!collapsedTrends?.has(f.code)}
onToggleExpand={() => onToggleTrendCollapse?.(f.code)} onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []} // 未设置持仓金额时不展示买入/卖出标记与标签
transactions={profit ? (transactions?.[f.code] || []) : []}
theme={theme} theme={theme}
/> />
</> </>

View File

@@ -56,12 +56,19 @@ function getChartThemeColors(theme) {
} }
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) { 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 [data, setData] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const chartRef = useRef(null); const chartRef = useRef(null);
const hoverTimeoutRef = 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]); 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 lineColor = change >= 0 ? upColor : downColor;
const primaryColor = chartColors.primary; 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(() => { const chartData = useMemo(() => {
// Calculate percentage change based on the first data point // Data_grandTotal在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
const firstValue = data.length > 0 ? data[0].value : 1; const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100);
// Map transaction dates to chart indices // Map transaction dates to chart indices
const dateToIndex = new Map(data.map((d, i) => [d.date, i])); 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 { return {
labels: data.map(d => d.date), labels: data.map(d => d.date),
datasets: [ datasets: [
{ {
type: 'line', type: 'line',
label: '涨跌幅', label: '本基金',
data: percentageData, data: percentageData,
borderColor: lineColor, borderColor: lineColor,
backgroundColor: (context) => { backgroundColor: (context) => {
@@ -165,9 +230,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
tension: 0.2, tension: 0.2,
order: 2 order: 2
}, },
...grandDatasets,
{ {
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
label: '买入', label: '买入',
isTradePoint: true,
data: buyPoints, data: buyPoints,
borderColor: '#ffffff', borderColor: '#ffffff',
borderWidth: 1, borderWidth: 1,
@@ -181,6 +248,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
{ {
type: 'line', type: 'line',
label: '卖出', label: '卖出',
isTradePoint: true,
data: sellPoints, data: sellPoints,
borderColor: '#ffffff', borderColor: '#ffffff',
borderWidth: 1, 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 options = useMemo(() => {
const colors = getChartThemeColors(theme); const colors = getChartThemeColors(theme);
@@ -265,9 +333,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
target.style.cursor = hasActive ? 'crosshair' : 'default'; 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 事件不稳定 // 仅用于桌面端 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]); }, [theme]);
@@ -286,14 +367,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
afterEvent: (chart, args) => { afterEvent: (chart, args) => {
const { event, replay } = args || {}; const { event, replay } = args || {};
if (!event || replay) return; // 忽略动画重放 if (!event || replay) return; // 忽略动画重放
const type = event.type; const type = event.type;
if (type === 'mousemove' || type === 'click') { if (type === 'mousemove' || type === 'click') {
if (hoverTimeoutRef.current) { if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current); clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null; hoverTimeoutRef.current = null;
} }
hoverTimeoutRef.current = setTimeout(() => { hoverTimeoutRef.current = setTimeout(() => {
if (!chart) return; if (!chart) return;
chart.setActiveElements([]); chart.setActiveElements([]);
@@ -301,6 +382,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
chart.tooltip.setActiveElements([], { x: 0, y: 0 }); chart.tooltip.setActiveElements([], { x: 0, y: 0 });
} }
chart.update(); chart.update();
clearActiveIndexRef.current?.();
}, 2000); }, 2000);
} }
}, },
@@ -374,27 +456,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
activeElements = chart.getActiveElements(); 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 // 1. Draw default labels for first buy and sell points only when NOT focused/hovering
// Index 1 is Buy, Index 2 is Sell // datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
if (!activeElements?.length && datasets[1] && datasets[1].data) { const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined); const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
if (firstBuyIndex !== -1) {
let sellIndex = -1; if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
if (datasets[2] && datasets[2].data) { const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); if (firstBuyIndex !== -1) {
} let sellIndex = -1;
const isCollision = (firstBuyIndex === sellIndex); if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0); 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 (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
if (firstSellIndex !== -1) { const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
drawPointLabel(2, firstSellIndex, '卖出', '#f87171'); if (firstSellIndex !== -1) {
} drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
}
} }
// 2. Handle active elements (hover crosshair) // 2. Handle active elements (hover crosshair)
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
if (activeElements && activeElements.length) { if (activeElements && activeElements.length) {
const activePoint = activeElements[0]; const activePoint = activeElements[0];
const x = activePoint.element.x; const x = activePoint.element.x;
@@ -425,64 +515,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
// Draw Axis Labels based on the first point (main line) // Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0作为数值来源
const datasetIndex = activePoint.datasetIndex; // 避免对比线在悬停时显示自己的数值标签
const index = activePoint.index; const baseIndex = activePoint.index;
const labels = chart.data.labels; const labels = chart.data.labels;
const mainDataset = datasets[0];
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) { if (labels && mainDataset && Array.isArray(mainDataset.data)) {
const dateStr = labels[index]; const dateStr = labels[baseIndex];
const value = datasets[datasetIndex].data[index]; const value = mainDataset.data[baseIndex];
if (dateStr !== undefined && value !== undefined) { if (dateStr !== undefined && value !== undefined) {
// X axis label (date) with boundary clamping // X axis label (date) with boundary clamping
const textWidth = ctx.measureText(dateStr).width + 8; const textWidth = ctx.measureText(dateStr).width + 8;
const chartLeft = chart.scales.x.left; const chartLeft = chart.scales.x.left;
const chartRight = chart.scales.x.right; const chartRight = chart.scales.x.right;
let labelLeft = x - textWidth / 2; let labelLeft = x - textWidth / 2;
if (labelLeft < chartLeft) labelLeft = chartLeft; if (labelLeft < chartLeft) labelLeft = chartLeft;
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth; if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
const labelCenterX = labelLeft + textWidth / 2; const labelCenterX = labelLeft + textWidth / 2;
ctx.fillStyle = primaryColor; ctx.fillStyle = primaryColor;
ctx.fillRect(labelLeft, bottomY, textWidth, 16); ctx.fillRect(labelLeft, bottomY, textWidth, 16);
ctx.fillStyle = colors.crosshairText; ctx.fillStyle = colors.crosshairText;
ctx.fillText(dateStr, labelCenterX, bottomY + 8); ctx.fillText(dateStr, labelCenterX, bottomY + 8);
// Y axis label (value) // Y axis label (value) — 始终基于主线百分比
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%'; const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
const valWidth = ctx.measureText(valueStr).width + 8; const valWidth = ctx.measureText(valueStr).width + 8;
ctx.fillStyle = primaryColor; ctx.fillStyle = primaryColor;
ctx.fillRect(leftX, y - 8, valWidth, 16); ctx.fillRect(leftX, y - 8, valWidth, 16);
ctx.fillStyle = colors.crosshairText; ctx.fillStyle = colors.crosshairText;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(valueStr, leftX + valWidth / 2, y); ctx.fillText(valueStr, leftX + valWidth / 2, y);
} }
} }
// Check for collision between Buy (1) and Sell (2) in active elements // Check for collision between Buy and Sell in active elements
const activeBuy = activeElements.find(e => e.datasetIndex === 1); const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
const activeSell = activeElements.find(e => e.datasetIndex === 2); const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index; 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 => { activeElements.forEach(element => {
const dsIndex = element.datasetIndex; const dsIndex = element.datasetIndex;
// Only for transaction datasets (index > 0) const ds = datasets?.[dsIndex];
if (dsIndex > 0 && datasets[dsIndex]) { if (!isBuyOrSellDataset(ds)) return;
const label = datasets[dsIndex].label;
// Determine background color based on dataset index
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
const bgColor = dsIndex === 1 ? primaryColor : colors.danger;
// If collision, offset Buy label upwards const label = ds.label;
let yOffset = 0; const bgColor = label === '买入' ? primaryColor : colors.danger;
if (isCollision && dsIndex === 1) {
yOffset = -20;
}
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(); ctx.restore();
@@ -491,8 +579,166 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}]; }];
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair }, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
const lastIndex = data.length > 0 ? data.length - 1 : null;
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
const chartBlock = ( 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' }}> <div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
{loading && ( {loading && (
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}> <div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>

View File

@@ -198,6 +198,7 @@ export default function MarketIndexAccordion({
onHeightChange, onHeightChange,
isMobile, isMobile,
onCustomSettingsChange, onCustomSettingsChange,
refreshing = false,
}) { }) {
const [indices, setIndices] = useState([]); const [indices, setIndices] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -206,6 +207,7 @@ export default function MarketIndexAccordion({
const [settingOpen, setSettingOpen] = useState(false); const [settingOpen, setSettingOpen] = useState(false);
const [tickerIndex, setTickerIndex] = useState(0); const [tickerIndex, setTickerIndex] = useState(0);
const rootRef = useRef(null); const rootRef = useRef(null);
const hasInitializedSelectedCodes = useRef(false);
useEffect(() => { useEffect(() => {
const el = rootRef.current; const el = rootRef.current;
@@ -222,8 +224,9 @@ export default function MarketIndexAccordion({
}; };
}, [onHeightChange, loading, indices.length]); }, [onHeightChange, loading, indices.length]);
useEffect(() => { const loadIndices = () => {
let cancelled = false; let cancelled = false;
setLoading(true);
fetchMarketIndices() fetchMarketIndices()
.then((data) => { .then((data) => {
if (!cancelled) setIndices(Array.isArray(data) ? data : []); if (!cancelled) setIndices(Array.isArray(data) ? data : []);
@@ -234,12 +237,28 @@ export default function MarketIndexAccordion({
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); 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(() => { useEffect(() => {
if (!indices.length || typeof window === 'undefined') return; if (!indices.length || typeof window === 'undefined') return;
if (hasInitializedSelectedCodes.current) return;
try { try {
const stored = window.localStorage.getItem('marketIndexSelected'); const stored = window.localStorage.getItem('marketIndexSelected');
const availableCodes = new Set(indices.map((it) => it.code)); 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)); const filtered = parsed.filter((c) => availableCodes.has(c));
if (filtered.length) { if (filtered.length) {
setSelectedCodes(filtered); setSelectedCodes(filtered);
hasInitializedSelectedCodes.current = true;
return; return;
} }
} }
@@ -344,7 +364,7 @@ export default function MarketIndexAccordion({
display: none; display: none;
} }
:global([data-theme='dark'] .market-index-accordion-root) { :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 { .market-index-ticker {
overflow: hidden; overflow: hidden;

View File

@@ -47,7 +47,7 @@ export default function MobileFundCardDrawer({
{children} {children}
</DrawerTrigger> </DrawerTrigger>
<DrawerContent <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) => { onPointerDownOutside={(e) => {
if (blockDrawerClose) return; if (blockDrawerClose) return;
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) { if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {

View File

@@ -1007,40 +1007,31 @@ export default function PcFundTable({
--row-bg: var(--bg); --row-bg: var(--bg);
background-color: var(--row-bg) !important; background-color: var(--row-bg) !important;
} }
/* 斑马纹行背景(非 hover 状态) */
.table-row-scroll:nth-child(even), .table-row-scroll:nth-child(even),
.table-row-scroll.row-even { .table-row-scroll.row-even {
background-color: var(--table-row-alt-bg) !important; 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 { .table-row-scroll .pinned-cell {
background-color: var(--row-bg) !important; background-color: var(--row-bg) !important;
} }
.table-row-scroll:nth-child(even) .pinned-cell, .table-row-scroll:nth-child(even) .pinned-cell,
.table-row-scroll.row-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 */
.row-even .pinned-cell { .row-even .pinned-cell {
background-color: var(--table-row-alt-bg) !important; background-color: var(--table-row-alt-bg) !important;
} }
/* Pinned cells on hover */ /* Hover 状态优先级最高,覆盖斑马纹和 pinned 背景 */
.table-row-scroll:hover .pinned-cell { .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; background-color: var(--table-row-hover-bg) !important;
} }
@@ -1221,7 +1212,7 @@ export default function PcFundTable({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div <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 ? ( {cardDialogRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" /> <FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />

View File

@@ -2093,10 +2093,12 @@ input[type="number"] {
flex-shrink: 0; flex-shrink: 0;
} }
/* 亮色主题下PC 右侧抽屉里的 Switch 拇指使用浅色,以保证对比度 */
[data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb { [data-theme="light"] .pc-table-setting-drawer .dca-toggle-thumb {
background: #fff; background: #fff;
} }
/* 移动端表格设置底部抽屉 */ /* 移动端表格设置底部抽屉 */
.mobile-setting-overlay { .mobile-setting-overlay {
position: fixed; position: fixed;
@@ -2538,6 +2540,13 @@ input[type="number"] {
transition: left 0.2s; transition: left 0.2s;
} }
/* 亮色主题下:所有使用 dca-toggle 的拇指在浅底上统一用白色,保证对比度
- PC 右侧排序设置抽屉
- 移动端排序个性化设置 Drawer以及其它区域 */
[data-theme="light"] .dca-toggle-thumb {
background: #ffffff;
}
.dca-option-group { .dca-option-group {
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
border-radius: 8px; border-radius: 8px;

View File

@@ -217,12 +217,37 @@ export default function HomePage() {
} }
if (rulesFromSettings && rulesFromSettings.length) { if (rulesFromSettings && rulesFromSettings.length) {
const merged = DEFAULT_SORT_RULES.map((rule) => { // 1先按本地存储的顺序还原包含 alias、enabled 等字段)
const found = rulesFromSettings.find((r) => r.id === rule.id); const defaultMap = new Map(
return found DEFAULT_SORT_RULES.map((rule) => [rule.id, rule])
? { ...rule, enabled: found.enabled !== false } );
: 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); setSortRules(merged);
} }
@@ -1624,7 +1649,9 @@ export default function HomePage() {
if (key === 'funds') { if (key === 'funds') {
const prevSig = getFundCodesSignature(prevValue); const prevSig = getFundCodesSignature(prevValue);
const nextSig = getFundCodesSignature(nextValue); const nextSig = getFundCodesSignature(nextValue);
if (prevSig === nextSig) return; if (prevSig === nextSig) {
return;
}
} }
if (!skipSyncRef.current) { if (!skipSyncRef.current) {
const now = nowInTz().toISOString(); const now = nowInTz().toISOString();
@@ -3692,7 +3719,7 @@ export default function HomePage() {
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className="search-dropdown glass" className="search-dropdown glass scrollbar-y-styled"
> >
{searchResults.length > 0 ? ( {searchResults.length > 0 ? (
<div className="search-results"> <div className="search-results">
@@ -3923,6 +3950,7 @@ export default function HomePage() {
onHeightChange={setMarketIndexAccordionHeight} onHeightChange={setMarketIndexAccordionHeight}
isMobile={isMobile} isMobile={isMobile}
onCustomSettingsChange={triggerCustomSettingsSync} onCustomSettingsChange={triggerCustomSettingsSync}
refreshing={refreshing}
/> />
<div className="grid"> <div className="grid">
<div className="col-12"> <div className="col-12">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

26
entrypoint.sh Normal file
View 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
View 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";
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.6", "version": "0.2.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.6", "version": "0.2.7",
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.2.6", "version": "0.2.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",