Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d90a756b | ||
|
|
cd89f58d14 | ||
|
|
ec7938e2ac | ||
|
|
b23befd143 |
@@ -91,6 +91,7 @@ docker compose up -d
|
|||||||
## 💬 开发者交流群
|
## 💬 开发者交流群
|
||||||
|
|
||||||
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
欢迎基金实时开发者加入微信群聊讨论开发与协作:
|
||||||
|
|
||||||
<img src="./doc/webchatGroup.jpg" width="300">
|
<img src="./doc/webchatGroup.jpg" width="300">
|
||||||
|
|
||||||
## 📝 免责声明
|
## 📝 免责声明
|
||||||
|
|||||||
BIN
app/assets/weChatGroup.png
Normal file
BIN
app/assets/weChatGroup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 563 KiB |
156
app/page.jsx
156
app/page.jsx
@@ -11,6 +11,7 @@ import Announcement from "./components/Announcement";
|
|||||||
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
|
import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common";
|
||||||
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons";
|
import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons";
|
||||||
import githubImg from "./assets/github.svg";
|
import githubImg from "./assets/github.svg";
|
||||||
|
import weChatGroupImg from "./assets/weChatGroup.png";
|
||||||
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
import { supabase, isSupabaseConfigured } from './lib/supabase';
|
||||||
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
|
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds, submitFeedback } from './api/fund';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
@@ -24,7 +25,7 @@ const nowInTz = () => dayjs().tz(TZ);
|
|||||||
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
|
||||||
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
|
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
|
||||||
|
|
||||||
function FeedbackModal({ onClose, user }) {
|
function FeedbackModal({ onClose, user, onOpenWeChat }) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [succeeded, setSucceeded] = useState(false);
|
const [succeeded, setSucceeded] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -148,6 +149,16 @@ function FeedbackModal({ onClose, user }) {
|
|||||||
</a>
|
</a>
|
||||||
区留言互动
|
区留言互动
|
||||||
</p>
|
</p>
|
||||||
|
<p className="muted" style={{ fontSize: '12px', lineHeight: '1.6' }}>
|
||||||
|
或加入我们的
|
||||||
|
<a
|
||||||
|
className="link-button"
|
||||||
|
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
onClick={onOpenWeChat}
|
||||||
|
>
|
||||||
|
微信用户交流群
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -156,6 +167,46 @@ function FeedbackModal({ onClose, user }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WeChatModal({ onClose }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="微信用户交流群"
|
||||||
|
onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{ zIndex: 10002 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="glass card modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: '360px', padding: '24px' }}
|
||||||
|
>
|
||||||
|
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span>💬 微信用户交流群</span>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
|
||||||
|
<CloseIcon width="20" height="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<img src={weChatGroupImg.src} alt="WeChat Group" style={{ maxWidth: '100%', borderRadius: '8px' }} />
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ textAlign: 'center', marginTop: 16, fontSize: '14px' }}>
|
||||||
|
扫码加入群聊,获取最新更新与交流
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HoldingActionModal({ fund, onClose, onAction }) {
|
function HoldingActionModal({ fund, onClose, onAction }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1838,6 +1889,7 @@ export default function HomePage() {
|
|||||||
// 反馈弹窗状态
|
// 反馈弹窗状态
|
||||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
const [feedbackNonce, setFeedbackNonce] = useState(0);
|
const [feedbackNonce, setFeedbackNonce] = useState(0);
|
||||||
|
const [weChatOpen, setWeChatOpen] = useState(false);
|
||||||
|
|
||||||
// 搜索相关状态
|
// 搜索相关状态
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -3021,16 +3073,94 @@ export default function HomePage() {
|
|||||||
const importFileRef = useRef(null);
|
const importFileRef = useRef(null);
|
||||||
const [importMsg, setImportMsg] = useState('');
|
const [importMsg, setImportMsg] = useState('');
|
||||||
|
|
||||||
|
const normalizeCode = (value) => String(value || '').trim();
|
||||||
|
const normalizeNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
};
|
||||||
|
|
||||||
function getComparablePayload(payload) {
|
function getComparablePayload(payload) {
|
||||||
if (!payload || typeof payload !== 'object') return '';
|
if (!payload || typeof payload !== 'object') return '';
|
||||||
|
const rawFunds = Array.isArray(payload.funds) ? payload.funds : [];
|
||||||
|
const fundCodes = rawFunds
|
||||||
|
.map((fund) => normalizeCode(fund?.code || fund?.CODE))
|
||||||
|
.filter(Boolean);
|
||||||
|
const uniqueFundCodes = Array.from(new Set(fundCodes)).sort();
|
||||||
|
|
||||||
|
const favorites = Array.isArray(payload.favorites)
|
||||||
|
? Array.from(new Set(payload.favorites.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const collapsedCodes = Array.isArray(payload.collapsedCodes)
|
||||||
|
? Array.from(new Set(payload.collapsedCodes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const groups = Array.isArray(payload.groups)
|
||||||
|
? payload.groups
|
||||||
|
.map((group) => {
|
||||||
|
const id = normalizeCode(group?.id);
|
||||||
|
if (!id) return null;
|
||||||
|
const name = typeof group?.name === 'string' ? group.name : '';
|
||||||
|
const codes = Array.isArray(group?.codes)
|
||||||
|
? Array.from(new Set(group.codes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort()
|
||||||
|
: [];
|
||||||
|
return { id, name, codes };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const holdingsSource = payload.holdings && typeof payload.holdings === 'object' && !Array.isArray(payload.holdings)
|
||||||
|
? payload.holdings
|
||||||
|
: {};
|
||||||
|
const holdings = {};
|
||||||
|
Object.keys(holdingsSource)
|
||||||
|
.map(normalizeCode)
|
||||||
|
.filter((code) => uniqueFundCodes.includes(code))
|
||||||
|
.sort()
|
||||||
|
.forEach((code) => {
|
||||||
|
const value = holdingsSource[code] || {};
|
||||||
|
const share = normalizeNumber(value.share);
|
||||||
|
const cost = normalizeNumber(value.cost);
|
||||||
|
if (share === null && cost === null) return;
|
||||||
|
holdings[code] = { share, cost };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingTrades = Array.isArray(payload.pendingTrades)
|
||||||
|
? payload.pendingTrades
|
||||||
|
.map((trade) => {
|
||||||
|
const fundCode = normalizeCode(trade?.fundCode);
|
||||||
|
if (!fundCode) return null;
|
||||||
|
return {
|
||||||
|
id: trade?.id ? String(trade.id) : '',
|
||||||
|
fundCode,
|
||||||
|
type: trade?.type || '',
|
||||||
|
share: normalizeNumber(trade?.share),
|
||||||
|
amount: normalizeNumber(trade?.amount),
|
||||||
|
feeRate: normalizeNumber(trade?.feeRate),
|
||||||
|
feeMode: trade?.feeMode || '',
|
||||||
|
feeValue: normalizeNumber(trade?.feeValue),
|
||||||
|
date: trade?.date || '',
|
||||||
|
isAfter3pm: !!trade?.isAfter3pm
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((trade) => trade && uniqueFundCodes.includes(trade.fundCode))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const keyA = a.id || `${a.fundCode}|${a.type}|${a.date}|${a.share ?? ''}|${a.amount ?? ''}|${a.feeMode}|${a.feeValue ?? ''}|${a.feeRate ?? ''}|${a.isAfter3pm ? 1 : 0}`;
|
||||||
|
const keyB = b.id || `${b.fundCode}|${b.type}|${b.date}|${b.share ?? ''}|${b.amount ?? ''}|${b.feeMode}|${b.feeValue ?? ''}|${b.feeRate ?? ''}|${b.isAfter3pm ? 1 : 0}`;
|
||||||
|
return keyA.localeCompare(keyB);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
funds: Array.isArray(payload.funds) ? payload.funds : [],
|
funds: uniqueFundCodes,
|
||||||
favorites: Array.isArray(payload.favorites) ? payload.favorites : [],
|
favorites,
|
||||||
groups: Array.isArray(payload.groups) ? payload.groups : [],
|
groups,
|
||||||
collapsedCodes: Array.isArray(payload.collapsedCodes) ? payload.collapsedCodes : [],
|
collapsedCodes,
|
||||||
refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000,
|
refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000,
|
||||||
holdings: payload.holdings && typeof payload.holdings === 'object' ? payload.holdings : {},
|
holdings,
|
||||||
pendingTrades: Array.isArray(payload.pendingTrades) ? payload.pendingTrades : []
|
pendingTrades
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3432,7 +3562,8 @@ export default function HomePage() {
|
|||||||
!!clearConfirm ||
|
!!clearConfirm ||
|
||||||
donateOpen ||
|
donateOpen ||
|
||||||
!!fundDeleteConfirm ||
|
!!fundDeleteConfirm ||
|
||||||
updateModalOpen;
|
updateModalOpen ||
|
||||||
|
weChatOpen;
|
||||||
|
|
||||||
if (isAnyModalOpen) {
|
if (isAnyModalOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -3458,7 +3589,8 @@ export default function HomePage() {
|
|||||||
tradeModal.open,
|
tradeModal.open,
|
||||||
clearConfirm,
|
clearConfirm,
|
||||||
donateOpen,
|
donateOpen,
|
||||||
updateModalOpen
|
updateModalOpen,
|
||||||
|
weChatOpen
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4545,9 +4677,15 @@ export default function HomePage() {
|
|||||||
key={feedbackNonce}
|
key={feedbackNonce}
|
||||||
onClose={() => setFeedbackOpen(false)}
|
onClose={() => setFeedbackOpen(false)}
|
||||||
user={user}
|
user={user}
|
||||||
|
onOpenWeChat={() => setWeChatOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{weChatOpen && (
|
||||||
|
<WeChatModal onClose={() => setWeChatOpen(false)} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{addResultOpen && (
|
{addResultOpen && (
|
||||||
<AddResultModal
|
<AddResultModal
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.4",
|
"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.4",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user