26 Commits

Author SHA1 Message Date
hzm
a3d90a756b feat: 发布 0.1.5 2026-02-09 10:31:09 +08:00
hzm
cd89f58d14 fix: 修复同步数据变化判断问题 2026-02-09 10:30:20 +08:00
hzm
ec7938e2ac feat:增加微信用户交流群入口 2026-02-09 09:02:39 +08:00
hzm
b23befd143 feat:修改 README 2026-02-09 08:38:28 +08:00
hzm
1f72dea441 feat:调整公共,发布 0.1.4 版本 2026-02-09 08:32:19 +08:00
hzm
7ceb43e7a6 feat:未配置 supabase 能正常启动项目 2026-02-09 08:15:18 +08:00
hzm
10200b8c8b fix:修改 docker 增加 env 信息 2026-02-08 23:00:36 +08:00
hzm
c27ea46738 fix:分组 tab 移动端选中样式问题 2026-02-08 22:47:06 +08:00
hzm
81783a8c36 feat:调整打包配置 2026-02-08 22:39:39 +08:00
hzm
b861e3abff feat:补充 supabase sql 2026-02-08 22:37:54 +08:00
hzm
6c1ddc6add feat:组件拆分和性能优化 2026-02-08 22:31:27 +08:00
hzm
6d12e9485f feat:环境变量优化 2026-02-08 21:33:37 +08:00
hzm
173330fa7f feat:修改 ignore 2026-02-08 20:52:00 +08:00
hzm
a76ced7bdc fix:日期选择问题 2026-02-08 20:00:51 +08:00
hzm
f0a95ac19f feat:增加加减仓 2026-02-08 19:29:45 +08:00
hzm
28352e87c1 feat:发布0.1.3 2026-02-08 08:01:12 +08:00
hzm
ed5fd98c7c feat:调整 localStorage 监听方案 2026-02-08 07:57:34 +08:00
hzm
003271a684 feat: 数据如果正在同步到云,增加图标提示 2026-02-08 07:30:35 +08:00
hzm
7772de1acf fix: 完善会话过期逻辑 2026-02-08 07:14:43 +08:00
hzm
4d4b931e30 fix: 远端退出登录接口异常导致本地无法清理登录信息的问题 2026-02-08 06:49:46 +08:00
hzm
406f14150d fix: 已登录用户本地与云端不一致则提示 2026-02-08 06:38:22 +08:00
hzm
2964cb2318 fix: 补充 userId 不存在错误提示的情况 2026-02-08 06:16:09 +08:00
hzm
ff4a10c84d fix: 补充同步失败错误提示的情况 2026-02-08 06:03:00 +08:00
hzm
d69bf547ac fix: 界面展示同步失败错误提示 2026-02-08 05:51:43 +08:00
hzm
fe5577265a fix: 界面展示同步失败错误提示 2026-02-08 05:44:56 +08:00
hzm
e7b28dfb30 feat:token 过期重新登录 2026-02-08 05:32:53 +08:00
19 changed files with 2248 additions and 1037 deletions

View File

@@ -14,6 +14,13 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Create .env.local
run: |
cat << 'EOF' > .env.local
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
EOF
- name: Build Dockerfile image - name: Build Dockerfile image
run: docker build -t real-time-fund . run: docker build -t real-time-fund .
@@ -40,6 +47,13 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Create .env.local
run: |
cat << 'EOF' > .env.local
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
EOF
- name: Docker Compose up - name: Docker Compose up
run: | run: |
@@ -53,4 +67,3 @@ jobs:
- name: Cleanup - name: Cleanup
run: docker compose down run: docker compose down

View File

@@ -71,6 +71,13 @@ jobs:
# If source files changed but packages didn't, rebuild from a prior cache. # If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: | restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
- name: Create .env.local
run: |
cat << 'EOF' > .env.local
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }}
EOF
- name: Install dependencies - name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- name: Build with Next.js - name: Build with Next.js

3
.gitignore vendored
View File

@@ -80,3 +80,6 @@ fabric.properties
/node_modules/ /node_modules/
/.next/ /.next/
.vscode/ .vscode/
.env.local
.DS_Store

View File

