|
|
@ -13,16 +13,24 @@ import { |
|
|
View, |
|
|
View, |
|
|
} from 'react-native'; |
|
|
} from 'react-native'; |
|
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
|
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
|
|
import { useSalaryCalculator } from '../../src/hooks'; |
|
|
import { useSalaryCalculator, useSalaryRecords } from '../../src/hooks'; |
|
|
|
|
|
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; |
|
|
import { colors, headerGradientShort } from '../../src/theme'; |
|
|
import { colors, headerGradientShort } from '../../src/theme'; |
|
|
import { |
|
|
import { |
|
|
CITIES, |
|
|
CITIES, |
|
|
DEFAULT_SOCIAL_PROFILE, |
|
|
DEFAULT_SOCIAL_PROFILE, |
|
|
SOCIAL_SECURITY_CITY_PROFILES, |
|
|
SOCIAL_SECURITY_CITY_PROFILES, |
|
|
} from '../../src/constants'; |
|
|
} from '../../src/constants'; |
|
|
import type { SalaryCalcInput, SalaryToolLink } from '../../src/types'; |
|
|
import type { SalaryCalcInput, SalaryCalcRecord, SalaryToolLink } from '../../src/types'; |
|
|
import { formatCurrency } from '../../src/utils'; |
|
|
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())}`; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ===================== 子组件:顶部导航 ===================== */ |
|
|
/* ===================== 子组件:顶部导航 ===================== */ |
|
|
|
|
|
|
|
|
@ -232,7 +240,34 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) { |
|
|
export default function SalaryCalculatorScreen() { |
|
|
export default function SalaryCalculatorScreen() { |
|
|
const router = useRouter(); |
|
|
const router = useRouter(); |
|
|
const { inputs, setInputs, result } = useSalaryCalculator(); |
|
|
const { inputs, setInputs, result } = useSalaryCalculator(); |
|
|
|
|
|
const records = useSalaryRecords(); |
|
|
const [cityPickerVisible, setCityPickerVisible] = useState(false); |
|
|
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 = |
|
|
const cityProfile = |
|
|
SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; |
|
|
SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; |
|
|
@ -260,7 +295,7 @@ export default function SalaryCalculatorScreen() { |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<SafeAreaView style={styles.container} edges={['top']}> |
|
|
<SafeAreaView style={styles.container} edges={['top']}> |
|
|
<Header onBack={() => router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} /> |
|
|
<Header onBack={() => router.back()} onHistory={() => setHistoryVisible(true)} /> |
|
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|
|
{/* 输入卡片 */} |
|
|
{/* 输入卡片 */} |
|
|
<View style={styles.card}> |
|
|
<View style={styles.card}> |
|
|
@ -347,7 +382,7 @@ export default function SalaryCalculatorScreen() { |
|
|
))} |
|
|
))} |
|
|
</View> |
|
|
</View> |
|
|
|
|
|
|
|
|
<Pressable style={styles.saveBtn} onPress={() => Alert.alert('保存计算记录', '保存功能开发中,敬请期待')}> |
|
|
<Pressable style={styles.saveBtn} onPress={() => setSaveSheetVisible(true)}> |
|
|
<Text style={styles.saveBtnText}>保存计算记录</Text> |
|
|
<Text style={styles.saveBtnText}>保存计算记录</Text> |
|
|
</Pressable> |
|
|
</Pressable> |
|
|
</ScrollView> |
|
|
</ScrollView> |
|
|
@ -358,6 +393,26 @@ export default function SalaryCalculatorScreen() { |
|
|
onClose={() => setCityPickerVisible(false)} |
|
|
onClose={() => setCityPickerVisible(false)} |
|
|
onSelect={handleSelectCity} |
|
|
onSelect={handleSelectCity} |
|
|
/> |
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
<SaveSheet |
|
|
|
|
|
visible={saveSheetVisible} |
|
|
|
|
|
note={saveNote} |
|
|
|
|
|
onNoteChange={setSaveNote} |
|
|
|
|
|
onSave={handleSave} |
|
|
|
|
|
onClose={() => setSaveSheetVisible(false)} |
|
|
|
|
|
saving={saving} |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
<HistoryModal |
|
|
|
|
|
visible={historyVisible} |
|
|
|
|
|
records={records.records} |
|
|
|
|
|
loading={records.loading} |
|
|
|
|
|
error={records.error} |
|
|
|
|
|
onRetry={records.refresh} |
|
|
|
|
|
onSelect={handleLoadRecord} |
|
|
|
|
|
onDelete={records.remove} |
|
|
|
|
|
onClose={() => setHistoryVisible(false)} |
|
|
|
|
|
/> |
|
|
</SafeAreaView> |
|
|
</SafeAreaView> |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
@ -404,6 +459,129 @@ function CityPicker({ |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ===================== 子组件:保存记录底部弹层 ===================== */ |
|
|
|
|
|
|
|
|
|
|
|
function SaveSheet({ |
|
|
|
|
|
visible, |
|
|
|
|
|
note, |
|
|
|
|
|
onNoteChange, |
|
|
|
|
|
onSave, |
|
|
|
|
|
onClose, |
|
|
|
|
|
saving, |
|
|
|
|
|
}: { |
|
|
|
|
|
visible: boolean; |
|
|
|
|
|
note: string; |
|
|
|
|
|
onNoteChange: (v: string) => void; |
|
|
|
|
|
onSave: () => void; |
|
|
|
|
|
onClose: () => void; |
|
|
|
|
|
saving: boolean; |
|
|
|
|
|
}) { |
|
|
|
|
|
return ( |
|
|
|
|
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}> |
|
|
|
|
|
<Pressable style={styles.sheetBackdrop} onPress={onClose}> |
|
|
|
|
|
<View style={styles.sheet}> |
|
|
|
|
|
<View style={styles.sheetHandle} /> |
|
|
|
|
|
<Text style={styles.sheetTitle}>保存计算记录</Text> |
|
|
|
|
|
<Text style={styles.saveHint}>备注(选填,便于区分不同方案)</Text> |
|
|
|
|
|
<TextInput |
|
|
|
|
|
style={styles.saveNoteInput} |
|
|
|
|
|
value={note} |
|
|
|
|
|
onChangeText={onNoteChange} |
|
|
|
|
|
placeholder="如:深圳 offer 对比" |
|
|
|
|
|
placeholderTextColor={colors.textHint} |
|
|
|
|
|
maxLength={20} |
|
|
|
|
|
/> |
|
|
|
|
|
<View style={styles.saveActions}> |
|
|
|
|
|
<Pressable style={styles.saveCancel} onPress={onClose}> |
|
|
|
|
|
<Text style={styles.saveCancelText}>取消</Text> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
<Pressable |
|
|
|
|
|
style={[styles.saveConfirm, saving && styles.saveConfirmDisabled]} |
|
|
|
|
|
onPress={onSave} |
|
|
|
|
|
disabled={saving} |
|
|
|
|
|
> |
|
|
|
|
|
<Text style={styles.saveConfirmText}>{saving ? '保存中…' : '保存'}</Text> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
</View> |
|
|
|
|
|
</View> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
</Modal> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ===================== 子组件:历史记录底部弹层 ===================== */ |
|
|
|
|
|
|
|
|
|
|
|
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 ( |
|
|
|
|
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}> |
|
|
|
|
|
<Pressable style={styles.sheetBackdrop} onPress={onClose}> |
|
|
|
|
|
<View style={styles.sheet}> |
|
|
|
|
|
<View style={styles.sheetHandle} /> |
|
|
|
|
|
<Text style={styles.sheetTitle}>计算历史</Text> |
|
|
|
|
|
{loading ? ( |
|
|
|
|
|
<View style={styles.historyBody}> |
|
|
|
|
|
<LoadingState text="加载历史记录…" /> |
|
|
|
|
|
</View> |
|
|
|
|
|
) : error ? ( |
|
|
|
|
|
<View style={styles.historyBody}> |
|
|
|
|
|
<ErrorState message="历史记录加载失败" onRetry={onRetry} /> |
|
|
|
|
|
</View> |
|
|
|
|
|
) : records.length === 0 ? ( |
|
|
|
|
|
<View style={styles.historyBody}> |
|
|
|
|
|
<EmptyState icon="time-outline" title="暂无保存的计算记录" subtitle="在下方「保存计算记录」即可留档" /> |
|
|
|
|
|
</View> |
|
|
|
|
|
) : ( |
|
|
|
|
|
<ScrollView style={styles.historyList} showsVerticalScrollIndicator={false}> |
|
|
|
|
|
{records.map((record) => ( |
|
|
|
|
|
<View key={record.id} style={styles.historyRow}> |
|
|
|
|
|
<Pressable style={styles.historyRowMain} onPress={() => onSelect(record)}> |
|
|
|
|
|
<Text style={styles.historyTitle} numberOfLines={1}> |
|
|
|
|
|
{record.note || `${record.input.city} · 税前${formatCurrency(record.input.monthlyGross, 0)}`} |
|
|
|
|
|
</Text> |
|
|
|
|
|
<Text style={styles.historySub}> |
|
|
|
|
|
到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatRecordTime(record.createdAt)} |
|
|
|
|
|
</Text> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
<Pressable |
|
|
|
|
|
style={styles.historyDelete} |
|
|
|
|
|
onPress={() => |
|
|
|
|
|
Alert.alert('删除记录', '确定删除这条计算记录?', [ |
|
|
|
|
|
{ text: '取消', style: 'cancel' }, |
|
|
|
|
|
{ text: '删除', style: 'destructive', onPress: () => onDelete(record.id) }, |
|
|
|
|
|
]) |
|
|
|
|
|
} |
|
|
|
|
|
hitSlop={8} |
|
|
|
|
|
> |
|
|
|
|
|
<Ionicons name="trash-outline" size={20} color={colors.danger} /> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
</View> |
|
|
|
|
|
))} |
|
|
|
|
|
</ScrollView> |
|
|
|
|
|
)} |
|
|
|
|
|
</View> |
|
|
|
|
|
</Pressable> |
|
|
|
|
|
</Modal> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({ |
|
|
const styles = StyleSheet.create({ |
|
|
container: { flex: 1, backgroundColor: colors.pageBg }, |
|
|
container: { flex: 1, backgroundColor: colors.pageBg }, |
|
|
header: { |
|
|
header: { |
|
|
@ -589,4 +767,46 @@ const styles = StyleSheet.create({ |
|
|
sheetItemActive: { backgroundColor: colors.primaryLight }, |
|
|
sheetItemActive: { backgroundColor: colors.primaryLight }, |
|
|
sheetItemText: { fontSize: 15, color: colors.textPrimary }, |
|
|
sheetItemText: { fontSize: 15, color: colors.textPrimary }, |
|
|
sheetItemTextActive: { color: colors.primary, fontWeight: '700' }, |
|
|
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 }, |
|
|
}); |
|
|
}); |
|
|
|