feat: 组件拆分
This commit is contained in:
5
.eslintrc.json
Normal file
5
.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals"
|
||||
]
|
||||
}
|
||||
90
app/components/AddFundToGroupModal.jsx
Normal file
90
app/components/AddFundToGroupModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/components/AddResultModal.jsx
Normal file
53
app/components/AddResultModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/components/CloudConfigModal.jsx
Normal file
54
app/components/CloudConfigModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/ConfirmModal.jsx
Normal file
43
app/components/ConfirmModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/DonateModal.jsx
Normal file
37
app/components/DonateModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
app/components/FeedbackModal.jsx
Normal file
148
app/components/FeedbackModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
app/components/GroupManageModal.jsx
Normal file
195
app/components/GroupManageModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/components/GroupModal.jsx
Normal file
61
app/components/GroupModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
app/components/HoldingActionModal.jsx
Normal file
68
app/components/HoldingActionModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
app/components/HoldingEditModal.jsx
Normal file
241
app/components/HoldingEditModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
app/components/LoginModal.jsx
Normal file
97
app/components/LoginModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/components/ScanImportConfirmModal.jsx
Normal file
86
app/components/ScanImportConfirmModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/components/ScanImportProgressModal.jsx
Normal file
48
app/components/ScanImportProgressModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
app/components/ScanPickModal.jsx
Normal file
40
app/components/ScanPickModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/components/ScanProgressModal.jsx
Normal file
54
app/components/ScanProgressModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
app/components/SettingsModal.jsx
Normal file
84
app/components/SettingsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
app/components/SuccessModal.jsx
Normal file
35
app/components/SuccessModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
697
app/components/TradeModal.jsx
Normal file
697
app/components/TradeModal.jsx
Normal 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' }}>查看详情 ></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' }}
|
||||
>
|
||||
< 返回
|
||||
</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>
|
||||
);
|
||||
}
|
||||
72
app/components/UpdatePromptModal.jsx
Normal file
72
app/components/UpdatePromptModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/components/WeChatModal.jsx
Normal file
45
app/components/WeChatModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2163
app/page.jsx
2163
app/page.jsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user