@@ -38,7 +38,16 @@
npm install npm install
``` ```
3. 运行开发服务器 3. 配置环境变量
```bash
cp env.example .env.local
```
按照 `env.example` 填入以下值:
- `NEXT_PUBLIC_SUPABASE_URL`Supabase 项目 URL
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`Supabase 匿名公钥
- `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`Web3Forms Access Key
4. 运行开发服务器:
```bash ```bash
npm run dev npm run dev
``` ```
@@ -47,6 +56,7 @@
### 构建与部署 ### 构建与部署
本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。 本项目已配置 GitHub Actions。每次推送到 `main` 分支时,会自动执行构建并部署到 GitHub Pages。
如需使用 GitHub Actions 部署,请在 GitHub 项目 Settings → Secrets and variables → Actions 中创建对应的 Repository secrets字段名称与 `.env.local` 保持一致)。
若要手动构建: 若要手动构建:
```bash ```bash
@@ -78,6 +88,12 @@ docker compose up -d
3. **调整频率**:点击右上角“设置”图标,可调整自动刷新的间隔时间。 3. **调整频率**:点击右上角“设置”图标,可调整自动刷新的间隔时间。
4. **删除基金**:点击卡片右上角的红色删除图标即可移除。 4. **删除基金**:点击卡片右上角的红色删除图标即可移除。
## 💬 开发者交流群
欢迎基金实时开发者加入微信群聊讨论开发与协作:
<img src="./doc/webchatGroup.jpg" width="300">
## 📝 免责声明 ## 📝 免责声明
本项目所有数据均来自公开接口,仅供个人学习及参考使用。数据可能存在延迟,不作为任何投资建议。 本项目所有数据均来自公开接口,仅供个人学习及参考使用。数据可能存在延迟,不作为任何投资建议。

406
app/api/fund.js Normal file
View File

@@ -0,0 +1,406 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault('Asia/Shanghai');
const TZ = 'Asia/Shanghai';
const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
export const loadScript = (url) => {
return new Promise((resolve, reject) => {
if (typeof document === 'undefined' || !document.body) return resolve();
const script = document.createElement('script');
script.src = url;
script.async = true;
const cleanup = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onload = () => {
cleanup();
resolve();
};
script.onerror = () => {
cleanup();
reject(new Error('数据加载失败'));
};
document.body.appendChild(script);
});
};
export const fetchFundNetValue = async (code, date) => {
if (typeof window === 'undefined') return null;
const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`;
try {
await loadScript(url);
if (window.apidata && window.apidata.content) {
const content = window.apidata.content;
if (content.includes('暂无数据')) return null;
const rows = content.split('<tr>');
for (const row of rows) {
if (row.includes(`<td>${date}</td>`)) {
const cells = row.match(/<td[^>]*>(.*?)<\/td>/g);
if (cells && cells.length >= 2) {
const valStr = cells[1].replace(/<[^>]+>/g, '');
const val = parseFloat(valStr);
return isNaN(val) ? null : val;
}
}
}
}
return null;
} catch (e) {
return null;
}
};
export const fetchSmartFundNetValue = async (code, startDate) => {
const today = nowInTz().startOf('day');
let current = toTz(startDate).startOf('day');
for (let i = 0; i < 30; i++) {
if (current.isAfter(today)) break;
const dateStr = current.format('YYYY-MM-DD');
const val = await fetchFundNetValue(code, dateStr);
if (val !== null) {
return { date: dateStr, value: val };
}
current = current.add(1, 'day');
}
return null;
};
export const fetchFundDataFallback = async (c) => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('无浏览器环境');
}
return new Promise(async (resolve, reject) => {
const searchCallbackName = `SuggestData_fallback_${Date.now()}`;
const searchUrl = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(c)}&callback=${searchCallbackName}&_=${Date.now()}`;
let fundName = '';
try {
await new Promise((resSearch, rejSearch) => {
window[searchCallbackName] = (data) => {
if (data && data.Datas && data.Datas.length > 0) {
const found = data.Datas.find(d => d.CODE === c);
if (found) {
fundName = found.NAME || found.SHORTNAME || '';
}
}
delete window[searchCallbackName];
resSearch();
};
const script = document.createElement('script');
script.src = searchUrl;
script.async = true;
script.onload = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
delete window[searchCallbackName];
rejSearch(new Error('搜索接口失败'));
};
document.body.appendChild(script);
setTimeout(() => {
if (window[searchCallbackName]) {
delete window[searchCallbackName];
resSearch();
}
}, 3000);
});
} catch (e) {
}
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
const tScript = document.createElement('script');
tScript.src = tUrl;
tScript.onload = () => {
const v = window[`v_jj${c}`];
if (v && v.length > 5) {
const p = v.split('~');
const name = fundName || p[1] || `未知基金(${c})`;
const dwjz = p[5];
const zzl = parseFloat(p[7]);
const jzrq = p[8] ? p[8].slice(0, 10) : '';
if (dwjz) {
resolve({
code: c,
name: name,
dwjz: dwjz,
gsz: null,
gztime: null,
jzrq: jzrq,
gszzl: null,
zzl: !isNaN(zzl) ? zzl : null,
noValuation: true,
holdings: []
});
} else {
reject(new Error('未能获取到基金数据'));
}
} else {
reject(new Error('未能获取到基金数据'));
}
if (document.body.contains(tScript)) document.body.removeChild(tScript);
};
tScript.onerror = () => {
if (document.body.contains(tScript)) document.body.removeChild(tScript);
reject(new Error('基金数据加载失败'));
};
document.body.appendChild(tScript);
});
};
export const fetchFundData = async (c) => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('无浏览器环境');
}
return new Promise(async (resolve, reject) => {
const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`;
const scriptGz = document.createElement('script');
scriptGz.src = gzUrl;
const originalJsonpgz = window.jsonpgz;
window.jsonpgz = (json) => {
window.jsonpgz = originalJsonpgz;
if (!json || typeof json !== 'object') {
fetchFundDataFallback(c).then(resolve).catch(reject);
return;
}
const gszzlNum = Number(json.gszzl);
const gzData = {
code: json.fundcode,
name: json.name,
dwjz: json.dwjz,
gsz: json.gsz,
gztime: json.gztime,
jzrq: json.jzrq,
gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl
};
const tencentPromise = new Promise((resolveT) => {
const tUrl = `https://qt.gtimg.cn/q=jj${c}`;
const tScript = document.createElement('script');
tScript.src = tUrl;
tScript.onload = () => {
const v = window[`v_jj${c}`];
if (v) {
const p = v.split('~');
resolveT({
dwjz: p[5],
zzl: parseFloat(p[7]),
jzrq: p[8] ? p[8].slice(0, 10) : ''
});
} else {
resolveT(null);
}
if (document.body.contains(tScript)) document.body.removeChild(tScript);
};
tScript.onerror = () => {
if (document.body.contains(tScript)) document.body.removeChild(tScript);
resolveT(null);
};
document.body.appendChild(tScript);
});
const holdingsPromise = new Promise((resolveH) => {
const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&_=${Date.now()}`;
loadScript(holdingsUrl).then(async () => {
let holdings = [];
const html = window.apidata?.content || '';
const headerRow = (html.match(/<thead[\s\S]*?<tr[\s\S]*?<\/tr>[\s\S]*?<\/thead>/i) || [])[0] || '';
const headerCells = (headerRow.match(/<th[\s\S]*?>([\s\S]*?)<\/th>/gi) || []).map(th => th.replace(/<[^>]*>/g, '').trim());
let idxCode = -1, idxName = -1, idxWeight = -1;
headerCells.forEach((h, i) => {
const t = h.replace(/\s+/g, '');
if (idxCode < 0 && (t.includes('股票代码') || t.includes('证券代码'))) idxCode = i;
if (idxName < 0 && (t.includes('股票名称') || t.includes('证券名称'))) idxName = i;
if (idxWeight < 0 && (t.includes('占净值比例') || t.includes('占比'))) idxWeight = i;
});
const rows = html.match(/<tbody[\s\S]*?<\/tbody>/i) || [];
const dataRows = rows.length ? rows[0].match(/<tr[\s\S]*?<\/tr>/gi) || [] : html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const r of dataRows) {
const tds = (r.match(/<td[\s\S]*?>([\s\S]*?)<\/td>/gi) || []).map(td => td.replace(/<[^>]*>/g, '').trim());
if (!tds.length) continue;
let code = '';
let name = '';
let weight = '';
if (idxCode >= 0 && tds[idxCode]) {
const m = tds[idxCode].match(/(\d{6})/);
code = m ? m[1] : tds[idxCode];
} else {
const codeIdx = tds.findIndex(txt => /^\d{6}$/.test(txt));
if (codeIdx >= 0) code = tds[codeIdx];
}
if (idxName >= 0 && tds[idxName]) {
name = tds[idxName];
} else if (code) {
const i = tds.findIndex(txt => txt && txt !== code && !/%$/.test(txt));
name = i >= 0 ? tds[i] : '';
}
if (idxWeight >= 0 && tds[idxWeight]) {
const wm = tds[idxWeight].match(/([\d.]+)\s*%/);
weight = wm ? `${wm[1]}%` : tds[idxWeight];
} else {
const wIdx = tds.findIndex(txt => /\d+(?:\.\d+)?\s*%/.test(txt));
weight = wIdx >= 0 ? tds[wIdx].match(/([\d.]+)\s*%/)?.[1] + '%' : '';
}
if (code || name || weight) {
holdings.push({ code, name, weight, change: null });
}
}
holdings = holdings.slice(0, 10);
const needQuotes = holdings.filter(h => /^\d{6}$/.test(h.code) || /^\d{5}$/.test(h.code));
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(',');
if (!tencentCodes) {
resolveH(holdings);
return;
}
const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`;
await new Promise((resQuote) => {
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];
if (dataStr) {
const parts = dataStr.split('~');
if (parts.length > 5) {
h.change = parseFloat(parts[5]);
}
}
});
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
resQuote();
};
scriptQuote.onerror = () => {
if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote);
resQuote();
};
document.body.appendChild(scriptQuote);
});
} catch (e) {
}
}
resolveH(holdings);
}).catch(() => resolveH([]));
});
Promise.all([tencentPromise, holdingsPromise]).then(([tData, holdings]) => {
if (tData) {
if (tData.jzrq && (!gzData.jzrq || tData.jzrq >= gzData.jzrq)) {
gzData.dwjz = tData.dwjz;
gzData.jzrq = tData.jzrq;
gzData.zzl = tData.zzl;
}
}
resolve({ ...gzData, holdings });
});
};
scriptGz.onerror = () => {
window.jsonpgz = originalJsonpgz;
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
reject(new Error('基金数据加载失败'));
};
document.body.appendChild(scriptGz);
setTimeout(() => {
if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz);
}, 5000);
});
};
export const searchFunds = async (val) => {
if (!val.trim()) return [];
if (typeof window === 'undefined' || typeof document === 'undefined') return [];
const callbackName = `SuggestData_${Date.now()}`;
const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
return new Promise((resolve, reject) => {
window[callbackName] = (data) => {
let results = [];
if (data && data.Datas) {
results = data.Datas.filter(d =>
d.CATEGORY === 700 ||
d.CATEGORY === '700' ||
d.CATEGORYDESC === '基金'
);
}
delete window[callbackName];
resolve(results);
};
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = () => {
if (document.body.contains(script)) document.body.removeChild(script);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
delete window[callbackName];
reject(new Error('搜索请求失败'));
};
document.body.appendChild(script);
});
};
export const fetchShanghaiIndexDate = async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://qt.gtimg.cn/q=sh000001&_t=${Date.now()}`;
script.onload = () => {
const data = window.v_sh000001;
let dateStr = null;
if (data) {
const parts = data.split('~');
if (parts.length > 30) {
dateStr = parts[30].slice(0, 8);
}
}
if (document.body.contains(script)) document.body.removeChild(script);
resolve(dateStr);
};
script.onerror = () => {
if (document.body.contains(script)) document.body.removeChild(script);
reject(new Error('指数数据加载失败'));
};
document.body.appendChild(script);
});
};
export const fetchLatestRelease = async () => {
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
if (!res.ok) return null;
const data = await res.json();
return {
tagName: data.tag_name,
body: data.body || ''
};
};
export const submitFeedback = async (formData) => {
const response = await fetch('https://api.web3forms.com/submit', {
method: 'POST',
body: formData
});
return response.json();
};

