Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c575b000 | ||
|
|
4a3431cc1e | ||
|
|
12667521a6 | ||
|
|
84d021506b | ||
|
|
e5b00515d3 | ||
|
|
6fad4ee487 |
48
README.md
48
README.md
@@ -84,6 +84,12 @@
|
||||
NEXT_PUBLIC_Supabase_URL:Supabase控制台 → Project Settings → General → Project ID
|
||||
NEXT_PUBLIC_Supabase_ANON_KEY: Supabase控制台 → Project Settings → API Keys → Publishable key
|
||||
|
||||
示例:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxxxxx
|
||||
```
|
||||
|
||||
2. 邮件数量修改
|
||||
|
||||
Supabase 免费项目自带每小时2条邮件服务。如果觉得额度不够,可以改成自己的邮箱SMTP。修改路径在 Supabase控制台 → Authentication → Email → SMTP Settings。
|
||||
@@ -102,7 +108,47 @@
|
||||
|
||||
在 Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → email 中,关闭 **Confirm email** 选项。这样用户注册后就不需要再去邮箱点击确认链接了,直接使用验证码登录即可。
|
||||
|
||||
6. 目前项目用到的 sql 语句,查看项目 /doc/supabase.sql 文件。
|
||||
6. 配置 GitHub 登录(可选)
|
||||
|
||||
如需支持 GitHub OAuth 登录,需完成以下配置:
|
||||
|
||||
**第一步:在 GitHub 创建 OAuth App**
|
||||
- 访问 GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
|
||||
- 填写信息:
|
||||
- Application name:自定义应用名称
|
||||
- Homepage URL:你的应用地址(如 `https://hzm0321.github.io/real-time-fund/`)
|
||||
- Authorization callback URL:`https://<your-supabase-project-id>.supabase.co/auth/v1/callback`
|
||||
- 创建后获取 **Client ID** 和 **Client Secret**
|
||||
|
||||
**第二步:在 Supabase 启用 GitHub Provider**
|
||||
- Supabase控制台 → Authentication → Sign In / Providers → Auth Providers → GitHub
|
||||
- 开启 **GitHub** 开关
|
||||
- 填入 GitHub OAuth App 的 **Client ID** 和 **Client Secret**
|
||||
- 点击 **Save** 保存
|
||||
|
||||
**第三步:配置站点 URL(重要)**
|
||||
- Supabase控制台 → Authentication → URL Configuration
|
||||
- **Site URL**:设置为你的应用主域名(如 `https://hzm0321.github.io/`)
|
||||
- **Redirect URLs**:添加你的应用完整路径(如 `https://hzm0321.github.io/real-time-fund/`)
|
||||
|
||||
配置完成后,用户即可通过 GitHub 账号一键登录。
|
||||
|
||||
7. 执行数据库初始化 SQL
|
||||
|
||||
项目需要创建 `user_configs` 表及相关策略才能使用云端同步功能。SQL 语句位于项目 `/doc/supabase.sql` 文件。
|
||||
|
||||
**执行步骤:**
|
||||
- Supabase控制台 → SQL Editor → New query
|
||||
- 复制 `/doc/supabase.sql` 文件中的全部内容,粘贴到编辑器
|
||||
- 点击 **Run** 执行
|
||||
|
||||
SQL 脚本将完成以下操作:
|
||||
- 创建 `user_configs` 表(存储用户配置数据)
|
||||
- 启用行级安全(RLS),确保用户只能访问自己的数据
|
||||
- 创建 SELECT / INSERT / UPDATE 策略
|
||||
- 创建 `update_user_config_partial` 函数(用于增量更新配置)
|
||||
|
||||
执行成功后,可在 Table Editor 中看到 `user_configs` 表。
|
||||
|
||||
更多 Supabase 相关内容查阅官方文档。
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function FundCard({
|
||||
dcaPlans,
|
||||
holdings,
|
||||
percentModes,
|
||||
todayPercentModes,
|
||||
valuationSeries,
|
||||
collapsedCodes,
|
||||
collapsedTrends,
|
||||
@@ -67,6 +68,7 @@ export default function FundCard({
|
||||
onHoldingClick,
|
||||
onActionClick,
|
||||
onPercentModeToggle,
|
||||
onTodayPercentModeToggle,
|
||||
onToggleCollapse,
|
||||
onToggleTrendCollapse,
|
||||
layoutMode = 'card', // 'card' | 'drawer',drawer 时前10重仓与业绩走势以 Tabs 展示
|
||||
@@ -281,8 +283,28 @@ export default function FundCard({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
|
||||
<span className="label">当日收益</span>
|
||||
<div
|
||||
className="stat"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (profit.profitToday != null) {
|
||||
onTodayPercentModeToggle?.(f.code);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: profit.profitToday != null ? 'pointer' : 'default',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
title={profit.profitToday != null ? '点击切换金额/百分比' : ''}
|
||||
>
|
||||
<span
|
||||
className="label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
当日收益{todayPercentModes?.[f.code] ? '(%)' : ''}
|
||||
{profit.profitToday != null && <SwitchIcon />}
|
||||
</span>
|
||||
<span
|
||||
className={`value ${
|
||||
profit.profitToday != null
|
||||
@@ -297,7 +319,16 @@ export default function FundCard({
|
||||
{profit.profitToday != null
|
||||
? masked
|
||||
? '******'
|
||||
: `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
|
||||
: <>
|
||||
{profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}
|
||||
{todayPercentModes?.[f.code]
|
||||
? `${Math.abs(
|
||||
holding?.cost * holding?.share
|
||||
? (profit.profitToday / (holding.cost * holding.share)) * 100
|
||||
: 0,
|
||||
).toFixed(2)}%`
|
||||
: `¥${Math.abs(profit.profitToday).toFixed(2)}`}
|
||||
</>
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const previousValue = useRef(value);
|
||||
const isFirstChange = useRef(true);
|
||||
const rafIdRef = useRef(null);
|
||||
const displayValueRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValue.current === value) return;
|
||||
@@ -15,13 +17,14 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
||||
if (isFirstChange.current) {
|
||||
isFirstChange.current = false;
|
||||
previousValue.current = value;
|
||||
displayValueRef.current = value;
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = previousValue.current;
|
||||
const start = displayValueRef.current;
|
||||
const end = value;
|
||||
const duration = 400;
|
||||
const duration = 300;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
@@ -29,16 +32,25 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 4);
|
||||
const current = start + (end - start) * ease;
|
||||
displayValueRef.current = current;
|
||||
setDisplayValue(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
previousValue.current = value;
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
@@ -64,6 +76,7 @@ export default function GroupSummary({
|
||||
navbarHeight
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [showTodayPercent, setShowTodayPercent] = useState(false);
|
||||
const [isMasked, setIsMasked] = useState(masked ?? false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
@@ -137,6 +150,7 @@ export default function GroupSummary({
|
||||
const roundedTotalProfitToday = Math.round(totalProfitToday * 100) / 100;
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
const todayReturnRate = totalCost > 0 ? (roundedTotalProfitToday / totalCost) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAsset,
|
||||
@@ -144,6 +158,7 @@ export default function GroupSummary({
|
||||
totalHoldingReturn,
|
||||
hasHolding,
|
||||
returnRate,
|
||||
todayReturnRate,
|
||||
hasAnyTodayData,
|
||||
};
|
||||
}, [funds, holdings, getProfit]);
|
||||
@@ -277,9 +292,17 @@ export default function GroupSummary({
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div
|
||||
className="muted"
|
||||
style={{ fontSize: '12px', marginBottom: 4 }}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
当日收益
|
||||
当日收益{showTodayPercent ? '(%)' : ''}{' '}
|
||||
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
@@ -295,7 +318,10 @@ export default function GroupSummary({
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
cursor: summary.hasAnyTodayData ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => summary.hasAnyTodayData && setShowTodayPercent(!showTodayPercent)}
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
{isMasked ? (
|
||||
<span className="mask-text" style={{ fontSize: metricSize }}>
|
||||
@@ -310,10 +336,18 @@ export default function GroupSummary({
|
||||
? '-'
|
||||
: ''}
|
||||
</span>
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalProfitToday)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
{showTodayPercent ? (
|
||||
<CountUp
|
||||
value={Math.abs(summary.todayReturnRate)}
|
||||
suffix="%"
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
) : (
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalProfitToday)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: metricSize }}>--</span>
|
||||
|
||||
@@ -202,7 +202,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
};
|
||||
|
||||
const isValid = isBuy
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta && (parseFloat(amount) || 0) >= (Number(minBuyAmount) || 0))
|
||||
? (!!amount && !!feeRate && !!date && calcShare !== null && !loadingBuyMeta)
|
||||
: (!!share && !!date);
|
||||
|
||||
const handleSetShareFraction = (fraction) => {
|
||||
@@ -434,9 +434,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
border: (!amount || (Number(minBuyAmount) > 0 && (parseFloat(amount) || 0) < Number(minBuyAmount)))
|
||||
? '1px solid var(--danger)'
|
||||
: '1px solid var(--border)',
|
||||
border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)',
|
||||
borderRadius: 12
|
||||
}}
|
||||
>
|
||||
@@ -444,15 +442,10 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
step={100}
|
||||
min={Number(minBuyAmount) || 0}
|
||||
placeholder={(Number(minBuyAmount) || 0) > 0 ? `最少 ¥${Number(minBuyAmount)},请输入加仓金额` : '请输入加仓金额'}
|
||||
min={0}
|
||||
placeholder="请输入加仓金额"
|
||||
/>
|
||||
</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 }}>
|
||||
|
||||
13
app/page.jsx
13
app/page.jsx
@@ -442,6 +442,7 @@ export default function HomePage() {
|
||||
const [historyModal, setHistoryModal] = useState({ open: false, fund: null });
|
||||
const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null });
|
||||
const [percentModes, setPercentModes] = useState({}); // { [code]: boolean }
|
||||
const [todayPercentModes, setTodayPercentModes] = useState({}); // { [code]: boolean }
|
||||
|
||||
const holdingsRef = useRef(holdings);
|
||||
const pendingTradesRef = useRef(pendingTrades);
|
||||
@@ -4334,7 +4335,9 @@ export default function HomePage() {
|
||||
favorites,
|
||||
dcaPlans,
|
||||
holdings,
|
||||
percentModes,
|
||||
percentModes,
|
||||
todayPercentModes,
|
||||
todayPercentModes,
|
||||
valuationSeries,
|
||||
collapsedCodes,
|
||||
collapsedTrends,
|
||||
@@ -4350,6 +4353,8 @@ export default function HomePage() {
|
||||
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||
onPercentModeToggle: (code) =>
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onTodayPercentModeToggle: (code) =>
|
||||
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
@@ -4426,6 +4431,8 @@ export default function HomePage() {
|
||||
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||
onPercentModeToggle: (code) =>
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onTodayPercentModeToggle: (code) =>
|
||||
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
masked: maskAmounts,
|
||||
@@ -4455,6 +4462,7 @@ export default function HomePage() {
|
||||
dcaPlans={dcaPlans}
|
||||
holdings={holdings}
|
||||
percentModes={percentModes}
|
||||
todayPercentModes={todayPercentModes}
|
||||
valuationSeries={valuationSeries}
|
||||
collapsedCodes={collapsedCodes}
|
||||
collapsedTrends={collapsedTrends}
|
||||
@@ -4471,6 +4479,9 @@ export default function HomePage() {
|
||||
onPercentModeToggle={(code) =>
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||
}
|
||||
onTodayPercentModeToggle={(code) =>
|
||||
setTodayPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||
}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onToggleTrendCollapse={toggleTrendCollapse}
|
||||
masked={maskAmounts}
|
||||
|
||||
Reference in New Issue
Block a user