import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSalaryCalculator, useSalaryRecords, useSalaryToolLinks } from '../../src/hooks'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; import { colors, headerGradientShort } from '../../src/theme'; import { CITIES, DEFAULT_SOCIAL_PROFILE, SOCIAL_SECURITY_CITY_PROFILES, } from '../../src/constants'; import type { SalaryCalcInput, SalaryCalcRecord, SalaryToolLink } from '../../src/types'; import { formatCurrency } from '../../src/utils'; /** 记录时间戳 → 简短可读文本(月-日 时:分) */ function formatRecordTime(ts: number): string { const d = new Date(ts); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack, onHistory }: { onBack: () => void; onHistory: () => void }) { return ( 薪资计算器 历史记录 ); } /* ===================== 子组件:数字输入行 ===================== */ function NumberInput({ label, value, suffix, onChange, placeholder, }: { label: string; value: number; suffix: string; onChange: (v: number) => void; placeholder?: string; }) { const [text, setText] = useState(String(value)); return ( {label} { setText(t); const n = parseFloat(t); if (!Number.isNaN(n)) onChange(n); }} keyboardType="numeric" placeholder={placeholder} placeholderTextColor={colors.textHint} /> {suffix} ); } function SelectRow({ label, value, onPress, }: { label: string; value: string; onPress: () => void; }) { return ( {label} {value} ); } /* ===================== 子组件:五险一金明细 ===================== */ function InsuranceCard({ result }: { result: import('../../src/types').SalaryCalcResult }) { return ( 五险一金明细 个人缴纳 缴纳比例 {result.insuranceItems.map((item) => ( {item.label} {formatCurrency(item.amount)} {(item.rate * 100).toFixed(1)}% ))} 合计个人缴纳 {formatCurrency(result.monthlySocial)} ); } /* ===================== 子组件:个人所得税明细 ===================== */ function TaxCard({ result }: { result: import('../../src/types').SalaryCalcResult }) { const taxItems = result.taxItems; const monthlyTax = taxItems.find((t) => t.label === '月度个税')?.amount ?? 0; const annualTax = taxItems.find((t) => t.label === '年度个税')?.amount ?? 0; const taxable = taxItems.find((t) => t.label === '应纳税所得额')?.amount ?? 0; const rate = taxItems.find((t) => t.label === '适用税率')?.amount ?? 0; return ( 个人所得税明细 应纳税所得额 {formatCurrency(taxable)} 适用税率 {(rate * 100).toFixed(0)}% 月度个税 {formatCurrency(monthlyTax)}/月 年度个税 {formatCurrency(annualTax)}/年 ); } /* ===================== 子组件:薪资结构 ===================== */ function StructureCard({ result }: { result: import('../../src/types').SalaryCalcResult }) { const items = result.structure.filter((s) => s.label !== '税前工资'); const themeColors: Record = { primary: colors.primary, info: colors.info, action: colors.action, success: colors.success, }; return ( 薪资结构 {items.map((item, index) => { const start = items.slice(0, index).reduce((sum, i) => sum + i.percent, 0); return ( ); })} {items.map((item) => ( {item.label} {formatCurrency(item.value / 1000, 0)}K {item.percent}% ))} ); } /* ===================== 子组件:更多工具 ===================== */ function ToolLink({ tool }: { tool: SalaryToolLink }) { const router = useRouter(); // 目标路由由接口层给出;没有 route 的只做展示,不带箭头 const route = tool.route; const body = ( <> {tool.title} {tool.desc} {route ? : null} ); if (!route) { return {body}; } return ( router.push(route as never)}> {body} ); } /* ===================== 页面 ===================== */ export default function SalaryCalculatorScreen() { const router = useRouter(); const { inputs, setInputs, result } = useSalaryCalculator(); const records = useSalaryRecords(); const { tools } = useSalaryToolLinks(); const [cityPickerVisible, setCityPickerVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false); const [saveSheetVisible, setSaveSheetVisible] = useState(false); const [saveNote, setSaveNote] = useState(''); const [saving, setSaving] = useState(false); /** 保存当前计算结果到本地记录 */ const handleSave = async () => { if (saving) return; setSaving(true); try { await records.save(inputs, result, saveNote); setSaveSheetVisible(false); setSaveNote(''); Alert.alert('已保存', '当前薪资计算已保存到本地记录'); } catch { Alert.alert('保存失败', '请稍后重试'); } finally { setSaving(false); } }; /** 从历史记录载入一笔参数到计算器 */ const handleLoadRecord = (record: SalaryCalcRecord) => { setInputs(record.input); setHistoryVisible(false); }; const cityProfile = SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; const update = (key: K, value: SalaryCalcInput[K]) => { setInputs({ ...inputs, [key]: value }); }; /** 将社保基数 clamp 到当前城市上下限内 */ const clampSocialBase = (v: number) => { const profile = SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, v)); }; /** 选城市:切换城市并把社保基数 clamp 到新城市上下限 */ const handleSelectCity = (city: string) => { const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; const clamped = Math.min( profile.socialBaseUpper, Math.max(profile.socialBaseLower, inputs.socialBase), ); setInputs({ ...inputs, city, socialBase: clamped }); setCityPickerVisible(false); }; return (
router.back()} onHistory={() => setHistoryVisible(true)} /> {/* 输入卡片 */} 收入参数 update('monthlyGross', Math.max(0, v))} /> setCityPickerVisible(true)} /> update('socialBase', clampSocialBase(Math.max(0, v)))} /> 基数范围 ¥{formatCurrency(cityProfile.socialBaseLower, 0)} – ¥ {formatCurrency(cityProfile.socialBaseUpper, 0)}({inputs.city}) update('providentFundRate', Math.max(0, Math.min(100, v)) / 100)} /> update('specialDeduction', Math.max(0, v))} /> {/* 结果卡片 */} 税后月工资 {formatCurrency(result.monthlyNet)} /月 {/* 五险一金 */} {/* 个税 */} {/* 薪资结构 */} {/* 更多工具:清单来自接口层,均为项目内真实页面 */} 更多工具 {tools.map((tool) => ( ))} setSaveSheetVisible(true)}> 保存计算记录 setCityPickerVisible(false)} onSelect={handleSelectCity} /> setSaveSheetVisible(false)} saving={saving} /> setHistoryVisible(false)} /> ); } /* ===================== 子组件:城市选择底部弹层 ===================== */ function CityPicker({ visible, current, onClose, onSelect, }: { visible: boolean; current: string; onClose: () => void; onSelect: (city: string) => void; }) { return ( 选择所在城市 {CITIES.map((city) => { const active = city === current; return ( onSelect(city)} > {city} {active && } ); })} ); } /* ===================== 子组件:保存记录底部弹层 ===================== */ function SaveSheet({ visible, note, onNoteChange, onSave, onClose, saving, }: { visible: boolean; note: string; onNoteChange: (v: string) => void; onSave: () => void; onClose: () => void; saving: boolean; }) { return ( 保存计算记录 备注(选填,便于区分不同方案) 取消 {saving ? '保存中…' : '保存'} ); } /* ===================== 子组件:历史记录底部弹层 ===================== */ function HistoryModal({ visible, records, loading, error, onRetry, onSelect, onDelete, onClose, }: { visible: boolean; records: SalaryCalcRecord[]; loading: boolean; error: Error | null; onRetry: () => void; onSelect: (record: SalaryCalcRecord) => void; onDelete: (id: string) => void; onClose: () => void; }) { return ( 计算历史 {loading ? ( ) : error ? ( ) : records.length === 0 ? ( ) : ( {records.map((record) => ( onSelect(record)}> {record.note || `${record.input.city} · 税前${formatCurrency(record.input.monthlyGross, 0)}`} 到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatRecordTime(record.createdAt)} Alert.alert('删除记录', '确定删除这条计算记录?', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: () => onDelete(record.id) }, ]) } hitSlop={8} > ))} )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.pageBg }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 12, backgroundColor: colors.white, }, headerBtn: { width: 60, justifyContent: 'center' }, headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, headerAction: { fontSize: 14, color: colors.primary, textAlign: 'right' }, body: { padding: 16, paddingBottom: 32 }, card: { backgroundColor: colors.white, borderRadius: 14, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: colors.borderLight, }, cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 }, inputRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight, }, inputLabel: { fontSize: 14, color: colors.textPrimary }, inputBox: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.pageBg, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 8, }, input: { minWidth: 80, fontSize: 15, fontWeight: '700', color: colors.textPrimary, textAlign: 'right', }, inputSuffix: { fontSize: 13, color: colors.textSecondary, marginLeft: 4 }, selectBox: { flexDirection: 'row', alignItems: 'center' }, selectValue: { fontSize: 15, fontWeight: '600', color: colors.textPrimary, marginRight: 4 }, resultCard: { borderRadius: 16, padding: 24, alignItems: 'center', marginBottom: 12, }, resultLabel: { fontSize: 14, color: colors.textInverse, opacity: 0.9 }, resultValue: { fontSize: 42, fontWeight: '800', color: colors.textInverse, marginTop: 8 }, resultUnit: { fontSize: 14, color: colors.textInverse, opacity: 0.9, marginTop: 2 }, cardSubTitleRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 10, }, cardSubTitle: { fontSize: 12, color: colors.textHint }, insuranceRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, }, insuranceLabel: { flex: 1, fontSize: 14, color: colors.textPrimary }, insuranceAmount: { width: 80, fontSize: 14, fontWeight: '700', color: colors.textPrimary, textAlign: 'right' }, insuranceRate: { width: 60, fontSize: 13, color: colors.textSecondary, textAlign: 'right' }, divider: { height: 1, backgroundColor: colors.borderLight, marginVertical: 10 }, totalRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, totalLabel: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, totalAmount: { fontSize: 18, fontWeight: '800', color: colors.primary }, taxGrid: { flexDirection: 'row', gap: 12, marginBottom: 10 }, taxItem: { flex: 1, backgroundColor: colors.pageBg, borderRadius: 10, padding: 12, }, taxItemLabel: { fontSize: 12, color: colors.textSecondary, marginBottom: 6 }, taxItemValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary }, taxRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, }, taxRowLabel: { fontSize: 14, color: colors.textPrimary }, taxRowValue: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, structureChart: { flexDirection: 'row', alignItems: 'center' }, pie: { width: 100, height: 100, borderRadius: 50, backgroundColor: colors.borderLight, overflow: 'hidden', }, pieSlice: { position: 'absolute', width: 100, height: 100, borderRadius: 50, }, structureLegend: { flex: 1, marginLeft: 20 }, legendRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 }, legendDot: { width: 10, height: 10, borderRadius: 5 }, legendLabel: { flex: 1, fontSize: 13, color: colors.textPrimary, marginLeft: 8 }, legendValue: { fontSize: 13, fontWeight: '700', color: colors.textPrimary, marginRight: 8 }, legendPercent: { width: 40, fontSize: 12, color: colors.textSecondary, textAlign: 'right' }, toolRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight, }, toolIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', }, toolBody: { flex: 1, marginLeft: 12 }, toolTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary }, toolDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2 }, saveBtn: { backgroundColor: colors.primaryBtn, borderRadius: 24, paddingVertical: 14, alignItems: 'center', marginTop: 8, }, saveBtnText: { fontSize: 16, fontWeight: '700', color: colors.white }, baseHint: { fontSize: 12, color: colors.textHint, marginTop: 6, paddingHorizontal: 2, }, sheetBackdrop: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end', }, sheet: { backgroundColor: colors.white, borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingTop: 10, paddingBottom: 24, maxHeight: '72%', }, sheetHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: colors.borderLight, alignSelf: 'center', marginBottom: 10, }, sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, textAlign: 'center', marginBottom: 12, }, sheetList: { maxHeight: 360 }, sheetItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 14, paddingHorizontal: 20, borderTopWidth: 1, borderTopColor: colors.borderLight, }, sheetItemActive: { backgroundColor: colors.primaryLight }, sheetItemText: { fontSize: 15, color: colors.textPrimary }, sheetItemTextActive: { color: colors.primary, fontWeight: '700' }, saveHint: { fontSize: 12, color: colors.textHint, marginBottom: 8 }, saveNoteInput: { backgroundColor: colors.pageBg, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, color: colors.textPrimary, marginBottom: 16, }, saveActions: { flexDirection: 'row', gap: 12 }, saveCancel: { flex: 1, backgroundColor: colors.pageBg, borderRadius: 24, paddingVertical: 13, alignItems: 'center', }, saveCancelText: { fontSize: 16, fontWeight: '700', color: colors.textSecondary }, saveConfirm: { flex: 1, backgroundColor: colors.primaryBtn, borderRadius: 24, paddingVertical: 13, alignItems: 'center', }, saveConfirmDisabled: { opacity: 0.6 }, saveConfirmText: { fontSize: 16, fontWeight: '700', color: colors.white }, historyBody: { height: 300, justifyContent: 'center' }, historyList: { maxHeight: 420 }, historyRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14, paddingHorizontal: 4, borderTopWidth: 1, borderTopColor: colors.borderLight, }, historyRowMain: { flex: 1, marginRight: 8 }, historyTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, historySub: { fontSize: 12, color: colors.textSecondary, marginTop: 4 }, historyDelete: { padding: 6 }, });