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 { clampSocialBase } from '../../src/api';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors, headerGradientShort , spacing } 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, formatDateTime } from '../../src/utils';
/* ===================== 子组件:顶部导航 ===================== */
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 handleSelectCity = (city: string) => {
setInputs({ ...inputs, city, socialBase: clampSocialBase(city, inputs.socialBase) });
setCityPickerVisible(false);
};
return (
router.back()} onHistory={() => setHistoryVisible(true)} />
{/* 输入卡片 */}
收入参数
update('monthlyGross', Math.max(0, v))}
/>
setCityPickerVisible(true)} />
update('socialBase', clampSocialBase(inputs.city, 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)}/月 · {formatDateTime(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: spacing.card, paddingBottom: 32 },
card: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
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: spacing.card,
},
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: spacing.card,
padding: spacing.card,
alignItems: 'center',
marginBottom: 8,
},
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: spacing.card,
padding: spacing.card,
},
taxItemLabel: { fontSize: 12, color: colors.textSecondary, marginBottom: 6 },
taxItemValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary },
taxRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.card,
},
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 },
});