3114 lines
136 KiB
HTML
3114 lines
136 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>家庭记账 · 液态玻璃设计</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
|
||
background: #FFFFFF;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 20px;
|
||
position: relative;
|
||
overflow-x: hidden;
|
||
}
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
top: -200px;
|
||
right: -150px;
|
||
width: 500px;
|
||
height: 500px;
|
||
background: radial-gradient(circle, rgba(255,107,53,0.06) 0%, transparent 70%);
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
body::after {
|
||
content: '';
|
||
position: fixed;
|
||
bottom: -200px;
|
||
left: -150px;
|
||
width: 400px;
|
||
height: 400px;
|
||
background: radial-gradient(circle, rgba(255,107,53,0.04) 0%, transparent 70%);
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.page-title {
|
||
color: #1C1C1E;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.5px;
|
||
margin-bottom: 6px;
|
||
text-align: center;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.page-subtitle {
|
||
color: #8E8E93;
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
margin-bottom: 32px;
|
||
text-align: center;
|
||
letter-spacing: 0.3px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.page-subtitle span {
|
||
color: #FF6B35;
|
||
font-weight: 600;
|
||
}
|
||
.caption-badge {
|
||
margin-top: 24px;
|
||
padding: 6px 18px;
|
||
background: rgba(255,107,53,0.06);
|
||
border: 1px solid rgba(255,107,53,0.12);
|
||
border-radius: 20px;
|
||
color: #FF6B35;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
letter-spacing: 1px;
|
||
backdrop-filter: blur(10px);
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
@keyframes liquidMorph {
|
||
0% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; transform: rotate(0deg) scale(1); }
|
||
25% { border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%; }
|
||
50% { border-radius: 40% 60% 50% 50% / 60% 40% 60% 40%; transform: rotate(180deg) scale(1.05); }
|
||
75% { border-radius: 70% 30% 40% 60% / 30% 70% 30% 70%; }
|
||
100% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; transform: rotate(360deg) scale(1); }
|
||
}
|
||
@keyframes liquidMorph2 {
|
||
0% { border-radius: 50% 50% 60% 40% / 40% 60% 40% 60%; transform: rotate(0deg) scale(1); }
|
||
33% { border-radius: 40% 60% 30% 70% / 60% 30% 70% 30%; }
|
||
66% { border-radius: 60% 40% 70% 30% / 40% 60% 30% 70%; transform: rotate(240deg) scale(1.08); }
|
||
100% { border-radius: 50% 50% 60% 40% / 40% 60% 40% 60%; transform: rotate(360deg) scale(1); }
|
||
}
|
||
@keyframes float {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-6px); }
|
||
}
|
||
@keyframes pulseOrange {
|
||
0%, 100% { box-shadow: 0 4px 20px rgba(255,107,53,0.2), 0 0 40px rgba(255,107,53,0.05); }
|
||
50% { box-shadow: 0 8px 40px rgba(255,107,53,0.4), 0 0 60px rgba(255,107,53,0.1); }
|
||
}
|
||
@keyframes fadeSlideUp {
|
||
from { opacity: 0; transform: translateY(12px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
@keyframes barGrow {
|
||
from { width: 0 !important; }
|
||
}
|
||
@keyframes shimmerOrange {
|
||
0% { transform: translateX(-100%) skewX(-15deg); }
|
||
100% { transform: translateX(300%) skewX(-15deg); }
|
||
}
|
||
.phone-wrapper {
|
||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.phone-wrapper:hover {
|
||
transform: scale(1.008);
|
||
}
|
||
.blob-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.blob-1 {
|
||
position: absolute;
|
||
top: -60px;
|
||
right: -50px;
|
||
width: 260px;
|
||
height: 240px;
|
||
background: linear-gradient(135deg, #FF6B35 0%, #FF9F5B 100%);
|
||
opacity: 0.1;
|
||
filter: blur(30px);
|
||
animation: liquidMorph 12s ease-in-out infinite;
|
||
}
|
||
.blob-2 {
|
||
position: absolute;
|
||
bottom: -40px;
|
||
left: -70px;
|
||
width: 200px;
|
||
height: 180px;
|
||
background: linear-gradient(135deg, #FF9F5B 0%, #FF6B35 100%);
|
||
opacity: 0.08;
|
||
filter: blur(35px);
|
||
animation: liquidMorph2 15s ease-in-out infinite;
|
||
}
|
||
.blob-3 {
|
||
position: absolute;
|
||
top: 30%;
|
||
left: 50%;
|
||
width: 120px;
|
||
height: 120px;
|
||
background: linear-gradient(135deg, #FF6B35, #FFB088);
|
||
opacity: 0.06;
|
||
filter: blur(25px);
|
||
border-radius: 50%;
|
||
animation: float 6s ease-in-out infinite;
|
||
}
|
||
@keyframes statusBarFadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
@keyframes cardHover {
|
||
0%, 100% { transform: translateY(0); box-shadow: 0 8px 32px rgba(255,107,53,0.08); }
|
||
50% { transform: translateY(-2px); box-shadow: 0 12px 40px rgba(255,107,53,0.12); }
|
||
}
|
||
@keyframes dockFloat {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-2px); }
|
||
}
|
||
|
||
@keyframes liquidPulse {
|
||
0%, 100% {
|
||
box-shadow: 0 0 0 1px rgba(255,255,255,0.3) inset, 0 4px 16px rgba(255,107,53,0.35);
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 2px rgba(255,255,255,0.4) inset, 0 6px 24px rgba(255,107,53,0.5);
|
||
transform: scale(1.03);
|
||
}
|
||
}
|
||
@keyframes slideUp {
|
||
from { transform: translateY(100%); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
@keyframes scanLine {
|
||
0% { top: 10%; }
|
||
50% { top: 85%; }
|
||
100% { top: 10%; }
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
</style>
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<div class="page-title">家庭记账 · 液态玻璃设计</div>
|
||
<div class="page-subtitle">简约白 · <span>时尚橙</span> · iOS 26 Liquid Glass</div>
|
||
<div id="root"></div>
|
||
<div class="caption-badge">iOS 26 · Liquid Glass Concept</div>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
||
|
||
/* ===== IosFrame (with overlay slots) ===== */
|
||
const iosFrameStyles = {
|
||
wrapper: {
|
||
display: 'inline-block',
|
||
padding: 12,
|
||
background: '#000',
|
||
borderRadius: 60,
|
||
boxShadow: '0 0 0 2px #1f2937, 0 20px 60px rgba(0,0,0,0.3)',
|
||
position: 'relative',
|
||
},
|
||
screen: {
|
||
position: 'relative',
|
||
borderRadius: 48,
|
||
overflow: 'hidden',
|
||
background: '#F2F2F7',
|
||
},
|
||
statusBar: {
|
||
position: 'absolute',
|
||
top: 0, left: 0, right: 0,
|
||
height: 54,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '0 32px 0 32px',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
fontFamily: '-apple-system, "SF Pro Text", sans-serif',
|
||
zIndex: 20,
|
||
pointerEvents: 'none',
|
||
},
|
||
dynamicIsland: {
|
||
position: 'absolute',
|
||
top: 12, left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: 124, height: 36,
|
||
background: '#000',
|
||
borderRadius: 999,
|
||
zIndex: 30,
|
||
},
|
||
statusIcons: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
signalIcon: {
|
||
display: 'flex',
|
||
alignItems: 'flex-end',
|
||
gap: 2,
|
||
height: 12,
|
||
},
|
||
signalBar: {
|
||
width: 3,
|
||
background: 'currentColor',
|
||
borderRadius: 1,
|
||
},
|
||
wifiIcon: {
|
||
width: 16,
|
||
height: 12,
|
||
position: 'relative',
|
||
},
|
||
batteryIcon: {
|
||
width: 26, height: 12,
|
||
border: '1.5px solid currentColor',
|
||
borderRadius: 3,
|
||
padding: 1,
|
||
position: 'relative',
|
||
opacity: 0.8,
|
||
},
|
||
batteryCap: {
|
||
position: 'absolute',
|
||
top: 3, right: -3,
|
||
width: 2, height: 6,
|
||
background: 'currentColor',
|
||
borderRadius: '0 1px 1px 0',
|
||
},
|
||
content: {
|
||
position: 'absolute',
|
||
top: 54, left: 0, right: 0,
|
||
bottom: 0,
|
||
overflow: 'visible',
|
||
},
|
||
homeIndicator: {
|
||
position: 'absolute',
|
||
bottom: 10,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: 140, height: 5,
|
||
background: 'rgba(0,0,0,0.3)',
|
||
borderRadius: 999,
|
||
zIndex: 10,
|
||
},
|
||
homeIndicatorDark: {
|
||
background: 'rgba(255,255,255,0.5)',
|
||
},
|
||
};
|
||
|
||
function IosFrame({
|
||
children, width = 370, height = 780,
|
||
time = '10:28', battery = 78,
|
||
darkMode = false,
|
||
showStatusBar = true, showDynamicIsland = true, showHomeIndicator = true,
|
||
bottomOverlay, fabOverlay,
|
||
}) {
|
||
const textColor = darkMode ? '#fff' : '#000';
|
||
const homeIndicatorStyle = darkMode
|
||
? { ...iosFrameStyles.homeIndicator, ...iosFrameStyles.homeIndicatorDark }
|
||
: iosFrameStyles.homeIndicator;
|
||
|
||
return (
|
||
<div style={iosFrameStyles.wrapper}>
|
||
<div style={{
|
||
...iosFrameStyles.screen,
|
||
width, height,
|
||
background: darkMode ? '#000' : '#F2F2F7',
|
||
}}>
|
||
{showStatusBar && (
|
||
<div style={{ ...iosFrameStyles.statusBar, color: textColor }}>
|
||
<span>{time}</span>
|
||
<div style={iosFrameStyles.statusIcons}>
|
||
<div style={iosFrameStyles.signalIcon}>
|
||
<div style={{ ...iosFrameStyles.signalBar, height: 4 }} />
|
||
<div style={{ ...iosFrameStyles.signalBar, height: 6 }} />
|
||
<div style={{ ...iosFrameStyles.signalBar, height: 9 }} />
|
||
<div style={{ ...iosFrameStyles.signalBar, height: 11 }} />
|
||
</div>
|
||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" style={{ color: textColor }}>
|
||
<path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="currentColor" />
|
||
<path d="M3 7.5a7 7 0 0110 0" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" />
|
||
<path d="M1 4.5a11 11 0 0114 0" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
|
||
</svg>
|
||
<div style={iosFrameStyles.batteryIcon}>
|
||
<div style={{
|
||
width: `${battery}%`, height: '100%',
|
||
background: 'currentColor', borderRadius: 1, opacity: 0.9,
|
||
}} />
|
||
<div style={iosFrameStyles.batteryCap} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showDynamicIsland && <div style={iosFrameStyles.dynamicIsland} />}
|
||
<div style={{
|
||
...iosFrameStyles.content,
|
||
bottom: showHomeIndicator ? 34 : 0,
|
||
}}>
|
||
{children}
|
||
</div>
|
||
{bottomOverlay && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: showHomeIndicator ? 34 : 0,
|
||
left: 0, right: 0,
|
||
zIndex: 50,
|
||
}}>
|
||
{bottomOverlay}
|
||
</div>
|
||
)}
|
||
{fabOverlay && (
|
||
<div style={{
|
||
position: 'absolute', zIndex: 60, pointerEvents: 'none',
|
||
}}>
|
||
{fabOverlay}
|
||
</div>
|
||
)}
|
||
{showHomeIndicator && <div style={homeIndicatorStyle} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Data ===== */
|
||
const CATEGORIES_EXPENSE = [
|
||
{ emoji: '\uD83C\uDF5C', name: '餐饮' },
|
||
{ emoji: '\uD83D\uDE95', name: '交通' },
|
||
{ emoji: '\uD83D\uDED2', name: '购物' },
|
||
{ emoji: '\uD83C\uDFE0', name: '居住' },
|
||
{ emoji: '\uD83D\uDC8A', name: '医疗' },
|
||
{ emoji: '\uD83C\uDFAE', name: '娱乐' },
|
||
{ emoji: '\uD83D\uDCDA', name: '教育' },
|
||
{ emoji: '\uD83D\uDCE6', name: '其他' },
|
||
];
|
||
const CATEGORIES_INCOME = [
|
||
{ emoji: '\uD83D\uDCB0', name: '工资' },
|
||
{ emoji: '\uD83D\uDCC8', name: '理财' },
|
||
{ emoji: '\uD83C\uDF93', name: '兼职' },
|
||
{ emoji: '\uD83E\uDD11', name: '红包' },
|
||
{ emoji: '\uD83C\uDFC6', name: '奖金' },
|
||
{ emoji: '\uD83D\uDCB2', name: '其他' },
|
||
];
|
||
|
||
const fmtAbs = (n) => '¥' + Math.abs(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
|
||
/* ===== Sub-Components ===== */
|
||
|
||
function OrangeHeader({ user }) {
|
||
var _bState = React.useState([]);
|
||
var bills = _bState[0];
|
||
var setBills = _bState[1];
|
||
|
||
React.useEffect(function() {
|
||
if (!user) return;
|
||
var result = window.__authApi.getBills(user.id);
|
||
if (result.success) setBills(result.data.bills);
|
||
}, [user]);
|
||
|
||
var now = new Date();
|
||
var thisMonth = now.getMonth() + 1;
|
||
var thisYear = now.getFullYear();
|
||
var monthBills = bills.filter(function(b) {
|
||
if (!b.date) return false;
|
||
var p = b.date.split('-');
|
||
return parseInt(p[0]) === thisYear && parseInt(p[1]) === thisMonth;
|
||
});
|
||
var totalIncome = monthBills.filter(function(b) { return b.type === 'income'; }).reduce(function(s, b) { return s + Number(b.amount); }, 0);
|
||
var totalExpense = monthBills.filter(function(b) { return b.type === 'expense'; }).reduce(function(s, b) { return s + Number(b.amount); }, 0);
|
||
var budgetRemaining = Math.max(totalIncome - totalExpense, 0);
|
||
var daysInMonth = new Date(thisYear, thisMonth, 0).getDate();
|
||
var daysLeft = daysInMonth - now.getDate();
|
||
|
||
return (
|
||
<div style={{
|
||
background: '#FFFFFF',
|
||
padding: '20px 16px 16px',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div className="blob-layer">
|
||
<div className="blob-1" />
|
||
<div className="blob-2" />
|
||
<div className="blob-3" />
|
||
</div>
|
||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{
|
||
width: 44, height: 44, borderRadius: '50%',
|
||
background: 'rgba(255,255,255,0.7)',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
border: '2px solid rgba(255,107,53,0.25)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 18, fontWeight: 700, color: '#FF6B35',
|
||
boxShadow: '0 4px 16px rgba(255,107,53,0.1), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
}}>
|
||
{user ? user.nickname.charAt(0) : '?'}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 20, fontWeight: 700, color: '#1C1C1E', letterSpacing: '-0.3px' }}>
|
||
{user ? '你好,' + user.nickname : '你好'}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#8E8E93', marginTop: 2, fontWeight: 500 }}>
|
||
{new Date().getMonth() + 1}月{new Date().getDate()}日 · {['日','一','二','三','四','五','六'][new Date().getDay()]}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8,
|
||
marginTop: 16,
|
||
}}>
|
||
{[
|
||
{ label: '本月收入', value: '¥' + totalIncome.toLocaleString('zh-CN', {minimumFractionDigits:2}), color: '#34C759' },
|
||
{ label: '本月支出', value: '¥' + totalExpense.toLocaleString('zh-CN', {minimumFractionDigits:2}), color: '#FF3B30' },
|
||
{ label: '预算剩余', value: '¥' + budgetRemaining.toLocaleString('zh-CN', {minimumFractionDigits:2}), color: '#FF6B35' },
|
||
].map(function(item, i) { return (
|
||
<div key={i} style={{
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(30px)',
|
||
WebkitBackdropFilter: 'blur(30px)',
|
||
borderRadius: 16,
|
||
padding: '12px 8px',
|
||
textAlign: 'center',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.08), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
}}>
|
||
<div style={{ fontSize: 11, color: '#8E8E93', fontWeight: 500, marginBottom: 4 }}>
|
||
{item.label}
|
||
</div>
|
||
<div style={{
|
||
fontSize: item.value.length > 10 ? 13 : 15,
|
||
fontWeight: 700,
|
||
color: item.color,
|
||
}}>
|
||
{item.value}
|
||
</div>
|
||
</div>
|
||
);})}
|
||
</div>
|
||
<div style={{
|
||
marginTop: 12, textAlign: 'center',
|
||
fontSize: 11, color: '#8E8E93',
|
||
fontWeight: 500, letterSpacing: '0.3px',
|
||
}}>
|
||
本月还剩 {daysLeft} 天
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TabSwitcher({ activeTab, onSwitch, animating }) {
|
||
return (
|
||
<div style={{ padding: '12px 16px 8px', position: 'relative', zIndex: 5 }}>
|
||
<div style={{
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(30px)',
|
||
WebkitBackdropFilter: 'blur(30px)',
|
||
borderRadius: 14,
|
||
padding: 3,
|
||
display: 'flex',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 4px 20px rgba(255,107,53,0.05)',
|
||
}}>
|
||
{['个人账本', '家庭账本'].map(tab => {
|
||
const key = tab === '个人账本' ? 'personal' : 'family';
|
||
const isActive = activeTab === key;
|
||
return (
|
||
<div
|
||
key={key}
|
||
onClick={() => !animating && onSwitch(key)}
|
||
style={{
|
||
flex: 1,
|
||
padding: '9px 12px',
|
||
borderRadius: 11,
|
||
textAlign: 'center',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
transition: 'all 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
|
||
background: isActive
|
||
? 'linear-gradient(135deg, #FF6B35 0%, #FF9F5B 100%)'
|
||
: 'transparent',
|
||
color: isActive ? '#fff' : '#8E8E93',
|
||
boxShadow: isActive ? '0 2px 12px rgba(255,107,53,0.25)' : 'none',
|
||
userSelect: 'none',
|
||
}}
|
||
>{tab}</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TransactionItem({ item, index }) {
|
||
const isIncome = item.amount > 0;
|
||
const iconBg = isIncome ? 'rgba(52,199,89,0.08)'
|
||
: item.category === '餐饮' ? 'rgba(255,107,53,0.08)'
|
||
: item.category === '交通' ? 'rgba(255,159,91,0.08)'
|
||
: item.category === '购物' ? 'rgba(255,107,53,0.06)'
|
||
: item.category === '医疗' ? 'rgba(255,59,48,0.06)'
|
||
: item.category === '工资' ? 'rgba(52,199,89,0.08)'
|
||
: 'rgba(142,142,147,0.08)';
|
||
|
||
return (
|
||
<div style={{
|
||
animation: `fadeSlideUp 0.35s ease ${index * 0.05}s both`,
|
||
}}>
|
||
<div style={{
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(30px)',
|
||
WebkitBackdropFilter: 'blur(30px)',
|
||
borderRadius: 16,
|
||
padding: '14px 14px',
|
||
margin: '0 16px 6px',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
}}>
|
||
<div style={{
|
||
width: 40, height: 40, borderRadius: '50%',
|
||
background: iconBg,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 18,
|
||
flexShrink: 0,
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
|
||
}}>
|
||
{item.emoji}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: '#1C1C1E' }}>{item.name}</div>
|
||
<div style={{ fontSize: 11, color: '#8E8E93', marginTop: 2, fontWeight: 500 }}>
|
||
{item.category} · {item.time}
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 16,
|
||
fontWeight: 700,
|
||
color: isIncome ? '#34C759' : '#FF3B30',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{item.amount > 0 ? '+' : ''}{fmtAbs(item.amount)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PersonalContent({ user }) {
|
||
var _billsState = React.useState([]);
|
||
var bills = _billsState[0];
|
||
var setBills = _billsState[1];
|
||
|
||
var loadBills = React.useCallback(function() {
|
||
if (!user) return;
|
||
var result = window.__authApi.getBills(user.id);
|
||
if (result.success) {
|
||
setBills(result.data.bills);
|
||
}
|
||
}, [user]);
|
||
|
||
React.useEffect(function() {
|
||
loadBills();
|
||
}, [loadBills]);
|
||
|
||
React.useEffect(function() {
|
||
var handler = function() { loadBills(); };
|
||
window.addEventListener('refreshBills', handler);
|
||
return function() { window.removeEventListener('refreshBills', handler); };
|
||
}, [loadBills]);
|
||
|
||
if (!user) {
|
||
return React.createElement('div', { style: { padding: '40px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 14 }}, '请先登录以查看账单');
|
||
}
|
||
|
||
var todayStr = new Date().getFullYear() + '年' + (new Date().getMonth() + 1) + '月' + new Date().getDate() + '日';
|
||
var weekDays = ['日','一','二','三','四','五','六'];
|
||
var weekDay = weekDays[new Date().getDay()];
|
||
|
||
return React.createElement('div', null,
|
||
React.createElement('div', { style: { padding: '8px 16px 6px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }},
|
||
React.createElement('div', { style: { fontSize: 15, fontWeight: 700, color: '#1C1C1E' }}, '今日 · ' + new Date().getMonth() + '月' + new Date().getDate() + '日'),
|
||
React.createElement('div', { style: { fontSize: 12, color: '#FF6B35', fontWeight: 600, cursor: 'pointer', transition: 'color 0.15s ease' }}, '查看全部 →')
|
||
),
|
||
bills.length === 0
|
||
? React.createElement('div', { style: { padding: '40px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 13 }}, '暂无账单记录')
|
||
: bills.map(function(item, i) {
|
||
return React.createElement(TransactionItem, {
|
||
key: item.id,
|
||
item: {
|
||
emoji: item.emoji,
|
||
name: item.note || item.category,
|
||
category: item.category,
|
||
time: item.time,
|
||
amount: item.type === 'income' ? item.amount : -item.amount,
|
||
},
|
||
index: i,
|
||
});
|
||
}),
|
||
React.createElement('div', { style: { height: 20 }})
|
||
);
|
||
}
|
||
|
||
function FamilyContent({ user }) {
|
||
var _billsState = React.useState([]);
|
||
var bills = _billsState[0];
|
||
var setBills = _billsState[1];
|
||
var _familyState = React.useState(null);
|
||
var family = _familyState[0];
|
||
var setFamily = _familyState[1];
|
||
var _membersState = React.useState([]);
|
||
var members = _membersState[0];
|
||
var setMembers = _membersState[1];
|
||
|
||
var loadData = React.useCallback(function() {
|
||
if (!user) return;
|
||
// Get active family
|
||
var familiesResult = window.__authApi.getUserFamilies(user.id);
|
||
if (!familiesResult.success || familiesResult.data.families.length === 0) {
|
||
setBills([]);
|
||
setFamily(null);
|
||
setMembers([]);
|
||
return;
|
||
}
|
||
var activeFamily = familiesResult.data.families.find(function(f) { return f.id === user.activeFamilyId; }) || familiesResult.data.families[0];
|
||
setFamily(activeFamily);
|
||
setMembers(activeFamily.members || []);
|
||
// Get family bills
|
||
var billsResult = window.__authApi.getFamilyBills(activeFamily.id);
|
||
if (billsResult.success) {
|
||
setBills(billsResult.data.bills);
|
||
}
|
||
}, [user]);
|
||
|
||
React.useEffect(function() {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
React.useEffect(function() {
|
||
var handler = function() { loadData(); };
|
||
window.addEventListener('refreshBills', handler);
|
||
return function() { window.removeEventListener('refreshBills', handler); };
|
||
}, [loadData]);
|
||
|
||
if (!user) {
|
||
return React.createElement('div', { style: { padding: '40px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 14 }}, '请先登录以查看家庭账单');
|
||
}
|
||
|
||
if (!family) {
|
||
return React.createElement('div', { style: { padding: '40px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 14 }}, '暂未加入任何家庭');
|
||
}
|
||
|
||
// Compute member rankings from bills
|
||
var memberTotals = {};
|
||
members.forEach(function(m) {
|
||
memberTotals[m.userId] = { userId: m.userId, nickname: m.nickname, total: 0, count: 0 };
|
||
});
|
||
var overallTotal = 0;
|
||
bills.forEach(function(b) {
|
||
overallTotal += Number(b.amount) || 0;
|
||
if (memberTotals[b.userId]) {
|
||
memberTotals[b.userId].total += Number(b.amount) || 0;
|
||
memberTotals[b.userId].count += 1;
|
||
}
|
||
});
|
||
var rankedMembers = Object.keys(memberTotals).map(function(k) { return memberTotals[k]; }).sort(function(a, b) { return b.total - a.total; });
|
||
var rankEmojis = ['🥇','🥈','🥉'];
|
||
var maxTotal = rankedMembers.length > 0 ? rankedMembers[0].total : 1;
|
||
|
||
// Recent family bills (last 5)
|
||
var recentBills = bills.slice(0, 5);
|
||
|
||
var glassCardStyle = {
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(30px)',
|
||
WebkitBackdropFilter: 'blur(30px)',
|
||
borderRadius: 14,
|
||
padding: '10px 14px',
|
||
marginBottom: 6,
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.02)',
|
||
};
|
||
|
||
return React.createElement('div', null,
|
||
// Header
|
||
React.createElement('div', { style: { padding: '6px 16px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }},
|
||
React.createElement('div', null,
|
||
React.createElement('div', { style: { fontSize: 15, fontWeight: 700, color: '#1C1C1E' }}, '家庭账单'),
|
||
React.createElement('div', { style: { fontSize: 11, color: '#8E8E93', fontWeight: 500, marginTop: 1 }},
|
||
React.createElement('span', { style: { color: '#FF6B35', fontWeight: 600 }}, family.name),
|
||
' · 本月累计 ¥' + overallTotal.toLocaleString('zh-CN', { minimumFractionDigits: 0 })
|
||
)
|
||
),
|
||
React.createElement('div', { style: { fontSize: 12, color: '#FF6B35', fontWeight: 600, background: 'rgba(255,107,53,0.08)', padding: '4px 10px', borderRadius: 8, cursor: 'pointer' }},
|
||
'详情 →'
|
||
)
|
||
),
|
||
|
||
// Member Rankings
|
||
React.createElement('div', { style: { padding: '0 16px', marginBottom: 4 }},
|
||
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4 }}, '成员消费排行'),
|
||
rankedMembers.length === 0
|
||
? React.createElement('div', { style: { padding: '20px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 13 }}, '暂无消费记录')
|
||
: rankedMembers.map(function(m, i) {
|
||
var isYou = m.userId === user.id;
|
||
var rankIcon = i < 3 ? rankEmojis[i] : String(i + 1);
|
||
var pct = maxTotal > 0 ? Math.round(m.total / maxTotal * 100) : 0;
|
||
return React.createElement('div', {
|
||
key: m.userId,
|
||
style: Object.assign({}, glassCardStyle, {
|
||
border: isYou ? '1px solid rgba(255,107,53,0.15)' : '1px solid rgba(255,255,255,0.8)',
|
||
borderLeft: isYou ? '3px solid #FF6B35' : '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: isYou ? '0 8px 32px rgba(255,107,53,0.08)' : '0 4px 16px rgba(0,0,0,0.02)',
|
||
})
|
||
},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 }},
|
||
React.createElement('span', { style: { fontSize: 16, width: 22, textAlign: 'center' }}, rankIcon),
|
||
React.createElement('div', { style: {
|
||
width: 32, height: 32, borderRadius: '50%',
|
||
background: isYou ? 'linear-gradient(135deg, #FF6B35, #FF9F5B)' : 'rgba(142,142,147,0.1)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 15, flexShrink: 0,
|
||
}},
|
||
isYou ? '👤' : '🙋'
|
||
),
|
||
React.createElement('span', { style: { flex: 1, fontSize: 13, fontWeight: 600, color: isYou ? '#FF6B35' : '#1C1C1E' }},
|
||
m.nickname + (isYou ? '(你)' : '')
|
||
),
|
||
React.createElement('span', { style: { fontSize: 13, fontWeight: 700, color: '#1C1C1E' }},
|
||
'¥' + m.total.toLocaleString('zh-CN', { minimumFractionDigits: 0 })
|
||
),
|
||
React.createElement('span', { style: { fontSize: 11, color: '#8E8E93', fontWeight: 500 }},
|
||
pct + '%'
|
||
)
|
||
)
|
||
);
|
||
})
|
||
),
|
||
|
||
// Recent Family Bills
|
||
React.createElement('div', { style: { padding: '0 16px' }},
|
||
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4, marginTop: 12 }}, '最近家庭记录'),
|
||
recentBills.length === 0
|
||
? React.createElement('div', { style: { padding: '20px 16px', textAlign: 'center', color: '#8E8E93', fontSize: 13 }}, '暂无家庭账单记录')
|
||
: recentBills.map(function(b, i) {
|
||
var member = members.find(function(m) { return m.userId === b.userId; });
|
||
var personName = member ? member.nickname : '未知';
|
||
var amt = b.type === 'expense' ? -Number(b.amount) : Number(b.amount);
|
||
return React.createElement('div', {
|
||
key: b.id,
|
||
style: Object.assign({}, glassCardStyle, { display: 'flex', alignItems: 'center', gap: 10 }),
|
||
},
|
||
React.createElement('span', { style: { fontSize: 18, width: 28, textAlign: 'center' }}, b.emoji || '📋'),
|
||
React.createElement('div', { style: { flex: 1 }},
|
||
React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: '#1C1C1E' }}, b.note || b.category),
|
||
React.createElement('div', { style: { fontSize: 11, color: '#8E8E93', fontWeight: 500, marginTop: 1 }},
|
||
personName + ' · ' + b.time
|
||
)
|
||
),
|
||
React.createElement('span', { style: { fontSize: 14, fontWeight: 700, color: amt > 0 ? '#34C759' : '#FF3B30', whiteSpace: 'nowrap' }},
|
||
(amt > 0 ? '+' : '') + '¥' + Math.abs(amt).toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||
)
|
||
);
|
||
})
|
||
),
|
||
|
||
React.createElement('div', { style: { height: 20 }})
|
||
);
|
||
}
|
||
|
||
function FAB({ onClick }) {
|
||
return (
|
||
<div onClick={onClick} style={{
|
||
pointerEvents: 'auto',
|
||
width: 50, height: 50,
|
||
borderRadius: '50%',
|
||
background: 'linear-gradient(135deg, #FF6B35 0%, #FF9F5B 100%)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: '0 4px 20px rgba(255,107,53,0.25), 0 0 0 1px rgba(255,255,255,0.2)',
|
||
cursor: 'pointer',
|
||
animation: 'pulseOrange 3s ease-in-out infinite',
|
||
color: '#fff',
|
||
fontSize: 26,
|
||
fontWeight: 300,
|
||
lineHeight: 1,
|
||
userSelect: 'none',
|
||
}}>
|
||
+
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActionSheet({ show, onManual, onScan, onCancel }) {
|
||
if (!show) return null;
|
||
return (
|
||
<div onClick={onCancel} style={{
|
||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||
background: 'rgba(0,0,0,0.3)',
|
||
backdropFilter: 'blur(4px)',
|
||
WebkitBackdropFilter: 'blur(4px)',
|
||
zIndex: 200,
|
||
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}}>
|
||
<div onClick={e => e.stopPropagation()} style={{
|
||
background: 'rgba(255,255,255,0.9)',
|
||
backdropFilter: 'blur(40px)',
|
||
WebkitBackdropFilter: 'blur(40px)',
|
||
borderRadius: '16px 16px 0 0',
|
||
width: '100%', maxWidth: 370,
|
||
padding: '20px 16px 32px',
|
||
animation: 'slideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||
border: '1px solid rgba(255,255,255,0.6)',
|
||
boxShadow: '0 -4px 32px rgba(0,0,0,0.1)',
|
||
}}>
|
||
{/* 手动记账 */}
|
||
<div onClick={onManual} style={{
|
||
display: 'flex', alignItems: 'center', gap: 14,
|
||
padding: '14px 16px', borderRadius: 12, cursor: 'pointer',
|
||
marginBottom: 6,
|
||
background: 'rgba(255,255,255,0.5)',
|
||
transition: 'background 0.15s ease',
|
||
}}>
|
||
<span style={{ fontSize: 26, flexShrink: 0 }}>📝</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 15, fontWeight: 600, color: '#1C1C1E' }}>手动记账</div>
|
||
<div style={{ fontSize: 12, color: '#8E8E93', fontWeight: 500, marginTop: 2 }}>手动输入账单信息</div>
|
||
</div>
|
||
</div>
|
||
{/* 扫描发票 */}
|
||
<div onClick={onScan} style={{
|
||
display: 'flex', alignItems: 'center', gap: 14,
|
||
padding: '14px 16px', borderRadius: 12, cursor: 'pointer',
|
||
background: 'rgba(255,107,53,0.04)',
|
||
border: '1px solid rgba(255,107,53,0.1)',
|
||
transition: 'background 0.15s ease',
|
||
}}>
|
||
<span style={{ fontSize: 26, flexShrink: 0 }}>📷</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 15, fontWeight: 600, color: '#FF6B35' }}>扫描发票</div>
|
||
<div style={{ fontSize: 12, color: '#8E8E93', fontWeight: 500, marginTop: 2 }}>拍照识别自动填入</div>
|
||
</div>
|
||
</div>
|
||
{/* 取消 */}
|
||
<div onClick={onCancel} style={{
|
||
textAlign: 'center', marginTop: 20, cursor: 'pointer',
|
||
padding: '10px 0',
|
||
}}>
|
||
<span style={{ fontSize: 15, fontWeight: 600, color: '#FF6B35' }}>取消</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BottomTabBar() {
|
||
const [activeNav, setActiveNav] = React.useState('home');
|
||
const [familyLabel, setFamilyLabel] = React.useState('个人');
|
||
const tabs = [
|
||
{ key: 'home', icon: '\uD83C\uDFE0', label: '首页' },
|
||
{ key: 'stats', icon: '\uD83D\uDCCA', label: '统计' },
|
||
{ key: 'profile', icon: '\uD83D\uDC64', label: '我的' },
|
||
];
|
||
const [plusHover, setPlusHover] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
const handler = (e) => setActiveNav(e.detail);
|
||
window.addEventListener('navUpdate', handler);
|
||
return () => window.removeEventListener('navUpdate', handler);
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
function updateLabel() {
|
||
var token = localStorage.getItem('__auth_token');
|
||
if (token && window.__authApi) {
|
||
var curUser = window.__authApi.getUserByToken(token);
|
||
if (curUser && curUser.activeFamilyId) {
|
||
var family = window.__authApi.getFamilyById(curUser.activeFamilyId);
|
||
setFamilyLabel(family ? family.name : '个人');
|
||
return;
|
||
}
|
||
}
|
||
setFamilyLabel('个人');
|
||
}
|
||
updateLabel();
|
||
window.addEventListener('familyModeUpdate', updateLabel);
|
||
return () => window.removeEventListener('familyModeUpdate', updateLabel);
|
||
}, []);
|
||
|
||
const handleNav = (key) => {
|
||
setActiveNav(key);
|
||
window.dispatchEvent(new CustomEvent('navTo', { detail: key }));
|
||
};
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
padding: '0 16px',
|
||
}}>
|
||
<div style={{display: 'flex', alignItems: 'center', gap: 10}}>
|
||
<div style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
background: 'rgba(255,255,255,0.5)',
|
||
backdropFilter: 'blur(40px)',
|
||
WebkitBackdropFilter: 'blur(40px)',
|
||
border: '1px solid rgba(255,255,255,0.6)',
|
||
borderRadius: 28,
|
||
padding: '6px 24px 6px 20px',
|
||
height: 56,
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
|
||
animation: 'dockFloat 4s ease-in-out infinite',
|
||
}}>
|
||
<div key="family-selector" onClick={() => window.dispatchEvent(new CustomEvent('navTo', { detail: 'profile' }))} style={{
|
||
display: 'flex', alignItems: 'center', gap: 4,
|
||
padding: '4px 8px 4px 4px', cursor: 'pointer',
|
||
borderRight: '1px solid rgba(60,60,67,0.08)', marginRight: 4,
|
||
fontSize: 12, fontWeight: 600, color: '#FF6B35',
|
||
whiteSpace: 'nowrap', flexShrink: 0,
|
||
}}>
|
||
<span style={{fontSize: 14}}>🏠</span>
|
||
<span>{familyLabel}</span>
|
||
<span style={{fontSize: 10, marginLeft: 2, color: '#C7C7CC'}}>▾</span>
|
||
</div>
|
||
{tabs.map((tab, i) => (
|
||
<div key={i} onClick={() => handleNav(tab.key)} style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||
gap: 2, cursor: 'pointer', userSelect: 'none',
|
||
padding: '4px 12px',
|
||
transition: 'all 0.2s ease',
|
||
}}>
|
||
<span style={{ fontSize: 20, lineHeight: 1 }}>{tab.icon}</span>
|
||
<span style={{
|
||
fontSize: 10, fontWeight: activeNav === tab.key ? 600 : 500,
|
||
color: activeNav === tab.key ? '#FF6B35' : '#8E8E93',
|
||
letterSpacing: '0.2px',
|
||
}}>
|
||
{tab.label}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: '50%',
|
||
background: 'rgba(255,107,53,0.06)',
|
||
backdropFilter: 'blur(40px)',
|
||
WebkitBackdropFilter: 'blur(40px)',
|
||
border: '1px solid rgba(255,255,255,0.6)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
userSelect: 'none',
|
||
transform: plusHover ? 'scale(1.05)' : 'scale(1)',
|
||
transition: 'all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
|
||
}}
|
||
onMouseEnter={() => setPlusHover(true)}
|
||
onMouseLeave={() => setPlusHover(false)}
|
||
onClick={() => window.dispatchEvent(new CustomEvent('showActionSheet'))}
|
||
>
|
||
<span style={{ fontSize: 24, fontWeight: 300, color: '#FF6B35', lineHeight: 1 }}>+</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddBillScreen({ onClose, ocrData }) {
|
||
const [type, setType] = React.useState('expense');
|
||
const [amount, setAmount] = React.useState('');
|
||
const [category, setCategory] = React.useState('餐饮');
|
||
const [note, setNote] = React.useState('');
|
||
const [addToFamily, setAddToFamily] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
if (ocrData) {
|
||
setAmount(ocrData.amount);
|
||
setCategory(ocrData.category);
|
||
setNote(ocrData.items);
|
||
}
|
||
}, [ocrData]);
|
||
|
||
const merchantName = ocrData ? ocrData.merchant : null;
|
||
|
||
const categories = type === 'expense' ? CATEGORIES_EXPENSE : CATEGORIES_INCOME;
|
||
const accentColor = type === 'expense' ? '#FF6B35' : '#34C759';
|
||
|
||
const handleNumberInput = (num) => {
|
||
if (num === '.' && amount.includes('.')) return;
|
||
if (num === '.' && amount === '') { setAmount('0.'); return; }
|
||
if (amount.includes('.') && amount.split('.')[1].length >= 2) return;
|
||
if (amount === '0' && num !== '.') { setAmount(String(num)); return; }
|
||
setAmount(prev => prev + String(num));
|
||
};
|
||
|
||
const handleDelete = () => {
|
||
setAmount(prev => prev.slice(0, -1));
|
||
};
|
||
|
||
const handleSave = () => {
|
||
if (!amount || parseFloat(amount) <= 0) return;
|
||
var token = localStorage.getItem('__auth_token');
|
||
var currentUser = null;
|
||
if (token && window.__authApi) {
|
||
currentUser = window.__authApi.getUserByToken(token);
|
||
}
|
||
var familyId = null;
|
||
if (addToFamily && currentUser) {
|
||
familyId = currentUser.activeFamilyId || null;
|
||
}
|
||
var result = window.__authApi.addBill(currentUser ? currentUser.id : 'unknown', {
|
||
type: type,
|
||
amount: amount,
|
||
category: category,
|
||
note: note,
|
||
familyId: familyId,
|
||
});
|
||
if (result.success) {
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
const displayAmount = amount || '0';
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7',
|
||
zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'slideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
padding: '18px 16px 12px',
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
background: 'rgba(255,255,255,0.85)',
|
||
backdropFilter: 'blur(30px)',
|
||
borderBottom: '1px solid rgba(60,60,67,0.06)',
|
||
}}>
|
||
<span onClick={onClose} style={{
|
||
fontSize: 16, color: '#8E8E93', fontWeight: 500, cursor: 'pointer',
|
||
padding: '4px 0',
|
||
}}>取消</span>
|
||
<span onClick={handleSave} style={{
|
||
fontSize: 16, color: accentColor, fontWeight: 600, cursor: 'pointer',
|
||
padding: '4px 0',
|
||
}}>保存</span>
|
||
</div>
|
||
|
||
{/* Type Toggle */}
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'center',
|
||
padding: '16px 16px 8px',
|
||
}}>
|
||
<div style={{
|
||
background: 'rgba(118,118,128,0.12)',
|
||
borderRadius: 10, padding: 2,
|
||
display: 'flex', width: 200,
|
||
}}>
|
||
{['expense', 'income'].map(t => (
|
||
<div key={t} onClick={() => { setType(t); setCategory(t === 'expense' ? '餐饮' : '工资'); }}
|
||
style={{
|
||
flex: 1, padding: '6px 0', textAlign: 'center',
|
||
borderRadius: 8, cursor: 'pointer',
|
||
background: type === t ? '#fff' : 'transparent',
|
||
boxShadow: type === t ? '0 1px 4px rgba(0,0,0,0.06)' : 'none',
|
||
fontSize: 14, fontWeight: 600,
|
||
color: type === t ? (t === 'expense' ? '#FF6B35' : '#34C759') : '#8E8E93',
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
>{t === 'expense' ? '支出' : '收入'}</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{merchantName && (
|
||
<div style={{
|
||
margin: '8px 16px 0', padding: '10px 14px',
|
||
background: '#FFF8F0', borderRadius: 10,
|
||
border: '1px solid #FFE4CC',
|
||
fontSize: 14, color: '#8B7355',
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
<span style={{ fontSize: 16 }}>📄</span>
|
||
<span>来自发票:<strong>{merchantName}</strong></span>
|
||
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#FF6B35', fontWeight: 500 }}>OCR识别</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Amount Input Hero */}
|
||
<div style={{
|
||
padding: '12px 20px 8px',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
gap: 4,
|
||
}}>
|
||
<span style={{ fontSize: 28, fontWeight: 300, color: '#8E8E93', marginTop: 8 }}>¥</span>
|
||
<span style={{
|
||
fontSize: 48, fontWeight: 700, color: '#1C1C1E',
|
||
letterSpacing: '-1px',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{parseFloat(displayAmount).toLocaleString('zh-CN', {minimumFractionDigits: displayAmount.includes('.') ? displayAmount.split('.')[1].length : 0, maximumFractionDigits: 2})}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Number Pad */}
|
||
<div style={{ padding: '8px 20px 4px', textAlign: 'center' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, maxWidth: 300, margin: '0 auto' }}>
|
||
{[1,2,3,4,5,6,7,8,9,'.',0,'delete'].map((key, i) => (
|
||
<div key={i} onClick={() => key === 'delete' ? handleDelete() : handleNumberInput(key)}
|
||
style={{
|
||
height: 48, borderRadius: 12, cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
background: key === 'delete' ? 'rgba(255,107,53,0.08)' : 'rgba(255,255,255,0.7)',
|
||
backdropFilter: key === 'delete' ? 'none' : 'blur(10px)',
|
||
fontSize: key === 'delete' ? 20 : 22,
|
||
fontWeight: key === 'delete' ? 400 : 500,
|
||
color: key === 'delete' ? '#FF6B35' : '#1C1C1E',
|
||
userSelect: 'none',
|
||
transition: 'all 0.1s ease',
|
||
}}
|
||
>
|
||
{key === 'delete' ? '\u232B' : key === '.' ? '.' : key}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category Grid */}
|
||
<div style={{ padding: '12px 16px 4px' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4 }}>
|
||
选择分类
|
||
</div>
|
||
<div style={{
|
||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8,
|
||
}}>
|
||
{categories.map((cat, i) => (
|
||
<div key={i} onClick={() => setCategory(cat.name)}
|
||
style={{
|
||
padding: '10px 6px', borderRadius: 12, cursor: 'pointer',
|
||
textAlign: 'center',
|
||
background: category === cat.name ? `rgba(255,107,53,0.1)` : 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(20px)',
|
||
border: category === cat.name ? `1.5px solid ${accentColor}` : '1px solid rgba(255,255,255,0.5)',
|
||
boxShadow: category === cat.name ? `0 2px 12px rgba(255,107,53,0.12)` : '0 1px 4px rgba(0,0,0,0.02)',
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 22, marginBottom: 2 }}>{cat.emoji}</div>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: category === cat.name ? 600 : 500,
|
||
color: category === cat.name ? '#FF6B35' : '#8E8E93',
|
||
}}>{cat.name}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form Details */}
|
||
<div style={{ padding: '12px 16px', flex: 1 }}>
|
||
<div style={{
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(20px)',
|
||
borderRadius: 14,
|
||
overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.5)',
|
||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||
}}>
|
||
{/* Note */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid rgba(60,60,67,0.06)' }}>
|
||
<span style={{ fontSize: 16, flexShrink: 0 }}>📝</span>
|
||
<input
|
||
type="text"
|
||
placeholder="添加备注..."
|
||
value={note}
|
||
onChange={e => setNote(e.target.value)}
|
||
style={{
|
||
flex: 1, border: 'none', outline: 'none',
|
||
fontSize: 14, color: '#1C1C1E',
|
||
background: 'transparent',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
</div>
|
||
{/* Date */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid rgba(60,60,67,0.06)' }}>
|
||
<span style={{ fontSize: 16, flexShrink: 0 }}>📅</span>
|
||
<span style={{ flex: 1, fontSize: 14, color: '#1C1C1E' }}>{new Date().getFullYear()}年{new Date().getMonth() + 1}月{new Date().getDate()}日</span>
|
||
<span style={{ fontSize: 14, color: '#8E8E93' }}>▸</span>
|
||
</div>
|
||
{/* Account */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: 'none' }}>
|
||
<span style={{ fontSize: 16, flexShrink: 0 }}>💳</span>
|
||
<span style={{ flex: 1, fontSize: 14, color: '#1C1C1E' }}>微信零钱</span>
|
||
<span style={{ fontSize: 14, color: '#8E8E93' }}>▸</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Family Toggle */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '12px 14px', marginTop: 8,
|
||
background: 'rgba(255,255,255,0.65)',
|
||
backdropFilter: 'blur(20px)',
|
||
borderRadius: 14,
|
||
border: '1px solid rgba(255,255,255,0.5)',
|
||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 16 }}>🏠</span>
|
||
<span style={{ fontSize: 14, color: '#1C1C1E', fontWeight: 500 }}>添加到家庭账本</span>
|
||
</div>
|
||
<div onClick={() => setAddToFamily(!addToFamily)} style={{
|
||
width: 44, height: 26, borderRadius: 13, cursor: 'pointer',
|
||
background: addToFamily ? '#34C759' : 'rgba(120,120,128,0.2)',
|
||
padding: 2, transition: 'all 0.2s ease',
|
||
}}>
|
||
<div style={{
|
||
width: 22, height: 22, borderRadius: '50%',
|
||
background: '#fff',
|
||
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||
marginLeft: addToFamily ? 20 : 0,
|
||
transition: 'all 0.2s ease',
|
||
}} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== OCR Scan Page ===== */
|
||
function OCRScanPage({ onClose, onCaptured }) {
|
||
const [scanning, setScanning] = useState(false);
|
||
const [torchOn, setTorchOn] = useState(false);
|
||
const hasScanned = useRef(false);
|
||
|
||
const handleCapture = () => {
|
||
if (hasScanned.current) return;
|
||
hasScanned.current = true;
|
||
setScanning(true);
|
||
setTimeout(() => {
|
||
const mockResult = {
|
||
merchant: '星巴克咖啡',
|
||
amount: '68.00',
|
||
date: '2026-05-13',
|
||
category: '餐饮',
|
||
items: '美式咖啡×1、拿铁×1',
|
||
};
|
||
onCaptured(mockResult);
|
||
}, 1800);
|
||
};
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#000', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}}>
|
||
{/* Top Bar */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }}>
|
||
<div onClick={onClose} style={{ fontSize: 22, color: '#fff', cursor: 'pointer', width: 32, textAlign: 'center' }}>✕</div>
|
||
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff' }}>扫描发票</div>
|
||
<div onClick={() => setTorchOn(!torchOn)} style={{ fontSize: 18, color: torchOn ? '#FF6B35' : '#fff', cursor: 'pointer', width: 32, textAlign: 'center' }}>🔦</div>
|
||
</div>
|
||
|
||
{/* Viewfinder Area */}
|
||
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||
<svg width="100%" height="100%" viewBox="0 0 370 460" preserveAspectRatio="xMidYMid meet" style={{ position: 'absolute', top: 0, left: 0 }}>
|
||
<defs>
|
||
<mask id="scanMask_ocr">
|
||
<rect width="370" height="460" fill="white" />
|
||
<rect x="45" y="80" width="280" height="180" rx="8" fill="black" />
|
||
</mask>
|
||
</defs>
|
||
<rect width="370" height="460" fill="rgba(0,0,0,0.5)" mask="url(#scanMask_ocr)" />
|
||
</svg>
|
||
|
||
{/* Corner guides */}
|
||
<div style={{ position: 'absolute', left: 45, top: 80, width: 280, height: 180, pointerEvents: 'none' }}>
|
||
<div style={{ position: 'absolute', left: -2, top: -2 }}><div style={{ width: 24, height: 3, background: '#FF6B35', borderRadius: 2 }} /><div style={{ width: 3, height: 24, background: '#FF6B35', borderRadius: 2 }} /></div>
|
||
<div style={{ position: 'absolute', right: -2, top: -2 }}><div style={{ width: 24, height: 3, background: '#FF6B35', borderRadius: 2, marginLeft: 'auto' }} /><div style={{ width: 3, height: 24, background: '#FF6B35', borderRadius: 2, marginLeft: 'auto' }} /></div>
|
||
<div style={{ position: 'absolute', left: -2, bottom: -2 }}><div style={{ width: 24, height: 3, background: '#FF6B35', borderRadius: 2 }} /><div style={{ width: 3, height: 24, background: '#FF6B35', borderRadius: 2 }} /></div>
|
||
<div style={{ position: 'absolute', right: -2, bottom: -2 }}><div style={{ width: 24, height: 3, background: '#FF6B35', borderRadius: 2, marginLeft: 'auto' }} /><div style={{ width: 3, height: 24, background: '#FF6B35', borderRadius: 2, marginLeft: 'auto' }} /></div>
|
||
</div>
|
||
|
||
<div style={{ position: 'absolute', left: 0, right: 0, textAlign: 'center', top: 280, fontSize: 13, color: 'rgba(255,255,255,0.6)' }}>将发票置于框内</div>
|
||
|
||
{/* Scanning Overlay */}
|
||
{scanning && (
|
||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.3)', zIndex: 1 }} />
|
||
<div style={{ position: 'absolute', left: 45, top: 160, width: 280, height: 2, background: 'linear-gradient(90deg, transparent, #FF6B35, transparent)', boxShadow: '0 0 8px #FF6B35, 0 0 16px #FF6B35', zIndex: 2, animation: 'scanLine 1.8s ease-in-out infinite' }} />
|
||
<div style={{ position: 'relative', zIndex: 3, textAlign: 'center' }}>
|
||
<div style={{ fontSize: 16, color: 'rgba(255,255,255,0.85)', marginBottom: 12 }}>正在识别发票内容...</div>
|
||
<div style={{ display: 'flex', justifyContent: 'center', gap: 6 }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#FF6B35', animation: 'pulse 0.8s ease infinite', animationDelay: '0s' }} />
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#FF6B35', animation: 'pulse 0.8s ease infinite', animationDelay: '0.2s' }} />
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#FF6B35', animation: 'pulse 0.8s ease infinite', animationDelay: '0.4s' }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bottom Toolbar */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 40, padding: '16px 20px 32px', background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(30px)' }}>
|
||
<div onClick={handleCapture} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||
<div style={{ width: 44, height: 44, borderRadius: 8, background: 'linear-gradient(135deg, rgba(255,255,255,0.15), rgba(255,255,255,0.05))', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}>🖼️</div>
|
||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.6)' }}>相册</div>
|
||
</div>
|
||
<div onClick={handleCapture} style={{ width: 72, height: 72, borderRadius: '50%', background: 'rgba(255,255,255,0.15)', border: '4px solid rgba(255,255,255,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', boxShadow: scanning ? 'none' : '0 0 20px rgba(255,107,53,0.5)' }}>
|
||
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#FF6B35' }} />
|
||
</div>
|
||
<div style={{ width: 44 }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== OCR Result Preview ===== */
|
||
function OCRResultPreview({ data, onConfirm, onRetake }) {
|
||
return (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: -54,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: '#F2F2F7',
|
||
zIndex: 100,
|
||
animation: 'fadeIn 0.2s ease',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}>
|
||
{/* Top bar */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '54px 16px 12px',
|
||
background: '#F2F2F7',
|
||
}}>
|
||
<button onClick={onRetake} style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#FF6B35',
|
||
fontSize: 16,
|
||
cursor: 'pointer',
|
||
padding: '8px 0',
|
||
}}>重新扫描</button>
|
||
<div style={{ fontSize: 17, fontWeight: 600, color: '#000' }}>扫描结果</div>
|
||
<button onClick={() => onRetake()} style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#FF6B35',
|
||
fontSize: 16,
|
||
cursor: 'pointer',
|
||
padding: '8px 0',
|
||
}}>取消</button>
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
<div style={{ flex: 1, overflow: 'auto', paddingTop: 8 }}>
|
||
<div style={{
|
||
margin: 16,
|
||
background: '#fff',
|
||
borderRadius: 16,
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.08), 0 0 0 0.5px rgba(0,0,0,0.02)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Header - success badge */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '24px 20px 8px',
|
||
gap: 8,
|
||
}}>
|
||
<div style={{
|
||
width: 22,
|
||
height: 22,
|
||
borderRadius: '50%',
|
||
background: '#34C759',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#fff',
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
}}>✓</div>
|
||
<span style={{ fontSize: 15, color: '#34C759', fontWeight: 500 }}>识别成功</span>
|
||
</div>
|
||
|
||
{/* Fields */}
|
||
<div style={{ padding: '8px 20px 20px' }}>
|
||
{/* 商家 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0' }}>
|
||
<div style={{ fontSize: 14, color: '#8E8E93', width: 80, flexShrink: 0 }}>商家</div>
|
||
<div style={{ flex: 1, textAlign: 'right', fontSize: 16, color: '#000' }}>{data.merchant}</div>
|
||
</div>
|
||
<div style={{ height: 1, background: '#E5E5E5', marginLeft: 0 }} />
|
||
|
||
{/* 金额 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0' }}>
|
||
<div style={{ fontSize: 14, color: '#8E8E93', width: 80, flexShrink: 0 }}>金额</div>
|
||
<div style={{ flex: 1, textAlign: 'right', fontSize: 16, color: '#FF6B35', fontWeight: 600 }}>¥{data.amount}</div>
|
||
</div>
|
||
<div style={{ height: 1, background: '#E5E5E5' }} />
|
||
|
||
{/* 日期 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0' }}>
|
||
<div style={{ fontSize: 14, color: '#8E8E93', width: 80, flexShrink: 0 }}>日期</div>
|
||
<div style={{ flex: 1, textAlign: 'right', fontSize: 16, color: '#000' }}>{data.date}</div>
|
||
</div>
|
||
<div style={{ height: 1, background: '#E5E5E5' }} />
|
||
|
||
{/* 分类 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0' }}>
|
||
<div style={{ fontSize: 14, color: '#8E8E93', width: 80, flexShrink: 0 }}>分类</div>
|
||
<div style={{ flex: 1, textAlign: 'right' }}>
|
||
<span style={{
|
||
display: 'inline-block',
|
||
background: '#FFF0E8',
|
||
color: '#FF6B35',
|
||
fontSize: 13,
|
||
padding: '2px 10px',
|
||
borderRadius: 10,
|
||
fontWeight: 500,
|
||
}}>{data.category}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ height: 1, background: '#E5E5E5' }} />
|
||
|
||
{/* 备注 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0' }}>
|
||
<div style={{ fontSize: 14, color: '#8E8E93', width: 80, flexShrink: 0 }}>备注</div>
|
||
<div style={{ flex: 1, textAlign: 'right', fontSize: 16, color: '#000' }}>{data.items}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom buttons */}
|
||
<div style={{
|
||
padding: '12px 32px',
|
||
paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 16px))',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 12,
|
||
}}>
|
||
<button onClick={onRetake} style={{
|
||
width: '100%',
|
||
height: 48,
|
||
background: '#fff',
|
||
color: '#FF6B35',
|
||
border: '1px solid #FF6B35',
|
||
borderRadius: 25,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}>重新拍摄</button>
|
||
<button onClick={() => onConfirm(data)} style={{
|
||
width: '100%',
|
||
height: 48,
|
||
background: '#FF6B35',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 25,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}>确认并入账</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Placeholder Components (will be replaced) ===== */
|
||
function StatisticsContent({ user, activeTab }) {
|
||
const [monthOffset, setMonthOffset] = React.useState(0);
|
||
const [bills, setBills] = React.useState([]);
|
||
|
||
const loadBills = React.useCallback(() => {
|
||
if (!user) { setBills([]); return; }
|
||
if (activeTab === 'family') {
|
||
var fResult = window.__authApi.getUserFamilies(user.id);
|
||
if (fResult.success && fResult.data.families.length > 0) {
|
||
var af = fResult.data.families.find(function(f) { return f.id === user.activeFamilyId; }) || fResult.data.families[0];
|
||
var bResult = window.__authApi.getFamilyBills(af.id);
|
||
if (bResult.success) setBills(bResult.data.bills);
|
||
}
|
||
} else {
|
||
var result = window.__authApi.getBills(user.id);
|
||
if (result.success) setBills(result.data.bills);
|
||
}
|
||
}, [user, activeTab]);
|
||
|
||
React.useEffect(function() { loadBills(); }, [loadBills]);
|
||
React.useEffect(function() {
|
||
var h = function() { loadBills(); };
|
||
window.addEventListener('refreshBills', h);
|
||
return function() { window.removeEventListener('refreshBills', h); };
|
||
}, [loadBills]);
|
||
|
||
// Date calculations
|
||
const now = new Date();
|
||
const targetDate = new Date(now.getFullYear(), now.getMonth() - monthOffset, 1);
|
||
const targetYear = targetDate.getFullYear();
|
||
const targetMonth = targetDate.getMonth();
|
||
const monthNames = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||
const label = targetYear + '年' + monthNames[targetMonth];
|
||
|
||
// Filter bills for selected month
|
||
const monthBills = bills.filter(function(b) {
|
||
if (!b.date) return false;
|
||
var parts = b.date.split('-');
|
||
if (parts.length < 2) return false;
|
||
return parseInt(parts[0]) === targetYear && parseInt(parts[1]) === (targetMonth + 1);
|
||
});
|
||
|
||
const totalIncome = monthBills.filter(function(b) { return b.type === 'income'; }).reduce(function(s, b) { return s + Number(b.amount); }, 0);
|
||
const totalExpense = monthBills.filter(function(b) { return b.type === 'expense'; }).reduce(function(s, b) { return s + Number(b.amount); }, 0);
|
||
const balance = totalIncome - totalExpense;
|
||
|
||
// Budget based on actual expense
|
||
const budgetUsed = totalExpense;
|
||
const budgetTotal = Math.max(totalExpense * 1.5, 5000);
|
||
const budgetPercent = budgetTotal > 0 ? Math.round(budgetUsed / budgetTotal * 100) : 0;
|
||
const ringRadius = 36;
|
||
const ringCirc = 2 * Math.PI * ringRadius;
|
||
const ringOffset = ringCirc * (1 - Math.min(budgetPercent, 100) / 100);
|
||
|
||
// Category breakdown from actual bills
|
||
var catColors = { '餐饮':'#FF6B35','交通':'#FF8F4F','购物':'#FFA878','居住':'#FFC4A0','医疗':'#FFDCC4','娱乐':'#FFE8D8','教育':'#FFF0E8','其他':'#FFF8F0', '工资':'#34C759','理财':'#34C759','兼职':'#34C759','红包':'#FF6B35','奖金':'#FF6B35' };
|
||
var catMap = {};
|
||
monthBills.filter(function(b) { return b.type === 'expense'; }).forEach(function(b) {
|
||
if (!catMap[b.category]) catMap[b.category] = { emoji: b.emoji || '📋', name: b.category, amount: 0, color: catColors[b.category] || '#FFE8D8' };
|
||
catMap[b.category].amount += Number(b.amount);
|
||
});
|
||
const categoryBreakdown = Object.keys(catMap).map(function(k) { return catMap[k]; }).sort(function(a, b) { return b.amount - a.amount; });
|
||
const maxCatAmount = categoryBreakdown.length > 0 ? categoryBreakdown[0].amount : 1;
|
||
|
||
// Daily trend
|
||
const daysInMonth = new Date(targetYear, targetMonth + 1, 0).getDate();
|
||
var dailyMap = {};
|
||
for (var d = 1; d <= daysInMonth; d++) { dailyMap[d] = 0; }
|
||
monthBills.filter(function(b) { return b.type === 'expense'; }).forEach(function(b) {
|
||
var day = parseInt((b.date || '').split('-')[2] || '1');
|
||
if (dailyMap[day] !== undefined) dailyMap[day] += Number(b.amount);
|
||
});
|
||
const dailyTrend = Object.keys(dailyMap).map(function(k) { return { label: k, amount: dailyMap[k] }; });
|
||
const maxDaily = Math.max.apply(null, dailyTrend.map(function(d) { return d.amount; }).concat([1]));
|
||
|
||
// Monthly comparison
|
||
var monthlyMap = {};
|
||
bills.filter(function(b) { return b.type === 'expense'; }).forEach(function(b) {
|
||
if (!b.date) return;
|
||
var parts = b.date.split('-');
|
||
var key = parseInt(parts[1]) + '月';
|
||
monthlyMap[key] = (monthlyMap[key] || 0) + Number(b.amount);
|
||
});
|
||
var monthlyComparison = [];
|
||
for (var i = 5; i >= 0; i--) {
|
||
var m = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||
var key = (m.getMonth() + 1) + '月';
|
||
monthlyComparison.push({ month: key, amount: monthlyMap[key] || 0 });
|
||
}
|
||
const maxMonthly = Math.max.apply(null, monthlyComparison.map(function(mc) { return mc.amount; }).concat([1]));
|
||
const prevIdx = monthlyComparison.length - 2;
|
||
const prevMonthAmount = prevIdx >= 0 ? monthlyComparison[prevIdx].amount : 0;
|
||
const changePercent = prevMonthAmount > 0 ? ((totalExpense - prevMonthAmount) / prevMonthAmount * 100).toFixed(1) : '0.0';
|
||
const isUp = totalExpense > prevMonthAmount;
|
||
|
||
const glassCardStyle = { background:'rgba(255,255,255,0.65)',backdropFilter:'blur(30px)',WebkitBackdropFilter:'blur(30px)',borderRadius:16,padding:'12px 8px',textAlign:'center',border:'1px solid rgba(255,255,255,0.8)',boxShadow:'0 8px 32px rgba(255,107,53,0.06), 0 0 0 1px rgba(255,255,255,0.9) inset' };
|
||
const arrowStyle = { fontSize:24,cursor:'pointer',color:'#FF6B35',padding:'4px 12px',userSelect:'none',fontWeight:300 };
|
||
|
||
return (
|
||
<div style={{ padding:'16px 16px 80px',overflowY:'auto',maxHeight:'100%',animation:'fadeSlideUp 0.35s ease' }}>
|
||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
|
||
<span onClick={function() { setMonthOffset(function(o) { return Math.min(o + 1, 0); }); }} style={Object.assign({}, arrowStyle, { visibility: monthOffset < 0 ? 'visible' : 'hidden' })}>‹</span>
|
||
<span style={{fontSize:16,fontWeight:700,color:'#1C1C1E'}}>{label}</span>
|
||
<span onClick={function() { setMonthOffset(function(o) { return o - 1; }); }} style={arrowStyle}>›</span>
|
||
</div>
|
||
|
||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr 1fr',gap:8,marginBottom:16}}>
|
||
{[{label:'收入',value:'¥'+totalIncome.toLocaleString(),color:'#34C759'},{label:'支出',value:'¥'+totalExpense.toLocaleString(),color:'#FF3B30'},{label:'结余',value:'¥'+balance.toLocaleString(),color:'#FF6B35'}].map(function(item,i) { return (
|
||
<div key={i} style={glassCardStyle}>
|
||
<div style={{fontSize:11,color:'#8E8E93',fontWeight:500,marginBottom:4}}>{item.label}</div>
|
||
<div style={{fontSize:14,fontWeight:700,color:item.color}}>{item.value}</div>
|
||
</div>
|
||
);})}
|
||
</div>
|
||
|
||
<div style={Object.assign({}, glassCardStyle, {display:'flex',flexDirection:'column',alignItems:'center',padding:'20px 16px',marginBottom:16})}>
|
||
<div style={{position:'relative',width:90,height:90}}>
|
||
<svg width="90" height="90" viewBox="0 0 90 90">
|
||
<circle cx="45" cy="45" r={ringRadius} fill="none" stroke="rgba(142,142,147,0.12)" strokeWidth="6"/>
|
||
<circle cx="45" cy="45" r={ringRadius} fill="none" stroke="#FF6B35" strokeWidth="6" strokeDasharray={ringCirc} strokeDashoffset={ringOffset} strokeLinecap="round" transform="rotate(-90 45 45)" style={{transition:'stroke-dashoffset 1s ease'}}/>
|
||
</svg>
|
||
<div style={{position:'absolute',top:0,left:0,right:0,bottom:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||
<span style={{fontSize:20,fontWeight:700,color:'#FF6B35'}}>{budgetPercent}%</span>
|
||
</div>
|
||
</div>
|
||
<div style={{fontSize:12,color:'#8E8E93',fontWeight:500,marginTop:8}}>预算 ¥{budgetTotal.toLocaleString()} · 已用 ¥{budgetUsed.toLocaleString()}</div>
|
||
</div>
|
||
|
||
<div style={{marginBottom:16}}>
|
||
<div style={{fontSize:13,fontWeight:600,color:'#1C1C1E',marginBottom:10,paddingLeft:4}}>支出分类排行</div>
|
||
{categoryBreakdown.length === 0 ? (
|
||
<div style={{textAlign:'center',color:'#8E8E93',fontSize:13,padding:'20px 0'}}>暂无支出数据</div>
|
||
) : categoryBreakdown.map(function(cat,i) { return (
|
||
<div key={i} style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
|
||
<span style={{fontSize:18,width:28,textAlign:'center'}}>{cat.emoji}</span>
|
||
<span style={{fontSize:12,fontWeight:600,color:'#1C1C1E',width:32,flexShrink:0}}>{cat.name}</span>
|
||
<div style={{flex:1,height:6,background:'rgba(142,142,147,0.08)',borderRadius:3,overflow:'hidden'}}>
|
||
<div style={{width:Math.round(cat.amount/maxCatAmount*100)+'%',height:'100%',background:cat.color,borderRadius:3,animation:'barGrow 0.6s ease '+(i*0.05)+'s both'}}/>
|
||
</div>
|
||
<span style={{fontSize:12,fontWeight:600,color:'#1C1C1E',width:68,textAlign:'right',flexShrink:0}}>¥{cat.amount.toLocaleString()}</span>
|
||
</div>
|
||
);})}
|
||
</div>
|
||
|
||
<div style={{marginBottom:16}}>
|
||
<div style={{fontSize:13,fontWeight:600,color:'#1C1C1E',marginBottom:10,paddingLeft:4}}>每日消费趋势</div>
|
||
<div style={Object.assign({}, glassCardStyle, {padding:'16px 12px',display:'flex',alignItems:'flex-end',gap:2,height:140})}>
|
||
{dailyTrend.map(function(d,i) { return (
|
||
<div key={i} style={{flex:1,display:'flex',flexDirection:'column',alignItems:'center',height:'100%',justifyContent:'flex-end'}}>
|
||
<div style={{width:'100%',maxWidth:18,height:Math.max(d.amount/maxDaily*80, d.amount > 0 ? 4 : 0)+'px',background:d.amount > 0 ? 'linear-gradient(180deg, #FF6B35, #FF9F5B)' : 'transparent',borderRadius:'4px 4px 0 0',transition:'height 0.5s ease'}}/>
|
||
<span style={{fontSize:8,color:'#8E8E93',marginTop:4,fontWeight:500}}>{d.label}</span>
|
||
</div>
|
||
);})}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{marginBottom:24}}>
|
||
<div style={{fontSize:13,fontWeight:600,color:'#1C1C1E',marginBottom:10,paddingLeft:4}}>月度对比</div>
|
||
{monthlyComparison.map(function(mc,i) {
|
||
var isCurrent = i === monthlyComparison.length - 1;
|
||
return (
|
||
<div key={i} style={Object.assign({}, glassCardStyle, {display:'flex',alignItems:'center',gap:12,padding:'12px 14px',marginBottom:6,borderLeft: isCurrent ? '3px solid #FF6B35' : '1px solid rgba(255,255,255,0.8)'})}>
|
||
<span style={{fontSize:13,fontWeight:600,color:isCurrent?'#FF6B35':'#1C1C1E',width:36}}>{mc.month}</span>
|
||
<div style={{flex:1,height:6,background:'rgba(142,142,147,0.08)',borderRadius:3,overflow:'hidden'}}>
|
||
<div style={{width:Math.round(mc.amount/maxMonthly*100)+'%',height:'100%',background:isCurrent?'linear-gradient(90deg, #FF6B35, #FF9F5B)':'rgba(142,142,147,0.2)',borderRadius:3}}/>
|
||
</div>
|
||
<span style={{fontSize:13,fontWeight:600,color:isCurrent?'#FF6B35':'#1C1C1E',width:60,textAlign:'right',flexShrink:0}}>¥{mc.amount.toLocaleString()}</span>
|
||
{isCurrent && (
|
||
<span style={{fontSize:11,fontWeight:600,color: isUp ? '#FF3B30' : '#34C759',width:50,textAlign:'right'}}>{isUp ? '\u2191' : '\u2193'}{Math.abs(Number(changePercent))}%</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
function ProfileContent({ user, onLogin, onLogout, onUserInfo, onFamilyManagement }) {
|
||
const settingsGroups = [
|
||
{
|
||
title: '账本管理',
|
||
items: [
|
||
{ emoji: '🏠', name: '我的家庭' },
|
||
{ emoji: '\uD83D\uDCD2', name: '账本管理' },
|
||
{ emoji: '\uD83D\uDCB0', name: '预算设置' },
|
||
{ emoji: '\uD83C\uDFF7\uFE0F', name: '分类管理' },
|
||
],
|
||
},
|
||
{
|
||
title: '系统设置',
|
||
items: [
|
||
{ emoji: '\uD83E\uDD16', name: 'AI 设置' },
|
||
{ emoji: '\uD83D\uDCE4', name: '数据导出' },
|
||
{ emoji: '\uD83D\uDD14', name: '提醒设置' },
|
||
],
|
||
},
|
||
{
|
||
title: '关于',
|
||
items: [
|
||
{ emoji: '\u2139\uFE0F', name: '关于' },
|
||
{ emoji: '\uD83D\uDCAC', name: '帮助与反馈' },
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div style={{ padding: '16px 16px 80px', overflowY: 'auto', maxHeight: '100%', animation: 'fadeSlideUp 0.35s ease' }}>
|
||
{/* 1. User Info Card */}
|
||
{!user ? (
|
||
<div onClick={onLogin} style={{
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 20, padding: '24px 20px', marginBottom: 16, cursor: 'pointer',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.08), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
display: 'flex', alignItems: 'center', gap: 16,
|
||
}}>
|
||
<div style={{
|
||
width: 56, height: 56, borderRadius: '50%',
|
||
background: 'rgba(142,142,147,0.12)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 24, flexShrink: 0,
|
||
}}>👤</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1C1C1E' }}>登录 / 注册</div>
|
||
<div style={{ fontSize: 12, color: '#8E8E93', fontWeight: 500, marginTop: 2 }}>
|
||
登录后可同步数据管理家庭账本
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div onClick={onUserInfo} style={{
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 20, padding: '24px 20px', marginBottom: 16, cursor: 'pointer',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.08), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
display: 'flex', alignItems: 'center', gap: 16,
|
||
}}>
|
||
<div style={{
|
||
width: 56, height: 56, borderRadius: '50%',
|
||
background: 'linear-gradient(135deg, #FF6B35, #FF9F5B)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 22, fontWeight: 700, color: '#fff',
|
||
flexShrink: 0,
|
||
boxShadow: '0 4px 16px rgba(255,107,53,0.25)',
|
||
}}>{user.nickname ? user.nickname.charAt(0) : '?'}</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1C1C1E' }}>{user.nickname || '用户'}</div>
|
||
<div style={{ fontSize: 12, color: '#8E8E93', fontWeight: 500, marginTop: 2 }}>
|
||
{'ID: ' + (user.id || '') + ' · ' + (user.joinDate || '')}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: (user.email ? '#8E8E93' : '#FF6B35'), fontWeight: 500, marginTop: 2 }}>
|
||
{user.email ? '邮箱已绑定' : '邮箱未绑定'}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#FF6B35', fontWeight: 600, marginTop: 4 }}>
|
||
{(function() {
|
||
try {
|
||
var families = window.__authApi.getUserFamilies(user.id);
|
||
if (families.success && families.data.families.length > 0) {
|
||
var active = families.data.families.find(function(f) { return f.id === user.activeFamilyId; });
|
||
return (active ? active.name : families.data.families[0].name) + ' 🏠';
|
||
}
|
||
} catch(e) {}
|
||
return '暂无家庭 🏠';
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div style={{ fontSize: 18, color: '#C7C7CC' }}>▸</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 2. Family Members Row */}
|
||
{user && (function() {
|
||
try {
|
||
var families = window.__authApi.getUserFamilies(user.id);
|
||
if (families.success && families.data.families.length > 0) {
|
||
var activeFamily = families.data.families.find(function(f) { return f.id === user.activeFamilyId; }) || families.data.families[0];
|
||
var members = activeFamily.members || [];
|
||
return React.createElement('div', { style: { marginBottom: 16 }},
|
||
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4 }},
|
||
activeFamily.name + ' · 家庭成员'
|
||
),
|
||
React.createElement('div', { style: {
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 16, padding: '14px 16px',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
display: 'flex', gap: 12, overflowX: 'auto',
|
||
}},
|
||
members.map(function(m, i) {
|
||
var isYou = m.userId === user.id;
|
||
var initial = m.nickname ? m.nickname.charAt(0) : '?';
|
||
return React.createElement('div', { key: i, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 48 }},
|
||
React.createElement('div', { style: {
|
||
width: 44, height: 44, borderRadius: '50%',
|
||
background: isYou ? 'linear-gradient(135deg, #FF6B35, #FF9F5B)' : 'rgba(142,142,147,0.1)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 20, fontWeight: 600, color: isYou ? '#fff' : '#8E8E93',
|
||
boxShadow: isYou ? '0 2px 8px rgba(255,107,53,0.2)' : 'none',
|
||
}}, initial),
|
||
React.createElement('span', { style: {
|
||
fontSize: 10, fontWeight: isYou ? 600 : 500,
|
||
color: isYou ? '#FF6B35' : '#8E8E93',
|
||
whiteSpace: 'nowrap',
|
||
}}, m.nickname + (isYou ? '(你)' : ''))
|
||
);
|
||
}),
|
||
React.createElement('div', { onClick: function() { if (onFamilyManagement) onFamilyManagement(); }, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 48 }},
|
||
React.createElement('div', { style: {
|
||
width: 44, height: 44, borderRadius: '50%',
|
||
border: '1.5px dashed #FF6B35',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 20, color: '#FF6B35', cursor: 'pointer',
|
||
}}, '+'),
|
||
React.createElement('span', { style: { fontSize: 10, fontWeight: 500, color: '#FF6B35' }}, '邀请')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
})()}
|
||
|
||
{/* 3. Settings Groups */}
|
||
{settingsGroups.map((group, gi) => (
|
||
<div key={gi} style={{ marginBottom: 16 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||
{group.title}
|
||
</div>
|
||
<div style={{
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 14, overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
}}>
|
||
{group.items.map((item, ii) => (
|
||
<div key={ii} onClick={function() {
|
||
if (user && item.name === '我的家庭' && onFamilyManagement) {
|
||
onFamilyManagement();
|
||
}
|
||
}} style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: '14px 16px',
|
||
borderBottom: ii < group.items.length - 1 ? '1px solid rgba(60,60,67,0.06)' : 'none',
|
||
cursor: 'pointer',
|
||
}}>
|
||
<span style={{ fontSize: 18, width: 28, textAlign: 'center' }}>{item.emoji}</span>
|
||
<span style={{ flex: 1, fontSize: 14, fontWeight: 500, color: '#1C1C1E' }}>{item.name}</span>
|
||
<span style={{ fontSize: 14, color: '#C7C7CC' }}>▸</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* 4. Logout */}
|
||
{user && (
|
||
<div style={{ textAlign: 'center', padding: '8px 0 16px' }}>
|
||
<span onClick={onLogout} style={{
|
||
fontSize: 14, fontWeight: 600, color: '#FF3B30', cursor: 'pointer',
|
||
padding: '10px 40px', display: 'inline-block',
|
||
border: '1px solid rgba(255,59,48,0.2)', borderRadius: 12,
|
||
background: 'rgba(255,59,48,0.04)',
|
||
}}>退出登录</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Mock Auth API ===== */
|
||
window.__authApi = (function() {
|
||
const USERS_KEY = '__mock_users';
|
||
const TOKEN_KEY = '__auth_token';
|
||
|
||
function getUsers() {
|
||
try { return JSON.parse(localStorage.getItem(USERS_KEY) || '{}'); }
|
||
catch(e) { return {}; }
|
||
}
|
||
|
||
function saveUsers(users) {
|
||
localStorage.setItem(USERS_KEY, JSON.stringify(users));
|
||
}
|
||
|
||
function generateToken(phone) {
|
||
return btoa(phone + ':' + Date.now());
|
||
}
|
||
|
||
function getUserFromToken(token) {
|
||
try {
|
||
const decoded = atob(token);
|
||
const phone = decoded.split(':')[0];
|
||
const users = getUsers();
|
||
if (users[phone]) {
|
||
const { password, ...user } = users[phone];
|
||
if (!user.familyIds) user.familyIds = [];
|
||
if (!user.activeFamilyId) user.activeFamilyId = null;
|
||
return user;
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
|
||
// 预设账号:13800138000 / 123456
|
||
function ensureDefaultUser() {
|
||
const users = getUsers();
|
||
if (!users['13800138000']) {
|
||
users['13800138000'] = {
|
||
id: '1001',
|
||
phone: '13800138000',
|
||
password: '123456',
|
||
nickname: '小明',
|
||
email: null,
|
||
familyIds: [],
|
||
activeFamilyId: null,
|
||
joinDate: '2026-03',
|
||
};
|
||
saveUsers(users);
|
||
}
|
||
}
|
||
ensureDefaultUser();
|
||
|
||
// Auto-create family for preset user
|
||
(function autoCreateFamily() {
|
||
var presetFamilies = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
var presetHasFamily = false;
|
||
for (var fid in presetFamilies) {
|
||
if (presetFamilies[fid].members && presetFamilies[fid].members.some(function(m) { return m.userId === '1001'; })) {
|
||
presetHasFamily = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!presetHasFamily) {
|
||
var users = getUsers();
|
||
var presetUser = users['13800138000'];
|
||
if (presetUser) {
|
||
var familyId = 'f_' + Date.now();
|
||
var chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||
var code = '';
|
||
for (var i = 0; i < 6; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
presetFamilies[familyId] = {
|
||
id: familyId, name: '小明的家', inviteCode: code,
|
||
createdBy: '1001', createdAt: '2026-03',
|
||
members: [{ userId: '1001', nickname: '小明', role: 'owner', joinedAt: '2026-03' }]
|
||
};
|
||
localStorage.setItem('__mock_families', JSON.stringify(presetFamilies));
|
||
presetUser.familyIds = [familyId];
|
||
presetUser.activeFamilyId = familyId;
|
||
saveUsers(users);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return {
|
||
login(phone, password) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
const users = getUsers();
|
||
const user = users[phone];
|
||
if (!user) {
|
||
resolve({ success: false, error: '该手机号未注册' });
|
||
return;
|
||
}
|
||
if (user.password !== password) {
|
||
resolve({ success: false, error: '密码错误' });
|
||
return;
|
||
}
|
||
const token = generateToken(phone);
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
const { password: pwd, ...safeUser } = user;
|
||
resolve({ success: true, data: { token, user: safeUser } });
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
register(phone, password, nickname) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
const users = getUsers();
|
||
if (users[phone]) {
|
||
resolve({ success: false, error: '该手机号已注册' });
|
||
return;
|
||
}
|
||
const newUser = {
|
||
id: String(Date.now()).slice(-6),
|
||
phone,
|
||
password,
|
||
nickname: nickname || '用户' + phone.slice(-4),
|
||
email: null,
|
||
familyIds: [],
|
||
activeFamilyId: null,
|
||
joinDate: new Date().toISOString().slice(0, 7).replace('-', '年') + '月',
|
||
};
|
||
users[phone] = newUser;
|
||
saveUsers(users);
|
||
const token = generateToken(phone);
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
const { password: pwd, ...safeUser } = newUser;
|
||
resolve({ success: true, data: { token, user: safeUser } });
|
||
}, 500);
|
||
});
|
||
},
|
||
|
||
logout() {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
},
|
||
|
||
getUserByToken(token) {
|
||
if (!token) return null;
|
||
return getUserFromToken(token);
|
||
},
|
||
|
||
bindEmail(userId, email) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
const users = getUsers();
|
||
for (const phone in users) {
|
||
if (users[phone].id === userId) {
|
||
users[phone].email = email;
|
||
saveUsers(users);
|
||
resolve({ success: true });
|
||
return;
|
||
}
|
||
}
|
||
resolve({ success: false, error: '用户不存在' });
|
||
}, 300);
|
||
});
|
||
},
|
||
|
||
createFamily(userId, name) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
if (!userId || !name) { resolve({ success: false, error: '参数不完整' }); return; }
|
||
var families = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
var inviteCode = '';
|
||
var chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||
for (var i = 0; i < 6; i++) inviteCode += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
var familyId = 'f_' + Date.now();
|
||
var family = { id: familyId, name: name, inviteCode: inviteCode, createdBy: userId, createdAt: new Date().toISOString().slice(0, 10), members: [{ userId: userId, nickname: '', role: 'owner', joinedAt: new Date().toISOString().slice(0, 10) }] };
|
||
var users = JSON.parse(localStorage.getItem('__mock_users') || '{}');
|
||
for (var phone in users) { if (users[phone].id === userId) { family.members[0].nickname = users[phone].nickname || '用户'; break; } }
|
||
families[familyId] = family;
|
||
localStorage.setItem('__mock_families', JSON.stringify(families));
|
||
for (var p in users) { if (users[p].id === userId) { users[p].familyIds = users[p].familyIds || []; if (users[p].familyIds.indexOf(familyId) === -1) users[p].familyIds.push(familyId); users[p].activeFamilyId = users[p].activeFamilyId || familyId; localStorage.setItem('__mock_users', JSON.stringify(users)); break; } }
|
||
resolve({ success: true, data: { family: family } });
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
joinFamily(userId, inviteCode) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
if (!inviteCode || inviteCode.length !== 6) { resolve({ success: false, error: '邀请码无效' }); return; }
|
||
var families = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
var foundFamily = null;
|
||
for (var fid in families) { if (families[fid].inviteCode === inviteCode.toUpperCase()) { foundFamily = families[fid]; break; } }
|
||
if (!foundFamily) { resolve({ success: false, error: '邀请码无效' }); return; }
|
||
if (foundFamily.members.some(function(m) { return m.userId === userId; })) { resolve({ success: false, error: '你已在该家庭中' }); return; }
|
||
var nickname = '用户', users = JSON.parse(localStorage.getItem('__mock_users') || '{}');
|
||
for (var phone in users) { if (users[phone].id === userId) { nickname = users[phone].nickname || '用户'; users[phone].familyIds = users[phone].familyIds || []; if (users[phone].familyIds.indexOf(foundFamily.id) === -1) users[phone].familyIds.push(foundFamily.id); users[phone].activeFamilyId = foundFamily.id; localStorage.setItem('__mock_users', JSON.stringify(users)); break; } }
|
||
foundFamily.members.push({ userId: userId, nickname: nickname, role: 'member', joinedAt: new Date().toISOString().slice(0, 10) });
|
||
localStorage.setItem('__mock_families', JSON.stringify(families));
|
||
resolve({ success: true, data: { family: foundFamily } });
|
||
}, 400);
|
||
});
|
||
},
|
||
|
||
leaveFamily(userId, familyId) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(function() {
|
||
var families = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
var family = families[familyId];
|
||
if (!family) { resolve({ success: false, error: '家庭不存在' }); return; }
|
||
var member = null;
|
||
for (var mi = 0; mi < family.members.length; mi++) { if (family.members[mi].userId === userId) { member = family.members[mi]; break; } }
|
||
if (!member) { resolve({ success: false, error: '你不在该家庭中' }); return; }
|
||
if (member.role === 'owner') { resolve({ success: false, error: '创建者不能退出家庭' }); return; }
|
||
family.members = family.members.filter(function(m) { return m.userId !== userId; });
|
||
localStorage.setItem('__mock_families', JSON.stringify(families));
|
||
var users = JSON.parse(localStorage.getItem('__mock_users') || '{}');
|
||
for (var p in users) { if (users[p].id === userId) { users[p].familyIds = (users[p].familyIds || []).filter(function(id) { return id !== familyId; }); if (users[p].activeFamilyId === familyId) users[p].activeFamilyId = users[p].familyIds.length > 0 ? users[p].familyIds[0] : null; localStorage.setItem('__mock_users', JSON.stringify(users)); break; } }
|
||
resolve({ success: true });
|
||
}, 300);
|
||
});
|
||
},
|
||
|
||
getUserFamilies(userId) {
|
||
var families = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
var result = [];
|
||
for (var fid in families) { if (families[fid].members && families[fid].members.some(function(m) { return m.userId === userId; })) result.push(families[fid]); }
|
||
return { success: true, data: { families: result } };
|
||
},
|
||
|
||
switchActiveFamily(userId, familyId) {
|
||
var users = JSON.parse(localStorage.getItem('__mock_users') || '{}');
|
||
for (var p in users) { if (users[p].id === userId) { users[p].activeFamilyId = familyId; localStorage.setItem('__mock_users', JSON.stringify(users)); return { success: true }; } }
|
||
return { success: false, error: '用户不存在' };
|
||
},
|
||
|
||
getFamilyById(familyId) {
|
||
var families = JSON.parse(localStorage.getItem('__mock_families') || '{}');
|
||
return families[familyId] || null;
|
||
},
|
||
|
||
// --- Bill methods ---
|
||
|
||
// Seed data migration: called once on first bill API access
|
||
_ensureBillSeedData: function() {
|
||
var key = '__mock_bills_seeded';
|
||
if (localStorage.getItem(key)) return;
|
||
var bills = JSON.parse(localStorage.getItem('__mock_bills') || '[]');
|
||
if (bills.length > 0) return;
|
||
var seedTransactions = [
|
||
{ emoji: '\uD83C\uDF5C', name: '早餐', category: '餐饮', amount: -18.00 },
|
||
{ emoji: '\uD83D\uDE95', name: '打车上班', category: '交通', amount: -25.00 },
|
||
{ emoji: '\u2615', name: '星巴克', category: '餐饮', amount: -38.00 },
|
||
{ emoji: '\uD83D\uDCBC', name: '工资到账', category: '工资', amount: 8000.00 },
|
||
{ emoji: '\uD83D\uDED2', name: '永辉超市', category: '购物', amount: -156.80 },
|
||
{ emoji: '\uD83D\uDCE6', name: '快递运费', category: '其他', amount: -12.00 },
|
||
{ emoji: '\uD83D\uDC8A', name: '药店', category: '医疗', amount: -89.00 },
|
||
{ emoji: '\uD83C\uDF72', name: '晚餐外卖', category: '餐饮', amount: -42.00 },
|
||
];
|
||
var times = ['08:15','09:30','10:45','10:00','12:20','14:00','16:30','19:00'];
|
||
seedTransactions.forEach(function(t, i) {
|
||
bills.push({
|
||
id: 'b_seed_' + i,
|
||
userId: '1001',
|
||
familyId: null,
|
||
type: t.amount > 0 ? 'income' : 'expense',
|
||
amount: Math.abs(t.amount),
|
||
category: t.category,
|
||
note: t.name,
|
||
emoji: t.emoji,
|
||
time: times[i] || '12:00',
|
||
date: '2026-05-12',
|
||
createdAt: '2026-05-12T' + (times[i] || '12:00') + ':00.000Z',
|
||
});
|
||
});
|
||
localStorage.setItem('__mock_bills', JSON.stringify(bills));
|
||
localStorage.setItem(key, 'true');
|
||
},
|
||
|
||
addBill(userId, billData) {
|
||
this._ensureBillSeedData();
|
||
var bills = JSON.parse(localStorage.getItem('__mock_bills') || '[]');
|
||
var categoryMap = {
|
||
'餐饮': '\uD83C\uDF5C', '交通': '\uD83D\uDE95', '购物': '\uD83D\uDED2',
|
||
'居住': '\uD83C\uDFE0', '医疗': '\uD83D\uDC8A', '娱乐': '\uD83C\uDFAE',
|
||
'教育': '\uD83D\uDCDA', '其他': '\uD83D\uDCE6',
|
||
'工资': '\uD83D\uDCB0', '理财': '\uD83D\uDCC8', '兼职': '\uD83C\uDF93',
|
||
'红包': '\uD83E\uDD11', '奖金': '\uD83C\uDFC6',
|
||
};
|
||
var now = new Date();
|
||
var h = String(now.getHours()).padStart(2, '0');
|
||
var m = String(now.getMinutes()).padStart(2, '0');
|
||
var y = now.getFullYear();
|
||
var mo = String(now.getMonth() + 1).padStart(2, '0');
|
||
var d = String(now.getDate()).padStart(2, '0');
|
||
var bill = {
|
||
id: 'b_' + Date.now(),
|
||
userId: userId,
|
||
familyId: billData.familyId || null,
|
||
type: billData.type || 'expense',
|
||
amount: Number(billData.amount) || 0,
|
||
category: billData.category || '其他',
|
||
note: billData.note || '',
|
||
emoji: categoryMap[billData.category] || '\uD83D\uDCE6',
|
||
time: h + ':' + m,
|
||
date: y + '-' + mo + '-' + d,
|
||
createdAt: now.toISOString(),
|
||
};
|
||
bills.unshift(bill);
|
||
localStorage.setItem('__mock_bills', JSON.stringify(bills));
|
||
return { success: true, data: { bill: bill } };
|
||
},
|
||
|
||
getBills(userId) {
|
||
this._ensureBillSeedData();
|
||
var bills = JSON.parse(localStorage.getItem('__mock_bills') || '[]');
|
||
var userBills = bills.filter(function(b) { return b.userId === userId && !b.familyId; });
|
||
return { success: true, data: { bills: userBills } };
|
||
},
|
||
|
||
getFamilyBills(familyId) {
|
||
this._ensureBillSeedData();
|
||
var bills = JSON.parse(localStorage.getItem('__mock_bills') || '[]');
|
||
var familyBills = bills.filter(function(b) { return b.familyId === familyId; });
|
||
return { success: true, data: { bills: familyBills } };
|
||
},
|
||
};
|
||
})();
|
||
|
||
/* ===== LoginPage ===== */
|
||
function LoginPage({ onLoginSuccess, onGoRegister, onClose }) {
|
||
var _useState = useState(''), phone = _useState[0], setPhone = _useState[1];
|
||
var _useState2 = useState(''), password = _useState2[0], setPassword = _useState2[1];
|
||
var _useState3 = useState(''), error = _useState3[0], setError = _useState3[1];
|
||
var _useState4 = useState(false), loading = _useState4[0], setLoading = _useState4[1];
|
||
|
||
var handleLogin = function() {
|
||
setError('');
|
||
if (!phone || phone.length !== 11) { setError('请输入正确的手机号'); return; }
|
||
if (!password || password.length < 6) { setError('密码至少6位'); return; }
|
||
setLoading(true);
|
||
window.__authApi.login(phone, password).then(function(result) {
|
||
setLoading(false);
|
||
if (result.success) {
|
||
onLoginSuccess(result.data.token, result.data.user);
|
||
} else {
|
||
setError(result.error);
|
||
}
|
||
});
|
||
};
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('div', { onClick: onClose, style: { fontSize: 20, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '\u53D6\u6D88'),
|
||
React.createElement('div', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '\u767B\u5F55'),
|
||
React.createElement('div', { style: { width: 40 }})
|
||
),
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 32px', gap: 20 }},
|
||
error ? React.createElement('div', { style: { fontSize: 14, color: '#FF3B30', textAlign: 'center', padding: '8px 0' }}, error) : null,
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDCDE'),
|
||
React.createElement('input', { type: 'tel', placeholder: '\u8BF7\u8F93\u5165\u624B\u673A\u53F7', value: phone, onChange: function(e) { setPhone(e.target.value.replace(/\D/g, '').slice(0, 11)); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDD12'),
|
||
React.createElement('input', { type: 'password', placeholder: '\u8BF7\u8F93\u5165\u5BC6\u7801', value: password, onChange: function(e) { setPassword(e.target.value); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('button', { onClick: handleLogin, disabled: loading, style: { width: '100%', height: 50, background: loading ? '#FF9F5B' : '#FF6B35', color: '#fff', border: 'none', borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', boxShadow: '0 4px 16px rgba(255,107,53,0.3)' }}, loading ? '\u767B\u5F55\u4E2D...' : '\u767B \u5F55'),
|
||
React.createElement('div', { style: { textAlign: 'center' }},
|
||
React.createElement('span', { onClick: onGoRegister, style: { fontSize: 14, color: '#FF6B35', fontWeight: 500, cursor: 'pointer' }}, '\u6CA1\u6709\u8D26\u53F7\uFF1F\u7ACB\u5373\u6CE8\u518C')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== RegisterPage ===== */
|
||
function RegisterPage({ onRegisterSuccess, onGoLogin, onClose }) {
|
||
var _useState = useState(''), phone = _useState[0], setPhone = _useState[1];
|
||
var _useState2 = useState(''), password = _useState2[0], setPassword = _useState2[1];
|
||
var _useState3 = useState(''), nickname = _useState3[0], setNickname = _useState3[1];
|
||
var _useState4 = useState(''), error = _useState4[0], setError = _useState4[1];
|
||
var _useState5 = useState(false), loading = _useState5[0], setLoading = _useState5[1];
|
||
|
||
var handleRegister = function() {
|
||
setError('');
|
||
if (!nickname || nickname.trim() === '') { setError('请输入昵称'); return; }
|
||
if (!phone || phone.length !== 11 || !phone.startsWith('1')) { setError('请输入正确的手机号'); return; }
|
||
if (!password || password.length < 6) { setError('密码至少6位'); return; }
|
||
setLoading(true);
|
||
window.__authApi.register(phone, password, nickname.trim()).then(function(result) {
|
||
setLoading(false);
|
||
if (result.success) {
|
||
onRegisterSuccess(result.data.token, result.data.user);
|
||
} else {
|
||
setError(result.error);
|
||
}
|
||
});
|
||
};
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('div', { onClick: onClose, style: { fontSize: 20, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '\u53D6\u6D88'),
|
||
React.createElement('div', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '\u6CE8\u518C'),
|
||
React.createElement('div', { style: { width: 40 }})
|
||
),
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 32px', gap: 16 }},
|
||
error ? React.createElement('div', { style: { fontSize: 14, color: '#FF3B30', textAlign: 'center', padding: '8px 0' }}, error) : null,
|
||
React.createElement('div', { style: { textAlign: 'center', marginBottom: 12 }},
|
||
React.createElement('div', { style: { fontSize: 24, fontWeight: 700, color: '#1C1C1E' }}, '\u521B\u5EFA\u4F60\u7684\u8D26\u53F7'),
|
||
React.createElement('div', { style: { fontSize: 14, color: '#8E8E93', marginTop: 6 }}, '\u52A0\u5165\u5BB6\u5EAD\u8BB0\u8D26\uFF0C\u8F7B\u677E\u7BA1\u7406\u8D22\u52A1')
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDC64'),
|
||
React.createElement('input', { type: 'text', placeholder: '\u8BF7\u8F93\u5165\u6635\u79F0', value: nickname, onChange: function(e) { setNickname(e.target.value); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDCDE'),
|
||
React.createElement('input', { type: 'tel', placeholder: '\u8BF7\u8F93\u5165\u624B\u673A\u53F7', value: phone, onChange: function(e) { setPhone(e.target.value.replace(/\D/g, '').slice(0, 11)); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDD12'),
|
||
React.createElement('input', { type: 'password', placeholder: '\u8BF7\u8BBE\u7F6E\u5BC6\u7801\uFF08\u81F3\u5C116\u4F4D\uFF09', value: password, onChange: function(e) { setPassword(e.target.value); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('button', { onClick: handleRegister, disabled: loading, style: { width: '100%', height: 50, background: loading ? '#FF9F5B' : '#FF6B35', color: '#fff', border: 'none', borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', boxShadow: '0 4px 16px rgba(255,107,53,0.3)', marginTop: 4 }}, loading ? '\u6CE8\u518C\u4E2D...' : '\u6CE8 \u518C'),
|
||
React.createElement('div', { style: { textAlign: 'center' }},
|
||
React.createElement('span', { onClick: onGoLogin, style: { fontSize: 14, color: '#FF6B35', fontWeight: 500, cursor: 'pointer' }}, '\u5DF2\u6709\u8D26\u53F7\uFF1F\u7ACB\u5373\u767B\u5F55')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== UserProfilePage ===== */
|
||
function UserProfilePage({ user, onUpdate, onBindEmail, onClose }) {
|
||
var _useState = useState(user.nickname || '');
|
||
var editNickname = _useState[0];
|
||
var setEditNickname = _useState[1];
|
||
|
||
var maskedPhone = user.phone ? user.phone.slice(0, 3) + '****' + user.phone.slice(-4) : '';
|
||
var avatarChar = user.nickname ? user.nickname.charAt(0) : '?';
|
||
var emailDisplay = user.email ? user.email : '未绑定';
|
||
var emailColor = user.email ? '#8E8E93' : '#FF6B35';
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { onClick: onClose, style: { fontSize: 16, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '← 返回'),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '个人信息'),
|
||
React.createElement('span', { style: { width: 60 }})
|
||
),
|
||
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '10px 32px 24px', gap: 10 }},
|
||
React.createElement('div', { style: {
|
||
width: 56, height: 56, borderRadius: '50%',
|
||
background: 'linear-gradient(135deg, #FF6B35, #FF9F5B)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 22, fontWeight: 700, color: '#fff',
|
||
boxShadow: '0 4px 16px rgba(255,107,53,0.25)',
|
||
}}, avatarChar),
|
||
React.createElement('div', { style: { fontSize: 18, fontWeight: 700, color: '#1C1C1E' }}, user.nickname || '用户')
|
||
),
|
||
React.createElement('div', { style: {
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 16, margin: '0 16px', overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06), 0 0 0 1px rgba(255,255,255,0.9) inset',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid rgba(60,60,67,0.06)' }},
|
||
React.createElement('span', { style: { fontSize: 14, color: '#8E8E93', width: 60 }}, '昵称'),
|
||
React.createElement('span', { style: { flex: 1, fontSize: 16, color: '#1C1C1E', textAlign: 'right' }}, user.nickname || ''),
|
||
React.createElement('span', { style: { fontSize: 14, color: '#C7C7CC', marginLeft: 8 }}, '›')
|
||
),
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid rgba(60,60,67,0.06)' }},
|
||
React.createElement('span', { style: { fontSize: 14, color: '#8E8E93', width: 60 }}, '手机号'),
|
||
React.createElement('span', { style: { flex: 1, fontSize: 16, color: '#1C1C1E', textAlign: 'right' }}, maskedPhone)
|
||
),
|
||
React.createElement('div', { onClick: onBindEmail, style: { display: 'flex', alignItems: 'center', padding: '14px 16px', cursor: 'pointer' }},
|
||
React.createElement('span', { style: { fontSize: 14, color: '#8E8E93', width: 60 }}, '邮箱'),
|
||
React.createElement('span', { style: { flex: 1, fontSize: 16, color: emailColor, textAlign: 'right' }}, emailDisplay),
|
||
React.createElement('span', { style: { fontSize: 14, color: '#C7C7CC', marginLeft: 8 }}, '›')
|
||
)
|
||
),
|
||
React.createElement('button', { onClick: onClose, style: {
|
||
width: 'calc(100% - 64px)', height: 50,
|
||
background: '#FF6B35', color: '#fff', border: 'none',
|
||
borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: 'pointer',
|
||
boxShadow: '0 4px 16px rgba(255,107,53,0.3)',
|
||
margin: '24px 32px 0',
|
||
}}, '保 存'),
|
||
React.createElement('div', { style: { fontSize: 12, color: '#8E8E93', textAlign: 'center', marginTop: 12 }},
|
||
'绑定邮箱后可用于找回密码'
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== BindEmailPage ===== */
|
||
function BindEmailPage({ currentEmail, user, onBind, onClose }) {
|
||
var _useState = useState(currentEmail || '');
|
||
var email = _useState[0], setEmail = _useState[1];
|
||
var _useState2 = useState(''), error = _useState2[0], setError = _useState2[1];
|
||
var _useState3 = useState(false), loading = _useState3[0], setLoading = _useState3[1];
|
||
|
||
var handleBind = function() {
|
||
setError('');
|
||
var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
if (!email || !emailRegex.test(email)) {
|
||
setError('请输入正确的邮箱地址');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
window.__authApi.bindEmail(user.id, email).then(function(result) {
|
||
setLoading(false);
|
||
if (result.success) {
|
||
onBind(email);
|
||
} else {
|
||
setError(result.error || '绑定失败,请重试');
|
||
}
|
||
});
|
||
};
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
/* Top Bar */
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { onClick: onClose, style: { fontSize: 16, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '\u2190 \u8FD4\u56DE'),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '\u7ED1\u5B9A\u90AE\u7BB1'),
|
||
React.createElement('span', { style: { width: 60 }})
|
||
),
|
||
/* Content */
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 32px', gap: 20 }},
|
||
/* Error */
|
||
error ? React.createElement('div', { style: { fontSize: 14, color: '#FF3B30', textAlign: 'center', padding: '8px 0' }}, error) : null,
|
||
/* Title */
|
||
React.createElement('div', { style: { textAlign: 'center', fontSize: 14, color: '#8E8E93', fontWeight: 500, marginBottom: -8 }}, '\u7ED1\u5B9A\u90AE\u7BB1'),
|
||
/* Email Input */
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '\uD83D\uDCE7'),
|
||
React.createElement('input', { type: 'email', placeholder: '\u8BF7\u8F93\u5165\u90AE\u7BB1\u5730\u5740', value: email, onChange: function(e) { setEmail(e.target.value); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
/* Confirm Button */
|
||
React.createElement('button', { onClick: handleBind, disabled: loading, style: { width: '100%', height: 50, background: loading ? '#FF9F5B' : '#FF6B35', color: '#fff', border: 'none', borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', boxShadow: '0 4px 16px rgba(255,107,53,0.3)' }}, loading ? '\u7ED1\u5B9A\u4E2D...' : '\u786E \u8BA4 \u7ED1 \u5B9A'),
|
||
/* Hint */
|
||
React.createElement('div', { style: { textAlign: 'center', fontSize: 12, color: '#8E8E93', marginTop: -8 }}, '\u7ED1\u5B9A\u540E\u53EF\u7528\u4E8E\u627E\u56DE\u5BC6\u7801')
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== CreateFamilyPage ===== */
|
||
function CreateFamilyPage({ user, onCreated, onClose }) {
|
||
var _useState = useState('');
|
||
var familyName = _useState[0], setFamilyName = _useState[1];
|
||
var _useState2 = useState(''), error = _useState2[0], setError = _useState2[1];
|
||
var _useState3 = useState(false), loading = _useState3[0], setLoading = _useState3[1];
|
||
var _useState4 = useState(null), createdFamily = _useState4[0], setCreatedFamily = _useState4[1];
|
||
|
||
var handleCreate = function() {
|
||
setError('');
|
||
if (!familyName || !familyName.trim()) {
|
||
setError('请输入家庭名称');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
window.__authApi.createFamily(user.id, familyName.trim()).then(function(result) {
|
||
setLoading(false);
|
||
if (result.success) {
|
||
setCreatedFamily(result.data.family);
|
||
} else {
|
||
setError(result.error || '创建失败,请重试');
|
||
}
|
||
});
|
||
};
|
||
|
||
// Show invite code after creation
|
||
if (createdFamily) {
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { style: { width: 40 }}),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '创建家庭'),
|
||
React.createElement('span', { style: { width: 40 }})
|
||
),
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '0 32px', gap: 24 }},
|
||
React.createElement('div', { style: { fontSize: 48 }}, '🎉'),
|
||
React.createElement('div', { style: { fontSize: 22, fontWeight: 700, color: '#1C1C1E' }}, '家庭创建成功!'),
|
||
React.createElement('div', { style: { fontSize: 18, fontWeight: 600, color: '#FF6B35' }}, createdFamily.name),
|
||
React.createElement('div', { style: { textAlign: 'center', marginTop: 8 }},
|
||
React.createElement('div', { style: { fontSize: 13, color: '#8E8E93', marginBottom: 8 }}, '邀请码'),
|
||
React.createElement('div', { style: {
|
||
fontSize: 28, fontWeight: 700, color: '#1C1C1E', letterSpacing: '6px',
|
||
background: '#E8E8ED', borderRadius: 12, padding: '12px 24px',
|
||
border: '2px dashed #C7C7CC',
|
||
fontFamily: 'monospace',
|
||
textAlign: 'center',
|
||
}}, createdFamily.inviteCode),
|
||
React.createElement('div', { style: { fontSize: 12, color: '#8E8E93', marginTop: 10, lineHeight: 1.5 }},
|
||
'将此邀请码分享给家人,他们可以在设置页加入你的家庭'
|
||
)
|
||
),
|
||
React.createElement('button', { onClick: function() { onCreated(createdFamily); }, style: {
|
||
width: '100%', height: 50,
|
||
background: '#FF6B35', color: '#fff', border: 'none',
|
||
borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: 'pointer',
|
||
boxShadow: '0 4px 16px rgba(255,107,53,0.3)',
|
||
marginTop: 8,
|
||
}}, '完 成')
|
||
)
|
||
);
|
||
}
|
||
|
||
// Input form
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { onClick: onClose, style: { fontSize: 16, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '← 返回'),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '创建家庭'),
|
||
React.createElement('span', { style: { width: 40 }})
|
||
),
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 32px', gap: 20 }},
|
||
error ? React.createElement('div', { style: { fontSize: 14, color: '#FF3B30', textAlign: 'center', padding: '8px 0' }}, error) : null,
|
||
React.createElement('div', { style: { textAlign: 'center', marginBottom: 4 }},
|
||
React.createElement('div', { style: { fontSize: 24, fontWeight: 700, color: '#1C1C1E' }}, '创建一个家庭'),
|
||
React.createElement('div', { style: { fontSize: 14, color: '#8E8E93', marginTop: 6 }}, '创建后邀请家人加入,共同记账')
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '🏠'),
|
||
React.createElement('input', { type: 'text', placeholder: '给家庭起个名字吧', value: familyName, onChange: function(e) { setFamilyName(e.target.value); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 16, background: 'transparent', color: '#000', fontFamily: 'inherit' }})
|
||
),
|
||
React.createElement('button', { onClick: handleCreate, disabled: loading, style: { width: '100%', height: 50, background: loading ? '#FF9F5B' : '#FF6B35', color: '#fff', border: 'none', borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', boxShadow: '0 4px 16px rgba(255,107,53,0.3)' }}, loading ? '创建中...' : '创建家庭')
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== JoinFamilyPage ===== */
|
||
function JoinFamilyPage({ user, onJoined, onClose }) {
|
||
var _useState = useState('');
|
||
var code = _useState[0], setCode = _useState[1];
|
||
var _useState2 = useState(''), error = _useState2[0], setError = _useState2[1];
|
||
var _useState3 = useState(false), loading = _useState3[0], setLoading = _useState3[1];
|
||
|
||
var handleJoin = function() {
|
||
setError('');
|
||
if (!code || code.length !== 6) {
|
||
setError('请输入6位邀请码');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
window.__authApi.joinFamily(user.id, code.toUpperCase()).then(function(result) {
|
||
setLoading(false);
|
||
if (result.success) {
|
||
onJoined(result.data.family);
|
||
} else {
|
||
setError(result.error || '加入失败,请重试');
|
||
}
|
||
});
|
||
};
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { onClick: onClose, style: { fontSize: 16, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '← 返回'),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '加入家庭'),
|
||
React.createElement('span', { style: { width: 40 }})
|
||
),
|
||
React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '0 32px', gap: 20 }},
|
||
error ? React.createElement('div', { style: { fontSize: 14, color: '#FF3B30', textAlign: 'center', padding: '8px 0' }}, error) : null,
|
||
React.createElement('div', { style: { textAlign: 'center', marginBottom: 4 }},
|
||
React.createElement('div', { style: { fontSize: 24, fontWeight: 700, color: '#1C1C1E' }}, '加入家庭'),
|
||
React.createElement('div', { style: { fontSize: 14, color: '#8E8E93', marginTop: 6 }}, '输入家人分享给你的6位邀请码')
|
||
),
|
||
React.createElement('div', { style: { background: 'rgba(255,255,255,0.8)', backdropFilter: 'blur(30px)', borderRadius: 14, padding: '0 16px', height: 48, display: 'flex', alignItems: 'center', gap: 8, border: '1px solid rgba(255,255,255,0.9)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }},
|
||
React.createElement('span', { style: { fontSize: 16, flexShrink: 0 }}, '🔑'),
|
||
React.createElement('input', { type: 'text', placeholder: '请输入邀请码', value: code, onChange: function(e) { setCode(e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 6)); setError(''); }, style: { border: 'none', outline: 'none', flex: 1, fontSize: 18, background: 'transparent', color: '#000', fontFamily: 'monospace', letterSpacing: '4px', textTransform: 'uppercase' }})
|
||
),
|
||
React.createElement('button', { onClick: handleJoin, disabled: loading || code.length !== 6, style: { width: '100%', height: 50, background: loading || code.length !== 6 ? '#FF9F5B' : '#FF6B35', color: '#fff', border: 'none', borderRadius: 25, fontSize: 17, fontWeight: 600, cursor: loading || code.length !== 6 ? 'not-allowed' : 'pointer', boxShadow: '0 4px 16px rgba(255,107,53,0.3)' }}, loading ? '加入中...' : '加入家庭')
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== FamilyManagementPage ===== */
|
||
function FamilyManagementPage({ user, onFamilyChange, onClose }) {
|
||
var _useState = useState([]);
|
||
var families = _useState[0], setFamilies = _useState[1];
|
||
var _useState2 = useState(true), loading = _useState2[0], setLoading = _useState2[1];
|
||
var _useState3 = useState('main'), page = _useState3[0], setPage = _useState3[1];
|
||
var _useState4 = useState(''), msg = _useState4[0], setMsg = _useState4[1];
|
||
var _useState5 = useState(''), msgType = _useState5[0], setMsgType = _useState5[1];
|
||
|
||
React.useEffect(function() {
|
||
var result = window.__authApi.getUserFamilies(user.id);
|
||
if (result.success) {
|
||
setFamilies(result.data.families);
|
||
}
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
var handleSwitchFamily = function(familyId) {
|
||
var result = window.__authApi.switchActiveFamily(user.id, familyId);
|
||
if (result.success) {
|
||
var updatedUser = Object.assign({}, user, { activeFamilyId: familyId });
|
||
onFamilyChange(updatedUser);
|
||
setMsg('已切换到当前家庭');
|
||
setMsgType('success');
|
||
setTimeout(function() { setMsg(''); }, 2000);
|
||
}
|
||
};
|
||
|
||
var handleLeaveFamily = function(familyId, e) {
|
||
e.stopPropagation();
|
||
var family = families.find(function(f) { return f.id === familyId; });
|
||
if (family && family.createdBy === user.id) {
|
||
setMsg('创建者不能退出家庭');
|
||
setMsgType('error');
|
||
setTimeout(function() { setMsg(''); }, 3000);
|
||
return;
|
||
}
|
||
if (!confirm('确定退出该家庭吗?')) return;
|
||
window.__authApi.leaveFamily(user.id, familyId).then(function(result) {
|
||
if (result.success) {
|
||
// Reload families
|
||
var r = window.__authApi.getUserFamilies(user.id);
|
||
if (r.success) {
|
||
setFamilies(r.data.families);
|
||
// If user had active family and left it, update
|
||
if (user.activeFamilyId === familyId) {
|
||
var nextFamily = r.data.families.length > 0 ? r.data.families[0] : null;
|
||
var newUserId = nextFamily ? nextFamily.id : null;
|
||
window.__authApi.switchActiveFamily(user.id, newUserId);
|
||
var updatedUser = Object.assign({}, user, { activeFamilyId: newUserId });
|
||
onFamilyChange(updatedUser);
|
||
} else {
|
||
onFamilyChange(user);
|
||
}
|
||
}
|
||
setMsg('已退出家庭');
|
||
setMsgType('success');
|
||
setTimeout(function() { setMsg(''); }, 2000);
|
||
} else {
|
||
setMsg(result.error || '退出失败');
|
||
setMsgType('error');
|
||
setTimeout(function() { setMsg(''); }, 3000);
|
||
}
|
||
});
|
||
};
|
||
|
||
var handleCreated = function(family) {
|
||
var r = window.__authApi.getUserFamilies(user.id);
|
||
if (r.success) {
|
||
setFamilies(r.data.families);
|
||
var updatedUser = Object.assign({}, user, { activeFamilyId: family.id, familyIds: (user.familyIds || []).concat([family.id]) });
|
||
onFamilyChange(updatedUser);
|
||
}
|
||
setPage('main');
|
||
};
|
||
|
||
var handleJoined = function(family) {
|
||
var r = window.__authApi.getUserFamilies(user.id);
|
||
if (r.success) {
|
||
setFamilies(r.data.families);
|
||
}
|
||
setPage('main');
|
||
};
|
||
|
||
// Sub-pages
|
||
if (page === 'create') {
|
||
return React.createElement(CreateFamilyPage, { user: user, onCreated: handleCreated, onClose: function() { setPage('main'); } });
|
||
}
|
||
if (page === 'join') {
|
||
return React.createElement(JoinFamilyPage, { user: user, onJoined: handleJoined, onClose: function() { setPage('main'); } });
|
||
}
|
||
|
||
// Main page
|
||
var activeFamily = families.find(function(f) { return f.id === user.activeFamilyId; });
|
||
|
||
return React.createElement('div', { style: {
|
||
position: 'absolute', top: -54, left: 0, right: 0, bottom: 0,
|
||
background: '#F2F2F7', zIndex: 100,
|
||
display: 'flex', flexDirection: 'column',
|
||
animation: 'fadeIn 0.2s ease',
|
||
}},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '54px 16px 16px' }},
|
||
React.createElement('span', { onClick: onClose, style: { fontSize: 16, color: '#007AFF', cursor: 'pointer', fontWeight: 500 }}, '← 返回'),
|
||
React.createElement('span', { style: { fontSize: 17, fontWeight: 600, color: '#000' }}, '我的家庭'),
|
||
React.createElement('span', { style: { width: 40 }})
|
||
),
|
||
msg ? React.createElement('div', { style: { fontSize: 13, color: msgType === 'success' ? '#34C759' : '#FF3B30', textAlign: 'center', padding: '6px 16px', background: msgType === 'success' ? 'rgba(52,199,89,0.1)' : 'rgba(255,59,48,0.1)', margin: '0 16px', borderRadius: 8 }}, msg) : null,
|
||
React.createElement('div', { style: { flex: 1, overflowY: 'auto', padding: '0 16px 24px' }},
|
||
// Active family card
|
||
activeFamily ? React.createElement('div', { style: {
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 16, padding: '20px', marginBottom: 16, marginTop: 8,
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06)',
|
||
}},
|
||
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.5px' }}, '当前家庭'),
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12 }},
|
||
React.createElement('div', { style: { width: 44, height: 44, borderRadius: 12, background: 'linear-gradient(135deg, #FF6B35, #FF9F5B)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}, '🏠'),
|
||
React.createElement('div', { style: { flex: 1 }},
|
||
React.createElement('div', { style: { fontSize: 17, fontWeight: 700, color: '#1C1C1E' }}, activeFamily.name),
|
||
React.createElement('div', { style: { fontSize: 12, color: '#8E8E93', marginTop: 2 }},
|
||
(activeFamily.members ? activeFamily.members.length : 0) + ' 位成员 · ' + (activeFamily.members && activeFamily.members.some(function(m) { return m.userId === user.id && m.role === 'owner'; }) ? '创建者' : '成员')
|
||
)
|
||
),
|
||
React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: '#FF6B35', background: 'rgba(255,107,53,0.1)', padding: '4px 10px', borderRadius: 10 }}, '当前')
|
||
)
|
||
) : React.createElement('div', { style: { textAlign: 'center', padding: '32px 16px', fontSize: 14, color: '#8E8E93' }}, '还没有加入任何家庭'),
|
||
|
||
// My Families section
|
||
families.length > 0 ? React.createElement('div', { style: { marginBottom: 16 }},
|
||
React.createElement('div', { style: { fontSize: 12, fontWeight: 600, color: '#8E8E93', marginBottom: 8, paddingLeft: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}, '我的所有家庭'),
|
||
React.createElement('div', { style: {
|
||
background: 'rgba(255,255,255,0.65)', backdropFilter: 'blur(30px)',
|
||
borderRadius: 14, overflow: 'hidden',
|
||
border: '1px solid rgba(255,255,255,0.8)',
|
||
boxShadow: '0 8px 32px rgba(255,107,53,0.06)',
|
||
}},
|
||
families.map(function(family, i) {
|
||
var isActive = family.id === user.activeFamilyId;
|
||
var isOwner = family.members && family.members.some(function(m) { return m.userId === user.id && m.role === 'owner'; });
|
||
return React.createElement('div', { key: family.id, onClick: function() { if (!isActive) handleSwitchFamily(family.id); }, style: {
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: '14px 16px', cursor: 'pointer',
|
||
borderBottom: i < families.length - 1 ? '1px solid rgba(60,60,67,0.06)' : 'none',
|
||
background: isActive ? 'rgba(255,107,53,0.04)' : 'transparent',
|
||
}},
|
||
React.createElement('div', { style: { width: 36, height: 36, borderRadius: 10, background: isActive ? '#FF6B35' : 'rgba(142,142,147,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, color: isActive ? '#fff' : '#8E8E93', flexShrink: 0 }}, '🏠'),
|
||
React.createElement('div', { style: { flex: 1 }},
|
||
React.createElement('div', { style: { fontSize: 15, fontWeight: 600, color: '#1C1C1E' }}, family.name),
|
||
React.createElement('div', { style: { fontSize: 11, color: '#8E8E93', marginTop: 1 }},
|
||
(family.members ? family.members.length : 0) + ' 位成员 · ' + (isOwner ? '创建者' : '成员')
|
||
)
|
||
),
|
||
isActive ? React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: '#FF6B35', background: 'rgba(255,107,53,0.1)', padding: '4px 8px', borderRadius: 8 }}, '当前') : null,
|
||
!isOwner && !isActive ? React.createElement('span', { onClick: function(e) { handleLeaveFamily(family.id, e); }, style: { fontSize: 12, color: '#FF3B30', padding: '4px 8px', cursor: 'pointer' }}, '退出') : null
|
||
);
|
||
})
|
||
)
|
||
) : null,
|
||
|
||
// Action buttons
|
||
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, marginTop: 8 }},
|
||
React.createElement('button', { onClick: function() { setPage('create'); }, style: {
|
||
width: '100%', height: 48,
|
||
background: '#FF6B35', color: '#fff', border: 'none',
|
||
borderRadius: 14, fontSize: 15, fontWeight: 600, cursor: 'pointer',
|
||
boxShadow: '0 4px 12px rgba(255,107,53,0.2)',
|
||
}}, '➕ 创建家庭'),
|
||
React.createElement('button', { onClick: function() { setPage('join'); }, style: {
|
||
width: '100%', height: 48,
|
||
background: 'rgba(255,255,255,0.65)', color: '#FF6B35',
|
||
border: '1px solid rgba(255,107,53,0.2)',
|
||
borderRadius: 14, fontSize: 15, fontWeight: 600, cursor: 'pointer',
|
||
backdropFilter: 'blur(30px)',
|
||
}}, '🔑 加入家庭')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
/* ===== App ===== */
|
||
function App() {
|
||
const [activeTab, setActiveTab] = useState('personal');
|
||
const [transitioning, setTransitioning] = useState(false);
|
||
const [displayTab, setDisplayTab] = useState('personal');
|
||
const [fadeState, setFadeState] = useState('visible');
|
||
const [screen, setScreen] = useState('home');
|
||
const [navTab, setNavTab] = useState('home');
|
||
const [previousTab, setPreviousTab] = useState('home');
|
||
const [showActionSheet, setShowActionSheet] = useState(false);
|
||
const [ocrData, setOcrData] = useState(null);
|
||
const [user, setUser] = useState(function() {
|
||
try {
|
||
var token = localStorage.getItem('__auth_token');
|
||
if (token && window.__authApi) {
|
||
var result = window.__authApi.getUserByToken(token);
|
||
if (result) return result;
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
});
|
||
|
||
const switchTab = useCallback((tab) => {
|
||
if (tab === activeTab || transitioning) return;
|
||
setTransitioning(true);
|
||
setFadeState('exiting');
|
||
setTimeout(() => {
|
||
setActiveTab(tab);
|
||
setDisplayTab(tab);
|
||
setFadeState('entering');
|
||
requestAnimationFrame(() => {
|
||
setFadeState('visible');
|
||
setTransitioning(false);
|
||
});
|
||
}, 180);
|
||
}, [activeTab, transitioning]);
|
||
|
||
useEffect(() => {
|
||
const handler = (e) => setNavTab(e.detail);
|
||
window.addEventListener('navTo', handler);
|
||
return () => window.removeEventListener('navTo', handler);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
window.dispatchEvent(new CustomEvent('navUpdate', { detail: navTab }));
|
||
}, [navTab]);
|
||
|
||
const handleCloseAddBill = () => {
|
||
setScreen('home');
|
||
setNavTab(previousTab);
|
||
window.__ocrPendingData = null;
|
||
window.dispatchEvent(new CustomEvent('refreshBills'));
|
||
};
|
||
|
||
const handleLoginSuccess = (token, userData) => {
|
||
localStorage.setItem('__auth_token', token);
|
||
setUser(userData);
|
||
setScreen('home');
|
||
setNavTab('profile');
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
if (confirm('确定要退出登录吗?')) {
|
||
window.__authApi.logout();
|
||
setUser(null);
|
||
}
|
||
};
|
||
|
||
const handleShowLogin = () => {
|
||
setScreen('login');
|
||
};
|
||
|
||
const handleGoRegister = () => {
|
||
setScreen('register');
|
||
};
|
||
|
||
const handleGoLogin = () => {
|
||
setScreen('login');
|
||
};
|
||
|
||
const handleShowUserInfo = () => {
|
||
setScreen('userProfile');
|
||
};
|
||
|
||
const handleBindEmail = (email) => {
|
||
if (user) {
|
||
var updatedUser = Object.assign({}, user, { email: email });
|
||
setUser(updatedUser);
|
||
window.__authApi.bindEmail(user.id, email);
|
||
}
|
||
};
|
||
|
||
const handleShowFamilyManagement = () => {
|
||
setScreen('familyManagement');
|
||
};
|
||
|
||
const handleFamilyChange = (updatedUser) => {
|
||
setUser(updatedUser);
|
||
if (updatedUser) {
|
||
window.dispatchEvent(new CustomEvent('familyModeUpdate'));
|
||
}
|
||
};
|
||
|
||
const handleFamilyCreated = (family) => {
|
||
var token = localStorage.getItem('__auth_token');
|
||
if (token && window.__authApi) {
|
||
var updatedUser = window.__authApi.getUserByToken(token);
|
||
if (updatedUser) {
|
||
setUser(updatedUser);
|
||
window.dispatchEvent(new CustomEvent('familyModeUpdate'));
|
||
}
|
||
}
|
||
setScreen('home');
|
||
};
|
||
|
||
const handleShowActionSheet = () => {
|
||
setShowActionSheet(true);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const handler = () => handleShowActionSheet();
|
||
window.addEventListener('showActionSheet', handler);
|
||
return () => window.removeEventListener('showActionSheet', handler);
|
||
}, [handleShowActionSheet]);
|
||
|
||
const handleOpenOCR = () => {
|
||
setShowActionSheet(false);
|
||
setScreen('ocrScan');
|
||
};
|
||
|
||
const handleOCRBack = () => {
|
||
setScreen('home');
|
||
setOcrData(null);
|
||
};
|
||
|
||
const handleOCRDataReady = (data) => {
|
||
setOcrData(data);
|
||
setScreen('ocrResult');
|
||
};
|
||
|
||
const handleOCRConfirm = () => {
|
||
const data = ocrData;
|
||
setOcrData(null);
|
||
setScreen('addBill');
|
||
window.__ocrPendingData = data;
|
||
};
|
||
|
||
if (screen === 'ocrScan') {
|
||
return (
|
||
<OCRScanPage
|
||
onClose={handleOCRBack}
|
||
onCaptured={handleOCRDataReady}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'ocrResult') {
|
||
return (
|
||
<OCRResultPreview
|
||
data={ocrData}
|
||
onConfirm={handleOCRConfirm}
|
||
onRetake={handleOCRBack}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'addBill') {
|
||
return <AddBillScreen onClose={handleCloseAddBill} ocrData={window.__ocrPendingData || null} />;
|
||
}
|
||
|
||
if (screen === 'login') {
|
||
return (
|
||
<LoginPage
|
||
onLoginSuccess={handleLoginSuccess}
|
||
onGoRegister={handleGoRegister}
|
||
onClose={function() { setScreen('home'); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'register') {
|
||
return (
|
||
<RegisterPage
|
||
onRegisterSuccess={handleLoginSuccess}
|
||
onGoLogin={handleGoLogin}
|
||
onClose={function() { setScreen('home'); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'userProfile') {
|
||
return (
|
||
<UserProfilePage
|
||
user={user}
|
||
onUpdate={setUser}
|
||
onBindEmail={function() { setScreen('bindEmail'); }}
|
||
onClose={function() { setScreen('home'); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'bindEmail') {
|
||
return (
|
||
<BindEmailPage
|
||
currentEmail={user ? user.email : null}
|
||
user={user}
|
||
onBind={function(email) {
|
||
handleBindEmail(email);
|
||
setScreen('userProfile');
|
||
}}
|
||
onClose={function() { setScreen('userProfile'); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (screen === 'familyManagement') {
|
||
return (
|
||
<FamilyManagementPage
|
||
user={user}
|
||
onFamilyChange={handleFamilyChange}
|
||
onClose={function() { setScreen('home'); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const contentOpacity = fadeState === 'visible' ? 1 : 0;
|
||
const contentTransform = fadeState === 'visible' ? 'translateY(0)' : 'translateY(8px)';
|
||
|
||
const renderContent = () => {
|
||
switch (navTab) {
|
||
case 'stats':
|
||
return <StatisticsContent user={user} activeTab={activeTab} />;
|
||
case 'profile':
|
||
return (
|
||
<ProfileContent
|
||
user={user}
|
||
onLogin={handleShowLogin}
|
||
onLogout={handleLogout}
|
||
onUserInfo={handleShowUserInfo}
|
||
onFamilyManagement={handleShowFamilyManagement}
|
||
/>
|
||
);
|
||
default:
|
||
return (
|
||
<>
|
||
<OrangeHeader user={user} />
|
||
<TabSwitcher activeTab={activeTab} onSwitch={switchTab} animating={transitioning} />
|
||
<div style={{
|
||
transition: 'opacity 0.25s ease, transform 0.25s ease',
|
||
opacity: contentOpacity,
|
||
transform: contentTransform,
|
||
}}>
|
||
{displayTab === 'personal' ? <PersonalContent user={user} /> : <FamilyContent user={user} />}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{
|
||
minHeight: '100%', display: 'flex', flexDirection: 'column',
|
||
background: 'linear-gradient(180deg, #F5F7FA 0%, #EDF2F9 100%)',
|
||
overflow: 'auto',
|
||
}}>
|
||
<ActionSheet
|
||
show={showActionSheet}
|
||
onManual={() => { setShowActionSheet(false); setScreen('addBill'); }}
|
||
onScan={handleOpenOCR}
|
||
onCancel={() => setShowActionSheet(false)}
|
||
/>
|
||
<div style={{ flex: 1 }}>
|
||
{renderContent()}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ===== Main ===== */
|
||
function Main() {
|
||
return (
|
||
<div className="phone-wrapper">
|
||
<IosFrame
|
||
width={370}
|
||
height={780}
|
||
time="10:28"
|
||
battery={78}
|
||
darkMode={false}
|
||
bottomOverlay={<BottomTabBar />}
|
||
fabOverlay={
|
||
<div style={{
|
||
bottom: 100,
|
||
right: 16,
|
||
position: 'absolute',
|
||
}}>
|
||
<FAB onClick={() => window.dispatchEvent(new CustomEvent('showActionSheet'))} />
|
||
</div>
|
||
}
|
||
>
|
||
<App />
|
||
</IosFrame>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<Main />);
|
||
</script>
|
||
</body>
|
||
</html>
|