feat: 组件拆分

This commit is contained in:
hzm
2026-02-19 10:54:51 +08:00
parent b4c94db2e8
commit 1234653fa9
22 changed files with 2320 additions and 2100 deletions

5
.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": [
"next/core-web-vitals"
]
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon, PlusIcon } from './Icons';
export default function AddFundToGroupModal({ allFunds, currentGroupCodes, onClose, onAdd }) {
const [selected, setSelected] = useState(new Set());
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
const toggleSelect = (code) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ maxWidth: '500px', width: '90vw' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<PlusIcon width="20" height="20" />
<span>添加基金到分组</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="group-manage-list-container" style={{ maxHeight: '50vh', overflowY: 'auto', paddingRight: '4px' }}>
{availableFunds.length === 0 ? (
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
<p>所有基金已在该分组中</p>
</div>
) : (
<div className="group-manage-list">
{availableFunds.map((fund) => (
<div
key={fund.code}
className={`group-manage-item glass ${selected.has(fund.code) ? 'selected' : ''}`}
onClick={() => toggleSelect(fund.code)}
style={{ cursor: 'pointer' }}
>
<div className="checkbox" style={{ marginRight: 12 }}>
{selected.has(fund.code) && <div className="checked-mark" />}
</div>
<div className="fund-info" style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{fund.name}</div>
<div className="muted" style={{ fontSize: '12px' }}>#{fund.code}</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="row" style={{ marginTop: 24, gap: 12 }}>
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button
className="button"
onClick={() => onAdd(Array.from(selected))}
disabled={selected.size === 0}
style={{ flex: 1 }}
>
确定 ({selected.size})
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon, SettingsIcon } from './Icons';
export default function AddResultModal({ failures, 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 }}
>
<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()}
>
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>部分基金添加失败</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="muted" style={{ marginBottom: 12, fontSize: '14px' }}>
未获取到估值数据的基金如下
</div>
<div className="list">
{failures.map((it, idx) => (
<div className="item" key={idx}>
<span className="name">{it.name || '未知名称'}</span>
<div className="values">
<span className="badge">#{it.code}</span>
</div>
</div>
))}
</div>
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 16 }}>
<button className="button" onClick={onClose}>知道了</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon, CloudIcon } from './Icons';
export default function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
const isConflict = type === 'conflict';
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={isConflict ? "配置冲突提示" : "云端同步提示"}
onClick={isConflict ? undefined : onCancel}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ maxWidth: '420px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CloudIcon width="20" height="20" />
<span>{isConflict ? '发现配置冲突' : '云端暂无配置'}</span>
</div>
{!isConflict && (
<button className="icon-button" onClick={onCancel} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
)}
</div>
<p className="muted" style={{ marginBottom: 20, fontSize: '14px', lineHeight: '1.6' }}>
{isConflict
? '检测到本地配置与云端不一致,请选择操作:'
: '是否将本地配置同步到云端?'}
</p>
<div className="row" style={{ flexDirection: 'column', gap: 12 }}>
<button className="button" onClick={onConfirm}>
{isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
</button>
<button className="button secondary" onClick={onCancel}>
{isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { motion } from 'framer-motion';
import { TrashIcon } from './Icons';
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
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"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 12 }}>
<TrashIcon width="20" height="20" className="danger" />
<span>{title}</span>
</div>
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
{message}
</p>
<div className="row" style={{ gap: 12 }}>
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>{confirmText}</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon } from './Icons';
import { DonateTabs } from './Common';
export default function DonateModal({ onClose }) {
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="赞助" onClick={onClose}>
<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"
style={{ maxWidth: '360px' }}
onClick={(e) => e.stopPropagation()}
>
<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={{ marginBottom: 20 }}>
<DonateTabs />
</div>
<div className="muted" style={{ fontSize: '12px', textAlign: 'center', lineHeight: 1.5 }}>
感谢您的支持您的鼓励是我持续维护和更新的动力
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon, SettingsIcon } from './Icons';
import { submitFeedback } from '../api/fund';
export default function FeedbackModal({ onClose, user, onOpenWeChat }) {
const [submitting, setSubmitting] = useState(false);
const [succeeded, setSucceeded] = useState(false);
const [error, setError] = useState("");
const onSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setError("");
const formData = new FormData(e.target);
const nickname = formData.get("nickname")?.trim();
if (!nickname) {
formData.set("nickname", "匿名");
}
// Web3Forms Access Key
formData.append("access_key", process.env.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY || '');
formData.append("subject", "基估宝 - 用户反馈");
try {
const data = await submitFeedback(formData);
if (data.success) {
setSucceeded(true);
} else {
setError(data.message || "提交失败,请稍后再试");
}
} catch (err) {
setError("网络错误,请检查您的连接");
} finally {
setSubmitting(false);
}
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="意见反馈"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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 feedback-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>意见反馈</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
{succeeded ? (
<div className="success-message" style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '48px', marginBottom: 16 }}>🎉</div>
<h3 style={{ marginBottom: 8 }}>感谢您的反馈</h3>
<p className="muted">我们已收到您的建议会尽快查看</p>
<button className="button" onClick={onClose} style={{ marginTop: 24, width: '100%' }}>
关闭
</button>
</div>
) : (
<form onSubmit={onSubmit} className="feedback-form">
<div className="form-group" style={{ marginBottom: 16 }}>
<label htmlFor="nickname" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
您的昵称可选
</label>
<input
id="nickname"
type="text"
name="nickname"
className="input"
placeholder="匿名"
style={{ width: '100%' }}
/>
</div>
<input type="hidden" name="email" value={user?.email || ''} />
<div className="form-group" style={{ marginBottom: 20 }}>
<label htmlFor="message" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
反馈内容
</label>
<textarea
id="message"
name="message"
className="input"
required
placeholder="请描述您遇到的问题或建议..."
style={{ width: '100%', minHeight: '120px', padding: '12px', resize: 'vertical' }}
/>
</div>
{error && (
<div className="error-text" style={{ marginBottom: 16, textAlign: 'center' }}>
{error}
</div>
)}
<button className="button" type="submit" disabled={submitting} style={{ width: '100%' }}>
{submitting ? '发送中...' : '提交反馈'}
</button>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)', textAlign: 'center' }}>
<p className="muted" style={{ fontSize: '12px', lineHeight: '1.6' }}>
如果您有 Github 账号也可以在本项目
<a
href="https://github.com/hzm0321/real-time-fund/issues"
target="_blank"
rel="noopener noreferrer"
className="link-button"
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }}
>
Issues
</a>
区留言互动
</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>
</form>
)}
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion, Reorder } from 'framer-motion';
import ConfirmModal from './ConfirmModal';
import { CloseIcon, DragIcon, PlusIcon, SettingsIcon, TrashIcon } from './Icons';
export default function GroupManageModal({ groups, onClose, onSave }) {
const [items, setItems] = useState(groups);
const [deleteConfirm, setDeleteConfirm] = useState(null); // { id, name }
const handleReorder = (newOrder) => {
setItems(newOrder);
};
const handleRename = (id, newName) => {
const truncatedName = (newName || '').slice(0, 8);
setItems(prev => prev.map(item => item.id === id ? { ...item, name: truncatedName } : item));
};
const handleDeleteClick = (id, name) => {
const itemToDelete = items.find(it => it.id === id);
const isNew = !groups.find(g => g.id === id);
const isEmpty = itemToDelete && (!itemToDelete.codes || itemToDelete.codes.length === 0);
if (isNew || isEmpty) {
setItems(prev => prev.filter(item => item.id !== id));
} else {
setDeleteConfirm({ id, name });
}
};
const handleConfirmDelete = () => {
if (deleteConfirm) {
setItems(prev => prev.filter(item => item.id !== deleteConfirm.id));
setDeleteConfirm(null);
}
};
const handleAddRow = () => {
const newGroup = {
id: `group_${Date.now()}`,
name: '',
codes: []
};
setItems(prev => [...prev, newGroup]);
};
const handleConfirm = () => {
const hasEmpty = items.some(it => !it.name.trim());
if (hasEmpty) return;
onSave(items);
onClose();
};
const isAllValid = items.every(it => it.name.trim() !== '');
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="管理分组"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ maxWidth: '500px', width: '90vw' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>管理分组</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
{items.length === 0 ? (
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
<p>暂无自定义分组</p>
</div>
) : (
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
<AnimatePresence mode="popLayout">
{items.map((item) => (
<Reorder.Item
key={item.id}
value={item}
className="group-manage-item glass"
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 35,
mass: 1,
layout: { duration: 0.2 }
}}
>
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
<DragIcon width="18" height="18" className="muted" />
</div>
<input
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
value={item.name}
onChange={(e) => handleRename(item.id, e.target.value)}
placeholder="请输入分组名称..."
style={{
flex: 1,
height: '36px',
background: 'rgba(0,0,0,0.2)',
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
}}
/>
<button
className="icon-button danger"
onClick={() => handleDeleteClick(item.id, item.name)}
title="删除分组"
style={{ width: '36px', height: '36px', flexShrink: 0 }}
>
<TrashIcon width="16" height="16" />
</button>
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
)}
<button
className="add-group-row-btn"
onClick={handleAddRow}
style={{
width: '100%',
marginTop: 12,
padding: '10px',
borderRadius: '12px',
border: '1px dashed var(--border)',
background: 'rgba(255,255,255,0.02)',
color: 'var(--muted)',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
>
<PlusIcon width="16" height="16" />
<span>新增分组</span>
</button>
</div>
<div style={{ marginTop: 24 }}>
{!isAllValid && (
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
所有分组名称均不能为空
</div>
)}
<button
className="button"
onClick={handleConfirm}
disabled={!isAllValid}
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
>
完成
</button>
</div>
</motion.div>
<AnimatePresence>
{deleteConfirm && (
<ConfirmModal
title="删除确认"
message={`确定要删除分组 "${deleteConfirm.name}" 吗?分组内的基金不会被删除。`}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon, PlusIcon } from './Icons';
export default function GroupModal({ onClose, onConfirm }) {
const [name, setName] = useState('');
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="新增分组"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<PlusIcon width="20" height="20" />
<span>新增分组</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div className="form-group" style={{ marginBottom: 20 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称最多 8 个字</label>
<input
className="input"
autoFocus
placeholder="请输入分组名称..."
value={name}
onChange={(e) => {
const v = e.target.value || '';
// 限制最多 8 个字符(兼容中英文),超出部分自动截断
setName(v.slice(0, 8));
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) onConfirm(name.trim());
}}
/>
</div>
<div className="row" style={{ gap: 12 }}>
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button className="button" onClick={() => name.trim() && onConfirm(name.trim())} disabled={!name.trim()} style={{ flex: 1 }}>确定</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon, SettingsIcon } from './Icons';
export default function HoldingActionModal({ fund, onClose, onAction }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="持仓操作"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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: '320px' }}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>持仓操作</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div style={{ marginBottom: 20, textAlign: 'center' }}>
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
</div>
<div className="grid" style={{ gap: 12 }}>
<button className="button col-6" onClick={() => onAction('buy')} style={{ background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)' }}>
加仓
</button>
<button className="button col-6" onClick={() => onAction('sell')} style={{ background: 'rgba(248, 113, 113, 0.1)', border: '1px solid var(--danger)', color: 'var(--danger)' }}>
减仓
</button>
<button className="button col-12" onClick={() => onAction('edit')} style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>
编辑持仓
</button>
<button
className="button col-12"
onClick={() => onAction('clear')}
style={{
marginTop: 8,
background: 'linear-gradient(180deg, #ef4444, #f87171)',
border: 'none',
color: '#2b0b0b',
fontWeight: 600
}}
>
清空持仓
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CloseIcon, SettingsIcon } from './Icons';
export default function HoldingEditModal({ fund, holding, onClose, onSave }) {
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
const dwjz = fund?.dwjz || fund?.gsz || 0;
const [share, setShare] = useState('');
const [cost, setCost] = useState('');
const [amount, setAmount] = useState('');
const [profit, setProfit] = useState('');
useEffect(() => {
if (holding) {
const s = holding.share || 0;
const c = holding.cost || 0;
setShare(String(s));
setCost(String(c));
if (dwjz > 0) {
const a = s * dwjz;
const p = (dwjz - c) * s;
setAmount(a.toFixed(2));
setProfit(p.toFixed(2));
}
}
}, [holding, fund]);
const handleModeChange = (newMode) => {
if (newMode === mode) return;
setMode(newMode);
if (newMode === 'share') {
if (amount && dwjz > 0) {
const a = parseFloat(amount);
const p = parseFloat(profit || 0);
const s = a / dwjz;
const principal = a - p;
const c = s > 0 ? principal / s : 0;
setShare(s.toFixed(2));
setCost(c.toFixed(4));
}
} else {
if (share && dwjz > 0) {
const s = parseFloat(share);
const c = parseFloat(cost || 0);
const a = s * dwjz;
const p = (dwjz - c) * s;
setAmount(a.toFixed(2));
setProfit(p.toFixed(2));
}
}
};
const handleSubmit = (e) => {
e.preventDefault();
let finalShare = 0;
let finalCost = 0;
if (mode === 'share') {
if (!share || !cost) return;
finalShare = Number(Number(share).toFixed(2));
finalCost = Number(cost);
} else {
if (!amount || !dwjz) return;
const a = Number(amount);
const p = Number(profit || 0);
const rawShare = a / dwjz;
finalShare = Number(rawShare.toFixed(2));
const principal = a - p;
finalCost = finalShare > 0 ? principal / finalShare : 0;
}
onSave({
share: finalShare,
cost: finalCost
});
onClose();
};
const isValid = mode === 'share'
? (share && cost && !isNaN(share) && !isNaN(cost))
: (amount && !isNaN(amount) && (!profit || !isNaN(profit)) && dwjz > 0);
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="编辑持仓"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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: '400px' }}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>设置持仓</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div style={{ marginBottom: 16 }}>
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
<div className="badge" style={{ fontSize: '12px' }}>
最新净值<span style={{ fontWeight: 600, color: 'var(--primary)' }}>{dwjz}</span>
</div>
</div>
</div>
<div className="tabs-container" style={{ marginBottom: 20, background: 'rgba(255,255,255,0.05)', padding: 4, borderRadius: 12 }}>
<div className="row" style={{ gap: 0 }}>
<button
type="button"
className={`tab ${mode === 'amount' ? 'active' : ''}`}
onClick={() => handleModeChange('amount')}
style={{ flex: 1, justifyContent: 'center', height: 32, borderRadius: 8 }}
>
按金额
</button>
<button
type="button"
className={`tab ${mode === 'share' ? 'active' : ''}`}
onClick={() => handleModeChange('share')}
style={{ flex: 1, justifyContent: 'center', height: 32, borderRadius: 8 }}
>
按份额
</button>
</div>
</div>
<form onSubmit={handleSubmit}>
{mode === 'amount' ? (
<>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
持有金额 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
type="number"
step="any"
className={`input ${!amount ? 'error' : ''}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="请输入持有总金额"
style={{
width: '100%',
border: !amount ? '1px solid var(--danger)' : undefined
}}
/>
</div>
<div className="form-group" style={{ marginBottom: 24 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
持有收益
</label>
<input
type="number"
step="any"
className="input"
value={profit}
onChange={(e) => setProfit(e.target.value)}
placeholder="请输入持有总收益 (可为负)"
style={{ width: '100%' }}
/>
</div>
</>
) : (
<>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
持有份额 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
type="number"
step="any"
className={`input ${!share ? 'error' : ''}`}
value={share}
onChange={(e) => setShare(e.target.value)}
placeholder="请输入持有份额"
style={{
width: '100%',
border: !share ? '1px solid var(--danger)' : undefined
}}
/>
</div>
<div className="form-group" style={{ marginBottom: 24 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
持仓成本价 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
type="number"
step="any"
className={`input ${!cost ? 'error' : ''}`}
value={cost}
onChange={(e) => setCost(e.target.value)}
placeholder="请输入持仓成本价"
style={{
width: '100%',
border: !cost ? '1px solid var(--danger)' : undefined
}}
/>
</div>
</>
)}
<div className="row" style={{ gap: 12 }}>
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button
type="submit"
className="button"
disabled={!isValid}
style={{ flex: 1, opacity: isValid ? 1 : 0.6 }}
>
保存
</button>
</div>
</form>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { MailIcon } from './Icons';
export default function LoginModal({
onClose,
loginEmail,
setLoginEmail,
loginOtp,
setLoginOtp,
loginLoading,
loginError,
loginSuccess,
handleSendOtp,
handleVerifyEmailOtp
}) {
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="登录"
onClick={onClose}
>
<div className="glass card modal login-modal" onClick={(e) => e.stopPropagation()}>
<div className="title" style={{ marginBottom: 16 }}>
<MailIcon width="20" height="20" />
<span>邮箱登录</span>
<span className="muted">使用邮箱验证登录</span>
</div>
<form onSubmit={handleSendOtp}>
<div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
请输入邮箱我们将发送验证码到您的邮箱
</div>
<input
style={{ width: '100%' }}
className="input"
type="email"
placeholder="your@email.com"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
disabled={loginLoading || !!loginSuccess}
/>
</div>
{loginSuccess && (
<div className="login-message success" style={{ marginBottom: 12 }}>
<span>{loginSuccess}</span>
</div>
)}
{loginSuccess && (
<div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
请输入邮箱验证码以完成注册/登录
</div>
<input
className="input"
type="text"
placeholder="输入验证码"
value={loginOtp}
onChange={(e) => setLoginOtp(e.target.value)}
disabled={loginLoading}
maxLength={6}
/>
</div>
)}
{loginError && (
<div className="login-message error" style={{ marginBottom: 12 }}>
<span>{loginError}</span>
</div>
)}
<div className="row" style={{ justifyContent: 'flex-end', gap: 12 }}>
<button
type="button"
className="button secondary"
onClick={onClose}
disabled={loginLoading}
>
取消
</button>
<button
className="button"
type={loginSuccess ? 'button' : 'submit'}
onClick={loginSuccess ? handleVerifyEmailOtp : undefined}
disabled={loginLoading || (loginSuccess && !loginOtp)}
>
{loginLoading ? '处理中...' : loginSuccess ? '确认验证码' : '发送邮箱验证码'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon } from './Icons';
export default function ScanImportConfirmModal({
scannedFunds,
selectedScannedCodes,
onClose,
onToggle,
onConfirm
}) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="确认导入基金"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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={{ width: 460, maxWidth: '90vw' }}
>
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<span>确认导入基金</span>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
{scannedFunds.length === 0 ? (
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6 }}>
未识别到有效的基金代码请尝试更清晰的截图或手动搜索
</div>
) : (
<div className="search-results pending-list" style={{ maxHeight: 320, overflowY: 'auto' }}>
{scannedFunds.map((item) => {
const isSelected = selectedScannedCodes.has(item.code);
const isAlreadyAdded = item.status === 'added';
const isInvalid = item.status === 'invalid';
const isDisabled = isAlreadyAdded || isInvalid;
const displayName = item.name || (isInvalid ? '未找到基金' : '未知基金');
return (
<div
key={item.code}
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (isDisabled) return;
onToggle(item.code);
}}
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
>
<div className="fund-info">
<span className="fund-name">{displayName}</span>
<span className="fund-code muted">#{item.code}</span>
</div>
{isAlreadyAdded ? (
<span className="added-label">已添加</span>
) : isInvalid ? (
<span className="added-label">未找到</span>
) : (
<div className="checkbox">
{isSelected && <div className="checked-mark" />}
</div>
)}
</div>
);
})}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
<button className="button secondary" onClick={onClose}>取消</button>
<button className="button" onClick={onConfirm} disabled={selectedScannedCodes.size === 0}>确认导入</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { motion } from 'framer-motion';
export default function ScanImportProgressModal({ scanImportProgress }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="导入进度"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ width: 320, maxWidth: '90vw', textAlign: 'center', padding: '24px' }}
>
<div style={{ marginBottom: 16 }}>
<div className="loading-spinner" style={{
width: 40,
height: 40,
border: '3px solid var(--muted)',
borderTopColor: 'var(--primary)',
borderRadius: '50%',
margin: '0 auto',
animation: 'spin 1s linear infinite'
}} />
</div>
<div className="title" style={{ justifyContent: 'center', marginBottom: 8 }}>
正在导入基金
</div>
{scanImportProgress.total > 0 && (
<div className="muted" style={{ marginBottom: 12 }}>
进度 {scanImportProgress.current} / {scanImportProgress.total}
</div>
)}
<div className="muted" style={{ fontSize: 12, lineHeight: 1.6 }}>
成功 {scanImportProgress.success}失败 {scanImportProgress.failed}
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { motion } from 'framer-motion';
export default function ScanPickModal({ onClose, onPick, isScanning }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="选择持仓截图"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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={{ width: 420, maxWidth: '90vw' }}
>
<div className="title" style={{ marginBottom: 12 }}>
<span>选择持仓截图</span>
</div>
<div className="muted" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 12 }}>
从相册选择一张或多张持仓截图系统将自动识别其中的基金代码6位数字并支持批量导入
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="button secondary" onClick={onClose}>取消</button>
<button className="button" onClick={onPick} disabled={isScanning}>
{isScanning ? '处理中…' : '选择图片'}
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { motion } from 'framer-motion';
export default function ScanProgressModal({ scanProgress, onCancel }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="识别进度"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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"
style={{ width: 320, maxWidth: '90vw', textAlign: 'center', padding: '24px' }}
>
<div style={{ marginBottom: 16 }}>
<div className="loading-spinner" style={{
width: 40,
height: 40,
border: '3px solid var(--muted)',
borderTopColor: 'var(--primary)',
borderRadius: '50%',
margin: '0 auto',
animation: 'spin 1s linear infinite'
}} />
</div>
<div className="title" style={{ justifyContent: 'center', marginBottom: 8 }}>
{scanProgress.stage === 'verify' ? '正在验证基金…' : '正在识别中…'}
</div>
{scanProgress.total > 0 && (
<div className="muted" style={{ marginBottom: 20 }}>
{scanProgress.stage === 'verify'
? `已验证 ${scanProgress.current} / ${scanProgress.total} 只基金`
: `已处理 ${scanProgress.current} / ${scanProgress.total} 张图片`}
</div>
)}
<button
className="button danger"
onClick={onCancel}
style={{ width: '100%' }}
>
终止识别
</button>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { SettingsIcon } from './Icons';
export default function SettingsModal({
onClose,
tempSeconds,
setTempSeconds,
saveSettings,
exportLocalData,
importFileRef,
handleImportFileChange,
importMsg
}) {
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={onClose}>
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
<div className="title" style={{ marginBottom: 12 }}>
<SettingsIcon width="20" height="20" />
<span>设置</span>
<span className="muted">配置刷新频率</span>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</div>
<div className="chips" style={{ marginBottom: 12 }}>
{[10, 30, 60, 120, 300].map((s) => (
<button
key={s}
type="button"
className={`chip ${tempSeconds === s ? 'active' : ''}`}
onClick={() => setTempSeconds(s)}
aria-pressed={tempSeconds === s}
>
{s}
</button>
))}
</div>
<input
className="input"
type="number"
min="10"
step="5"
value={tempSeconds}
onChange={(e) => setTempSeconds(Number(e.target.value))}
placeholder="自定义秒数"
/>
{tempSeconds < 10 && (
<div className="error-text" style={{ marginTop: 8 }}>
最小 10
</div>
)}
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
<div className="row" style={{ gap: 8 }}>
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
</div>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
<div className="row" style={{ gap: 8, marginTop: 8 }}>
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
</div>
<input
ref={importFileRef}
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={handleImportFileChange}
/>
{importMsg && (
<div className="muted" style={{ marginTop: 8 }}>
{importMsg}
</div>
)}
</div>
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
<button className="button" onClick={saveSettings} disabled={tempSeconds < 10}>保存并关闭</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { motion } from 'framer-motion';
export default function SuccessModal({ message, 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 }}
>
<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()}
>
<div className="success-message" style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '48px', marginBottom: 16 }}>🎉</div>
<h3 style={{ marginBottom: 8 }}>{message}</h3>
<p className="muted">操作已完成您可以继续使用</p>
<button className="button" onClick={onClose} style={{ marginTop: 24, width: '100%' }}>
关闭
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,697 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { fetchSmartFundNetValue } from '../api/fund';
import { DatePicker, NumericInput } from './Common';
import ConfirmModal from './ConfirmModal';
import { CloseIcon } from './Icons';
dayjs.extend(utc);
dayjs.extend(timezone);
const DEFAULT_TZ = 'Asia/Shanghai';
const getBrowserTimeZone = () => {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || DEFAULT_TZ;
}
return DEFAULT_TZ;
};
const TZ = getBrowserTimeZone();
dayjs.tz.setDefault(TZ);
const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
export default function TradeModal({ type, fund, holding, onClose, onConfirm, pendingTrades = [], onDeletePending }) {
const isBuy = type === 'buy';
const [share, setShare] = useState('');
const [amount, setAmount] = useState('');
const [feeRate, setFeeRate] = useState('0');
const [date, setDate] = useState(() => {
return formatDate();
});
const [isAfter3pm, setIsAfter3pm] = useState(nowInTz().hour() >= 15);
const [calcShare, setCalcShare] = useState(null);
const currentPendingTrades = useMemo(() => {
return pendingTrades.filter(t => t.fundCode === fund?.code);
}, [pendingTrades, fund]);
const pendingSellShare = useMemo(() => {
return currentPendingTrades
.filter(t => t.type === 'sell')
.reduce((acc, curr) => acc + (Number(curr.share) || 0), 0);
}, [currentPendingTrades]);
const availableShare = holding ? Math.max(0, holding.share - pendingSellShare) : 0;
const [showPendingList, setShowPendingList] = useState(false);
useEffect(() => {
if (showPendingList && currentPendingTrades.length === 0) {
setShowPendingList(false);
}
}, [showPendingList, currentPendingTrades]);
const getEstimatePrice = () => fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz));
const [price, setPrice] = useState(getEstimatePrice());
const [loadingPrice, setLoadingPrice] = useState(false);
const [actualDate, setActualDate] = useState(null);
useEffect(() => {
if (date && fund?.code) {
setLoadingPrice(true);
setActualDate(null);
let queryDate = date;
if (isAfter3pm) {
queryDate = toTz(date).add(1, 'day').format('YYYY-MM-DD');
}
fetchSmartFundNetValue(fund.code, queryDate).then(result => {
if (result) {
setPrice(result.value);
setActualDate(result.date);
} else {
setPrice(0);
setActualDate(null);
}
}).finally(() => setLoadingPrice(false));
}
}, [date, isAfter3pm, isBuy, fund]);
const [feeMode, setFeeMode] = useState('rate');
const [feeValue, setFeeValue] = useState('0');
const [showConfirm, setShowConfirm] = useState(false);
const sellShare = parseFloat(share) || 0;
const sellPrice = parseFloat(price) || 0;
const sellAmount = sellShare * sellPrice;
let sellFee = 0;
if (feeMode === 'rate') {
const rate = parseFloat(feeValue) || 0;
sellFee = sellAmount * (rate / 100);
} else {
sellFee = parseFloat(feeValue) || 0;
}
const estimatedReturn = sellAmount - sellFee;
useEffect(() => {
if (!isBuy) return;
const a = parseFloat(amount);
const f = parseFloat(feeRate);
const p = parseFloat(price);
if (a > 0 && !isNaN(f)) {
if (p > 0) {
const netAmount = a / (1 + f / 100);
const s = netAmount / p;
setCalcShare(s.toFixed(2));
} else {
setCalcShare('待确认');
}
} else {
setCalcShare(null);
}
}, [isBuy, amount, feeRate, price]);
const handleSubmit = (e) => {
e.preventDefault();
if (isBuy) {
if (!amount || !feeRate || !date || calcShare === null) return;
setShowConfirm(true);
} else {
if (!share || !date) return;
setShowConfirm(true);
}
};
const handleFinalConfirm = () => {
if (isBuy) {
onConfirm({ share: calcShare === '待确认' ? null : Number(calcShare), price: Number(price), totalCost: Number(amount), date, isAfter3pm, feeRate: Number(feeRate) });
return;
}
onConfirm({ share: Number(share), price: Number(price), date: actualDate || date, isAfter3pm, feeMode, feeValue });
};
const isValid = isBuy
? (!!amount && !!feeRate && !!date && calcShare !== null)
: (!!share && !!date);
const handleSetShareFraction = (fraction) => {
if (availableShare > 0) {
setShare((availableShare * fraction).toFixed(2));
}
};
const [revokeTrade, setRevokeTrade] = useState(null);
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={isBuy ? "加仓" : "减仓"}
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<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: '420px' }}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '20px' }}>{isBuy ? '📥' : '📤'}</span>
<span>{showPendingList ? '待交易队列' : (showConfirm ? (isBuy ? '买入确认' : '卖出确认') : (isBuy ? '加仓' : '减仓'))}</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
{!showPendingList && !showConfirm && currentPendingTrades.length > 0 && (
<div
style={{
marginBottom: 16,
background: 'rgba(230, 162, 60, 0.1)',
border: '1px solid rgba(230, 162, 60, 0.2)',
borderRadius: 8,
padding: '8px 12px',
fontSize: '12px',
color: '#e6a23c',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer'
}}
onClick={() => setShowPendingList(true)}
>
<span> 当前有 {currentPendingTrades.length} 笔待处理交易</span>
<span style={{ textDecoration: 'underline' }}>查看详情 &gt;</span>
</div>
)}
{showPendingList ? (
<div className="pending-list" style={{ maxHeight: '300px', overflowY: 'auto' }}>
<div className="pending-list-header" style={{ position: 'sticky', top: 0, zIndex: 1, background: 'rgba(15,23,42,0.95)', backdropFilter: 'blur(6px)', paddingBottom: 8, marginBottom: 8, borderBottom: '1px solid var(--border)' }}>
<button
className="button secondary"
onClick={() => setShowPendingList(false)}
style={{ padding: '4px 8px', fontSize: '12px' }}
>
&lt; 返回
</button>
</div>
<div className="pending-list-items" style={{ paddingTop: 0 }}>
{currentPendingTrades.map((trade, idx) => (
<div key={trade.id || idx} style={{ background: 'rgba(255,255,255,0.05)', padding: 12, borderRadius: 8, marginBottom: 8 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: trade.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
{trade.type === 'buy' ? '买入' : '卖出'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>{trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
<span className="muted">份额/金额</span>
<span>{trade.share ? `${trade.share}` : `¥${trade.amount}`}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 4 }}>
<span className="muted">状态</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
<button
className="button secondary"
onClick={() => setRevokeTrade(trade)}
style={{
padding: '2px 8px',
fontSize: '10px',
height: 'auto',
background: 'rgba(255,255,255,0.1)',
color: 'var(--text)'
}}
>
撤销
</button>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<>
{!showConfirm && (
<div style={{ marginBottom: 16 }}>
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
</div>
)}
{showConfirm ? (
isBuy ? (
<div style={{ fontSize: '14px' }}>
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">基金名称</span>
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">买入金额</span>
<span>¥{Number(amount).toFixed(2)}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">买入费率</span>
<span>{Number(feeRate).toFixed(2)}%</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">参考净值</span>
<span>{loadingPrice ? '查询中...' : (price ? `¥${Number(price).toFixed(4)}` : '待查询 (加入队列)')}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">预估份额</span>
<span>{calcShare === '待确认' ? '待确认' : `${Number(calcShare).toFixed(2)}`}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">买入日期</span>
<span>{date}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
<span className="muted">交易时段</span>
<span>{isAfter3pm ? '15:00后' : '15:00前'}</span>
</div>
<div className="muted" style={{ fontSize: '12px', textAlign: 'right', marginTop: 4 }}>
{loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`}
</div>
</div>
{holding && calcShare !== '待确认' && (
<div style={{ marginBottom: 20 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
<div className="row" style={{ gap: 12 }}>
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
<div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
<span style={{ margin: '0 4px' }}></span>
<span style={{ fontWeight: 600 }}>{(holding.share + Number(calcShare)).toFixed(2)}</span>
</div>
</div>
{price ? (
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div>
<div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>¥{(holding.share * Number(price)).toFixed(2)}</span>
<span style={{ margin: '0 4px' }}></span>
<span style={{ fontWeight: 600 }}>¥{((holding.share + Number(calcShare)) * Number(price)).toFixed(2)}</span>
</div>
</div>
) : null}
</div>
</div>
)}
<div className="row" style={{ gap: 12 }}>
<button
type="button"
className="button secondary"
onClick={() => setShowConfirm(false)}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
返回修改
</button>
<button
type="button"
className="button"
onClick={handleFinalConfirm}
disabled={loadingPrice}
style={{ flex: 1, background: 'var(--primary)', opacity: loadingPrice ? 0.6 : 1, color: '#05263b' }}
>
{loadingPrice ? '请稍候' : (price ? '确认买入' : '加入待处理队列')}
</button>
</div>
</div>
) : (
<div style={{ fontSize: '14px' }}>
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 16, marginBottom: 20 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">基金名称</span>
<span style={{ fontWeight: 600 }}>{fund?.name}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">卖出份额</span>
<span>{sellShare.toFixed(2)} </span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">预估卖出单价</span>
<span>{loadingPrice ? '查询中...' : (price ? `¥${sellPrice.toFixed(4)}` : '待查询 (加入队列)')}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">卖出费率/费用</span>
<span>{feeMode === 'rate' ? `${feeValue}%` : `¥${feeValue}`}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">预估手续费</span>
<span>{price ? `¥${sellFee.toFixed(2)}` : '待计算'}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted">卖出日期</span>
<span>{date}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 8, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 8 }}>
<span className="muted">预计回款</span>
<span style={{ color: 'var(--danger)', fontWeight: 700 }}>{loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')}</span>
</div>
<div className="muted" style={{ fontSize: '12px', textAlign: 'right', marginTop: 4 }}>
{loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`}
</div>
</div>
{holding && (
<div style={{ marginBottom: 20 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '12px' }}>持仓变化预览</div>
<div className="row" style={{ gap: 12 }}>
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有份额</div>
<div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>{holding.share.toFixed(2)}</span>
<span style={{ margin: '0 4px' }}></span>
<span style={{ fontWeight: 600 }}>{(holding.share - sellShare).toFixed(2)}</span>
</div>
</div>
{price ? (
<div style={{ flex: 1, background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 8 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>持有市值 ()</div>
<div style={{ fontSize: '12px' }}>
<span style={{ opacity: 0.7 }}>¥{(holding.share * sellPrice).toFixed(2)}</span>
<span style={{ margin: '0 4px' }}></span>
<span style={{ fontWeight: 600 }}>¥{((holding.share - sellShare) * sellPrice).toFixed(2)}</span>
</div>
</div>
) : null}
</div>
</div>
)}
<div className="row" style={{ gap: 12 }}>
<button
type="button"
className="button secondary"
onClick={() => setShowConfirm(false)}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
返回修改
</button>
<button
type="button"
className="button"
onClick={handleFinalConfirm}
disabled={loadingPrice}
style={{ flex: 1, background: 'var(--danger)', opacity: loadingPrice ? 0.6 : 1 }}
>
{loadingPrice ? '请稍候' : (price ? '确认卖出' : '加入待处理队列')}
</button>
</div>
</div>
)
) : (
<form onSubmit={handleSubmit}>
{isBuy ? (
<>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !amount ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={amount}
onChange={setAmount}
step={100}
min={0}
placeholder="请输入加仓金额"
/>
</div>
</div>
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
买入费率 (%) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !feeRate ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={feeRate}
onChange={setFeeRate}
step={0.01}
min={0}
placeholder="0.12"
/>
</div>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
加仓日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段
</label>
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
<button
type="button"
onClick={() => setIsAfter3pm(false)}
style={{
flex: 1,
border: 'none',
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
>
15:00
</button>
<button
type="button"
onClick={() => setIsAfter3pm(true)}
style={{
flex: 1,
border: 'none',
background: isAfter3pm ? 'var(--primary)' : 'transparent',
color: isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {Number(price).toFixed(4)}</span>
</div>
)}
</div>
</>
) : (
<>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
卖出份额 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ border: !share ? '1px solid var(--danger)' : '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={share}
onChange={setShare}
step={1}
min={0}
placeholder={holding ? `最多可卖 ${availableShare.toFixed(2)}` : "请输入卖出份额"}
/>
</div>
{holding && holding.share > 0 && (
<div className="row" style={{ gap: 8, marginTop: 8 }}>
{[
{ label: '1/4', value: 0.25 },
{ label: '1/3', value: 1 / 3 },
{ label: '1/2', value: 0.5 },
{ label: '全部', value: 1 }
].map((opt) => (
<button
key={opt.label}
type="button"
onClick={() => handleSetShareFraction(opt.value)}
style={{
flex: 1,
padding: '4px 8px',
fontSize: '12px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '4px',
color: 'var(--text)',
cursor: 'pointer'
}}
>
{opt.label}
</button>
))}
</div>
)}
{holding && (
<div className="muted" style={{ fontSize: '12px', marginTop: 6 }}>
当前持仓: {holding.share.toFixed(2)} {pendingSellShare > 0 && <span style={{ color: '#e6a23c', marginLeft: 8 }}>冻结: {pendingSellShare.toFixed(2)} </span>}
</div>
)}
</div>
<div className="row" style={{ gap: 12, marginBottom: 16 }}>
<div className="form-group" style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<label className="muted" style={{ fontSize: '14px' }}>
{feeMode === 'rate' ? '卖出费率 (%)' : '卖出费用 (¥)'}
</label>
<button
type="button"
onClick={() => {
setFeeMode(m => m === 'rate' ? 'amount' : 'rate');
setFeeValue('0');
}}
style={{
background: 'none',
border: 'none',
color: 'var(--primary)',
fontSize: '12px',
cursor: 'pointer',
padding: 0
}}
>
切换为{feeMode === 'rate' ? '金额' : '费率'}
</button>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 12 }}>
<NumericInput
value={feeValue}
onChange={setFeeValue}
step={feeMode === 'rate' ? 0.01 : 1}
min={0}
placeholder={feeMode === 'rate' ? "0.00" : "0.00"}
/>
</div>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
卖出日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
交易时段
</label>
<div className="row" style={{ gap: 8, background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '4px' }}>
<button
type="button"
onClick={() => setIsAfter3pm(false)}
style={{
flex: 1,
border: 'none',
background: !isAfter3pm ? 'var(--primary)' : 'transparent',
color: !isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
>
15:00
</button>
<button
type="button"
onClick={() => setIsAfter3pm(true)}
style={{
flex: 1,
border: 'none',
background: isAfter3pm ? 'var(--primary)' : 'transparent',
color: isAfter3pm ? '#05263b' : 'var(--muted)',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
padding: '6px 8px'
}}
>
15:00
</button>
</div>
</div>
<div style={{ marginBottom: 12, fontSize: '12px' }}>
{loadingPrice ? (
<span className="muted">正在查询净值数据...</span>
) : price === 0 ? null : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className="muted">参考净值: {price.toFixed(4)}</span>
</div>
)}
</div>
</>
)}
<div className="row" style={{ gap: 12, marginTop: 12 }}>
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button
type="submit"
className="button"
disabled={!isValid || loadingPrice}
style={{ flex: 1, opacity: (!isValid || loadingPrice) ? 0.6 : 1 }}
>
确定
</button>
</div>
</form>
)}
</>
)}
</motion.div>
<AnimatePresence>
{revokeTrade && (
<ConfirmModal
key="revoke-confirm"
title="撤销交易"
message={`确定要撤销这笔 ${revokeTrade.share ? `${revokeTrade.share}` : `¥${revokeTrade.amount}`}${revokeTrade.type === 'buy' ? '买入' : '卖出'}申请吗?`}
onConfirm={() => {
onDeletePending?.(revokeTrade.id);
setRevokeTrade(null);
}}
onCancel={() => setRevokeTrade(null)}
confirmText="确认撤销"
/>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { motion } from 'framer-motion';
import { UpdateIcon } from './Icons';
export default function UpdatePromptModal({ updateContent, onClose, onRefresh }) {
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="更新提示"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 10002 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 12 }}>
<UpdateIcon width="20" height="20" style={{ color: 'var(--success)' }} />
<span>更新提示</span>
</div>
<div style={{ marginBottom: 24 }}>
<p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
检测到新版本是否刷新浏览器以更新
<br />
更新内容如下
</p>
{updateContent && (
<div style={{
background: 'rgba(0,0,0,0.2)',
padding: '12px',
borderRadius: '8px',
fontSize: '13px',
lineHeight: '1.5',
maxHeight: '200px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
border: '1px solid rgba(255,255,255,0.1)'
}}>
{updateContent}
</div>
)}
</div>
<div className="row" style={{ gap: 12 }}>
<button
className="button secondary"
onClick={onClose}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
取消
</button>
<button
className="button"
onClick={onRefresh}
style={{ flex: 1, background: 'var(--success)', color: '#fff', border: 'none' }}
>
刷新浏览器
</button>
</div>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { motion } from 'framer-motion';
import { CloseIcon } from './Icons';
import weChatGroupImg from '../assets/weChatGroup.jpg';
export default 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>
);
}

File diff suppressed because it is too large Load Diff