BIN
app/assets/weChatGroup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 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_v5'; const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v6';
export default function Announcement() { export default function Announcement() {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -65,14 +65,13 @@ export default function Announcement() {
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}> <div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}>
感谢大家反馈的需求现已增加如下功能 感谢大家反馈的需求现已增加如下功能
<p>1. 持仓金额录入支持按金额</p> <p>1. 邮箱账号登录以支持同步本地数据至云端</p>
<p>2. 排序支持升序降序</p> <p>2. 加减仓</p>
<p>3. PC 端表格模式优化</p> <p>3. 版本更新提示</p>
<p>4. 移动端表格模式删除按钮改为向左滑动</p> <p>4. 性能优化</p>
以下功能会在下一个版本上线 以下功能会在下一个版本上线
<p>1. 减仓</p> <p>1. 定投</p>
<p>2. 获取不到估值数据的基金能正常添加仅展示最新净值数据</p> <p>2. 基金历史 K 线</p>
每一个功能的加入都会去精细设计它的UI和交互以符合项目整体的简约风格所以请大家敬请期待
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

286
app/components/Common.jsx Normal file
View File

@@ -0,0 +1,286 @@
'use client';
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import zhifubaoImg from "../assets/zhifubao.jpg";
import weixinImg from "../assets/weixin.jpg";
import { CalendarIcon, MinusIcon, PlusIcon } from './Icons';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault('Asia/Shanghai');
const TZ = 'Asia/Shanghai';
const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
export function DatePicker({ value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
useEffect(() => {
const close = () => setIsOpen(false);
if (isOpen) window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [isOpen]);
const year = currentMonth.year();
const month = currentMonth.month();
const handlePrevMonth = (e) => {
e.stopPropagation();
setCurrentMonth(currentMonth.subtract(1, 'month').startOf('month'));
};
const handleNextMonth = (e) => {
e.stopPropagation();
setCurrentMonth(currentMonth.add(1, 'month').startOf('month'));
};
const handleSelect = (e, day) => {
e.stopPropagation();
const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
const today = nowInTz().startOf('day');
const selectedDate = toTz(dateStr).startOf('day');
if (selectedDate.isAfter(today)) return;
onChange(dateStr);
setIsOpen(false);
};
const daysInMonth = currentMonth.daysInMonth();
const firstDayOfWeek = currentMonth.startOf('month').day();
const days = [];
for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
for (let i = 1; i <= daysInMonth; i++) days.push(i);
return (
<div className="date-picker" style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
<div
className="input-trigger"
onClick={() => setIsOpen(!isOpen)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px',
height: '40px',
background: 'rgba(0,0,0,0.2)',
borderRadius: '8px',
cursor: 'pointer',
border: '1px solid transparent',
transition: 'all 0.2s'
}}
>
<span>{value || '选择日期'}</span>
<CalendarIcon width="16" height="16" className="muted" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="glass card"
style={{
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
marginTop: 8,
padding: 12,
zIndex: 10,
background: 'rgba(30, 41, 59, 0.95)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<div className="calendar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<button type="button" onClick={handlePrevMonth} className="icon-button" style={{ width: 24, height: 24 }}>&lt;</button>
<span style={{ fontWeight: 600 }}>{year} {month + 1}</span>
<button
type="button"
onClick={handleNextMonth}
className="icon-button"
style={{ width: 24, height: 24 }}
>
&gt;
</button>
</div>
<div className="calendar-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
{['日', '一', '二', '三', '四', '五', '六'].map(d => (
<div key={d} className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>{d}</div>
))}
{days.map((d, i) => {
if (!d) return <div key={i} />;
const dateStr = formatDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
const isSelected = value === dateStr;
const today = nowInTz().startOf('day');
const current = toTz(dateStr).startOf('day');
const isToday = current.isSame(today);
const isFuture = current.isAfter(today);
return (
<div
key={i}
onClick={(e) => !isFuture && handleSelect(e, d)}
style={{
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
borderRadius: '6px',
cursor: isFuture ? 'not-allowed' : 'pointer',
background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent',
color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)',
fontWeight: isSelected || isToday ? 600 : 400,
opacity: isFuture ? 0.3 : 1
}}
onMouseEnter={(e) => {
if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
}}
onMouseLeave={(e) => {
if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent';
}}
>
{d}
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function DonateTabs() {
const [method, setMethod] = useState('wechat');
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div className="tabs glass" style={{ padding: 4, borderRadius: 12, width: '100%', display: 'flex' }}>
<button
onClick={() => setMethod('alipay')}
style={{
flex: 1,
padding: '8px 0',
border: 'none',
background: method === 'alipay' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
color: method === 'alipay' ? 'var(--primary)' : 'var(--muted)',
borderRadius: 8,
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
transition: 'all 0.2s ease'
}}
>
支付宝
</button>
<button
onClick={() => setMethod('wechat')}
style={{
flex: 1,
padding: '8px 0',
border: 'none',
background: method === 'wechat' ? 'rgba(34, 211, 238, 0.15)' : 'transparent',
color: method === 'wechat' ? 'var(--primary)' : 'var(--muted)',
borderRadius: 8,
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
transition: 'all 0.2s ease'
}}
>
微信支付
</button>
</div>
<div
style={{
width: 200,
height: 200,
background: 'white',
borderRadius: 12,
padding: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{method === 'alipay' ? (
<img
src={zhifubaoImg.src}
alt="支付宝收款码"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<img
src={weixinImg.src}
alt="微信收款码"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)}
</div>
</div>
);
}
export function NumericInput({ value, onChange, step = 1, min = 0, placeholder }) {
const decimals = String(step).includes('.') ? String(step).split('.')[1].length : 0;
const fmt = (n) => Number(n).toFixed(decimals);
const inc = () => {
const v = parseFloat(value);
const base = isNaN(v) ? 0 : v;
const next = base + step;
onChange(fmt(next));
};
const dec = () => {
const v = parseFloat(value);
const base = isNaN(v) ? 0 : v;
const next = Math.max(min, base - step);
onChange(fmt(next));
};
return (
<div style={{ position: 'relative' }}>
<input
type="number"
step="any"
className="input no-zoom"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{ width: '100%', paddingRight: 56 }}
/>
<div style={{ position: 'absolute', right: 6, top: 6, display: 'flex', flexDirection: 'column', gap: 6 }}>
<button className="icon-button" type="button" onClick={inc} style={{ width: 44, height: 16, padding: 0 }}>
<PlusIcon width="14" height="14" />
</button>
<button className="icon-button" type="button" onClick={dec} style={{ width: 44, height: 16, padding: 0 }}>
<MinusIcon width="14" height="14" />
</button>
</div>
</div>
);
}
export function Stat({ label, value, delta }) {
const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
return (
<div className="stat" style={{ flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span className="label" style={{ fontSize: '11px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span className={`value ${dir}`} style={{ fontSize: '15px', lineHeight: 1.2, whiteSpace: 'nowrap' }}>{value}</span>
</div>
);
}

190
app/components/Icons.jsx Normal file
View File

@@ -0,0 +1,190 @@
'use client';
export function PlusIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function UpdateIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function TrashIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 6h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M8 6l1-2h6l1 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M6 6l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function SettingsIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M19.4 15a7.97 7.97 0 0 0 .1-2l2-1.5-2-3.5-2.3.5a8.02 8.02 0 0 0-1.7-1l-.4-2.3h-4l-.4 2.3a8.02 8.02 0 0 0-1.7 1l-2.3-.5-2 3.5 2 1.5a7.97 7.97 0 0 0 .1 2l-2 1.5 2 3.5 2.3-.5a8.02 8.02 0 0 0 1.7 1l.4 2.3h4l.4-2.3a8.02 8.02 0 0 0 1.7-1l2.3.5 2-3.5-2-1.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function CloudIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M20 17.5a4.5 4.5 0 0 0-1.5-8.77A6 6 0 1 0 6 16.5H18a3.5 3.5 0 0 0 2-6.4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function RefreshIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 0 1 12.5-6.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 5h3v3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20 12a8 8 0 0 1-12.5 6.9" stroke="currentColor" strokeWidth="2" />
<path d="M8 19H5v-3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function ChevronIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function SortIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 7h18M6 12h12M9 17h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function UserIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="2" />
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function LogoutIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16 17 21 12 16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function LoginIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="10 17 15 12 10 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="15" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function MailIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M22 6l-10 7L2 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function GridIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function CloseIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function ExitIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function ListIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function DragIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 8h16M4 12h16M4 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function FolderPlusIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9 13h6m-3-3v6m-9-4V5a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function StarIcon({ filled, ...props }) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function CalendarIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
);
}
export function MinusIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}

View File

@@ -712,6 +712,19 @@ input[type="number"] {
box-shadow: none; box-shadow: none;
} }
@media (hover: none) {
.tab:hover {
color: var(--muted);
background: transparent;
}
.tab.active,
.tab.active:hover {
background: rgba(34, 211, 238, 0.15);
color: var(--primary);
box-shadow: none;
}
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -729,6 +742,14 @@ input[type="number"] {
padding: 16px; padding: 16px;
} }
.pending-list {
scrollbar-width: none;
}
.pending-list::-webkit-scrollbar {
display: none;
}
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

4
app/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="#60A5FA" stroke-width="2" />
<path d="M5 14c2-4 7-6 14-5" stroke="#22D3EE" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -1,11 +1,47 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
// Supabase 配置 const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
// 注意:此处使用 publishable key可安全在客户端使用 const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const supabaseUrl = 'https://mouvsqlmgymsaxikvqsh.supabase.co'; export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey);
const supabaseAnonKey = 'sb_publishable_c5f58knbVz8UgOh6L88MUQ_p9j8c1Q-';
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { const createNoopChannel = () => {
const channel = {
on: () => channel,
subscribe: () => channel
};
return channel;
};
const createNoopTable = () => {
return {
select: () => ({
eq: () => ({
maybeSingle: async () => ({ data: null, error: { message: 'Supabase not configured' } })
})
}),
insert: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
upsert: () => ({
select: async () => ({ data: null, error: { message: 'Supabase not configured' } })
})
};
};
const createNoopSupabase = () => ({
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
onAuthStateChange: () => ({
data: { subscription: { unsubscribe: () => { } } }
}),
signInWithOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
verifyOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
signOut: async () => ({ error: null })
},
from: () => createNoopTable(),
channel: () => createNoopChannel(),
removeChannel: () => { }
});
export const supabase = isSupabaseConfigured ? createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
// 启用自动刷新 token // 启用自动刷新 token
autoRefreshToken: true, autoRefreshToken: true,
@@ -14,4 +50,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
// 检测 URL 中的 session用于邮箱验证回调 // 检测 URL 中的 session用于邮箱验证回调
detectSessionInUrl: true detectSessionInUrl: true
} }
}); }) : createNoopSupabase();

File diff suppressed because it is too large Load Diff

BIN
doc/webchatGroup.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

12
env.example Normal file
View File

@@ -0,0 +1,12 @@
# Supabase 配置
# 从 Supabase 项目设置中获取这些值https://app.supabase.com/project/_/settings/api
# 复制此文件为 .env.local 并填入实际值
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# web3forms 配置
# 从 web3forms 中获取这些值 https://app.web3forms.com/dashboard
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY=your_web3forms_access_key
# 如果要用 Github Actions 部署,需要在 Github 项目 Settings → secrets and actions → Actions → 创建 Repository secrets

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
reactCompiler: true,
}; };
module.exports = nextConfig; module.exports = nextConfig;

58
package-lock.json generated
View File

@@ -1,25 +1,63 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.0", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.0", "version": "0.1.5",
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"next": "^16.1.5", "next": "^16.1.5",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },
"devDependencies": {
"babel-plugin-react-compiler": "^1.0.0"
},
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
} }
}, },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@dicebear/adventurer": { "node_modules/@dicebear/adventurer": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.3.1.tgz", "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.3.1.tgz",
@@ -1177,6 +1215,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -1212,6 +1260,12 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.2", "version": "0.1.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -11,6 +11,7 @@
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",
"@supabase/supabase-js": "^2.78.0", "@supabase/supabase-js": "^2.78.0",
"dayjs": "^1.11.19",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"next": "^16.1.5", "next": "^16.1.5",
"react": "18.3.1", "react": "18.3.1",
@@ -18,5 +19,8 @@
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
},
"devDependencies": {
"babel-plugin-react-compiler": "^1.0.0"
} }
} }

33
supabase.sql Normal file
View File

@@ -0,0 +1,33 @@
-- 建表
create table public.user_configs (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
data json null,
updated_at text null,
user_id uuid not null,
constraint user_configs_pkey primary key (id),
constraint user_configs_user_id_key unique (user_id)
) TABLESPACE pg_default;
-- 启用行级安全RLS
alter table public.user_configs enable row level security;
drop policy if exists "user_configs_select_own" on public.user_configs;
drop policy if exists "user_configs_insert_own" on public.user_configs;
drop policy if exists "user_configs_update_own" on public.user_configs;
create policy "user_configs_select_own"
on public.user_configs
for select
using (auth.uid() = user_id);
create policy "user_configs_insert_own"
on public.user_configs
for insert
with check (auth.uid() = user_id);
create policy "user_configs_update_own"
on public.user_configs
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);