Browse Source

feat(salary): 薪资计算器保存记录与计算历史

- types: 新增 SalaryCalcRecord(输入快照+结果快照+备注+时间戳)
- api: salary:records 本地持久化 fetch/save/delete,以 storage 为唯一真相源
- hooks: 新增 useSalaryRecords(useAsyncData 取列表 + save/remove 变更)
- page: 历史按钮→历史弹层(加载/错误重试/空态/列表,点按回填参数);
        保存按钮→保存弹层(备注+确认),清除两处占位 Alert
- test: 新增保存/倒序读取/删除用例;同步修正城市基数 clamp 影响的既有用例
master
doublex001 2 months ago
parent
commit
2afe503b32
  1. 228
      app/tools/salary-calculator.tsx
  2. 74
      src/__tests__/salary.test.ts
  3. 72
      src/api/api.ts
  4. 3
      src/api/index.ts
  5. 1
      src/hooks/index.ts
  6. 46
      src/hooks/useSalaryRecords.ts
  7. 1
      src/types/index.ts
  8. 14
      src/types/tools.ts

228
app/tools/salary-calculator.tsx

@ -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 },
}); });

74
src/__tests__/salary.test.ts

@ -1,23 +1,40 @@
import { computeSalaryResult } from '../api/api'; import { computeSalaryResult, fetchSalaryRecords, saveSalaryRecord, deleteSalaryRecord } from '../api/api';
import type { SalaryCalcInput } from '../types';
// 内存版 storage mock,使记录类接口可在单测中验证(storage 为唯一真相源)
jest.mock('../lib/storage', () => {
const store: Record<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
describe('computeSalaryResult(薪资计算器业务规则)', () => { describe('computeSalaryResult(薪资计算器业务规则)', () => {
it('低薪且五险一金+起征点覆盖时个税为 0', () => { it('低薪且五险一金+起征点覆盖时个税为 0', () => {
const input = { const input = {
monthlyGross: 5000, monthlyGross: 5000,
city: '北京',
bonusMonths: 0, bonusMonths: 0,
socialBase: 5000, socialBase: 10000,
providentFundRate: 0.12, providentFundRate: 0.12,
specialDeduction: 0, specialDeduction: 0,
}; };
const r = computeSalaryResult(input as any); const r = computeSalaryResult(input as any);
// monthlySocial = 5000*(0.08+0.02+0.005+0.12) = 1125 // 社保基数 10000 在北京市上下限(6821-33891)内,不触发 clamp
expect(r.monthlySocial).toBe(1125); // monthlySocial = 10000*(0.08+0.02+0.005+0.12) = 2250
// taxable = max(0, 5000-1125-5000) = 0 → 个税 0 expect(r.monthlySocial).toBe(2250);
// taxable = max(0, 5000-2250-5000) = 0 → 个税 0
expect(r.monthlyTax).toBe(0); expect(r.monthlyTax).toBe(0);
// monthlyNet = 5000 - 1125 = 3875 // monthlyNet = 5000 - 2250 = 2750
expect(r.monthlyNet).toBe(3875); expect(r.monthlyNet).toBe(2750);
expect(r.annualGross).toBe(60000); expect(r.annualGross).toBe(60000);
expect(r.annualNet).toBe(46500); expect(r.annualNet).toBe(33000);
}); });
it('含年终奖时月度税前合计摊入', () => { it('含年终奖时月度税前合计摊入', () => {
@ -33,3 +50,44 @@ describe('computeSalaryResult(薪资计算器业务规则)', () => {
expect(r.monthlyTotal).toBe(11667); expect(r.monthlyTotal).toBe(11667);
}); });
}); });
describe('薪资计算记录(本地持久化)', () => {
const baseInput: SalaryCalcInput = {
monthlyGross: 30000,
city: '北京',
socialBase: 25000,
providentFundRate: 0.12,
specialDeduction: 2000,
bonusMonths: 0,
};
it('保存后可在记录列表中读取,并按时间倒序', async () => {
const r1 = computeSalaryResult(baseInput);
const rec1 = await saveSalaryRecord(baseInput, r1, '深圳 offer');
expect(rec1.id).toBeTruthy();
expect(rec1.note).toBe('深圳 offer');
const later = await new Promise<ReturnType<typeof saveSalaryRecord>>((resolve) => {
setTimeout(async () => {
const r2 = computeSalaryResult({ ...baseInput, monthlyGross: 40000 });
resolve(await saveSalaryRecord({ ...baseInput, monthlyGross: 40000 }, r2));
}, 5);
});
const list = await fetchSalaryRecords();
expect(list).toHaveLength(2);
// 后保存的排在最前
expect(list[0].id).toBe(later.id);
expect(list[1].id).toBe(rec1.id);
});
it('删除记录后不再出现在列表中', async () => {
const listBefore = await fetchSalaryRecords();
const target = listBefore[0];
await deleteSalaryRecord(target.id);
const listAfter = await fetchSalaryRecords();
expect(listAfter.find((r) => r.id === target.id)).toBeUndefined();
expect(listAfter).toHaveLength(listBefore.length - 1);
});
});

72
src/api/api.ts

@ -43,6 +43,7 @@ import type {
SalaryBracket, SalaryBracket,
SalaryCalcInput, SalaryCalcInput,
SalaryCalcResult, SalaryCalcResult,
SalaryCalcRecord,
SalaryInsuranceItem, SalaryInsuranceItem,
SalaryStructureItem, SalaryStructureItem,
SalaryTaxItem, SalaryTaxItem,
@ -1813,6 +1814,77 @@ export function computeSalaryResult(input: SalaryCalcInput): SalaryCalcResult {
}; };
} }
/* ===================== 薪资计算记录(本地持久化) =====================
* mock 阶段以 storage(youzhi: 前缀)为唯一真相源读写,不另建模块级内存缓存;
* 真实后端就绪后把三个函数体替换为 fetch 即可,签名不变。
*/
const SALARY_RECORD_KEY = 'salary:records';
/** 读取全部薪资计算记录(原始顺序) */
async function readSalaryRecords(): Promise<SalaryCalcRecord[]> {
const raw = await storageGet(SALARY_RECORD_KEY);
return raw ? (JSON.parse(raw) as SalaryCalcRecord[]) : [];
}
/**
* 获取薪资计算保存记录(按创建时间倒序)
* @method GET
* @url /api/salary/records
* @returns Promise<SalaryCalcRecord[]> 已保存的计算记录列表
*/
export async function fetchSalaryRecords(): Promise<SalaryCalcRecord[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/salary/records`, { method: 'GET' }); return res.json();
const all = await readSalaryRecords();
return all.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* 保存一笔薪资计算记录
* @method POST
* @url /api/salary/records
* @param input { SalaryCalcInput } 计算输入参数(完整快照)
* @param result { SalaryCalcResult } 计算结果快照
* @param note? { string } 用户备注(可选,用于区分不同场景)
* @returns Promise<SalaryCalcRecord> 新建的记录
*/
export async function saveSalaryRecord(
input: SalaryCalcInput,
result: SalaryCalcResult,
note?: string,
): Promise<SalaryCalcRecord> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/salary/records`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ input, result, note }),
// }); return res.json();
const record: SalaryCalcRecord = {
id: `salary-${Date.now()}`,
input,
result,
note: note?.trim() ? note.trim() : undefined,
createdAt: Date.now(),
};
const all = await readSalaryRecords();
all.unshift(record);
await storageSet(SALARY_RECORD_KEY, JSON.stringify(all));
return mockRequest(record);
}
/**
* 删除一笔薪资计算记录
* @method DELETE
* @url /api/salary/records/:id
* @param id { string } 记录 id
* @returns Promise<void>
*/
export async function deleteSalaryRecord(id: string): Promise<void> {
// TODO: await fetch(`${API_BASE_URL}/api/salary/records/${id}`, { method: 'DELETE' });
const all = await readSalaryRecords();
const next = all.filter((r) => r.id !== id);
await storageSet(SALARY_RECORD_KEY, JSON.stringify(next));
}
/** 月度超额累进个税(单位元) */ /** 月度超额累进个税(单位元) */
function calcMonthlyTax(t: number): number { function calcMonthlyTax(t: number): number {
if (t <= 0) return 0; if (t <= 0) return 0;

3
src/api/index.ts

@ -93,6 +93,9 @@ export {
fetchResumeExportSettings, fetchResumeExportSettings,
fetchSalaryBrackets, fetchSalaryBrackets,
fetchSalaryData, fetchSalaryData,
fetchSalaryRecords,
saveSalaryRecord,
deleteSalaryRecord,
fetchSkillCloud, fetchSkillCloud,
fetchUserProfile, fetchUserProfile,
fetchUserStats, fetchUserStats,

1
src/hooks/index.ts

@ -54,6 +54,7 @@ export { useResume } from './useResume';
export { useResumeExport } from './useResumeExport'; export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets'; export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator'; export { useSalaryCalculator } from './useSalaryCalculator';
export { useSalaryRecords } from './useSalaryRecords';
export { useSkillCloud } from './useSkillCloud'; export { useSkillCloud } from './useSkillCloud';
export { useTalentRecommendation } from './useTalentRecommendation'; export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings'; export { useWeightSettings } from './useWeightSettings';

46
src/hooks/useSalaryRecords.ts

@ -0,0 +1,46 @@
/**
* 薪资计算记录 hook:封装记录列表的取数与保存/删除变更。
* 列表通过通用 useAsyncData 管理 loading/error/refresh 三态;
* save/remove 为变更操作,执行后调用 refresh 以 storage 为唯一真相源重新取数。
*/
import { useCallback } from 'react';
import { useAsyncData } from './useAsyncData';
import { deleteSalaryRecord, fetchSalaryRecords, saveSalaryRecord } from '../api';
import type {
SalaryCalcInput,
SalaryCalcRecord,
SalaryCalcResult,
} from '../types';
export function useSalaryRecords() {
const fetcher = useCallback(() => fetchSalaryRecords(), []);
const { data, loading, error, refresh } = useAsyncData<SalaryCalcRecord[]>(fetcher);
/** 保存当前计算结果,成功后刷新列表 */
const save = useCallback(
async (input: SalaryCalcInput, result: SalaryCalcResult, note?: string) => {
const record = await saveSalaryRecord(input, result, note);
refresh();
return record;
},
[refresh],
);
/** 删除某条记录,成功后刷新列表 */
const remove = useCallback(
async (id: string) => {
await deleteSalaryRecord(id);
refresh();
},
[refresh],
);
return {
records: data ?? [],
loading,
error,
refresh,
save,
remove,
};
}

1
src/types/index.ts

@ -163,6 +163,7 @@ export type {
CitySocialProfile, CitySocialProfile,
SalaryCalcInput, SalaryCalcInput,
SalaryCalcResult, SalaryCalcResult,
SalaryCalcRecord,
SalaryInsuranceItem, SalaryInsuranceItem,
SalaryStructureItem, SalaryStructureItem,
SalaryTaxItem, SalaryTaxItem,

14
src/types/tools.ts

@ -238,6 +238,20 @@ export interface SalaryCalcResult {
structure: SalaryStructureItem[]; structure: SalaryStructureItem[];
} }
/** 薪资计算保存记录(本地持久化,storage 为唯一真相源) */
export interface SalaryCalcRecord {
/** 记录 id(本地生成) */
id: string;
/** 计算输入参数快照 */
input: SalaryCalcInput;
/** 计算结果快照(月/年 到手等) */
result: SalaryCalcResult;
/** 用户备注(可选,如「深圳 offer 对比」) */
note?: string;
/** 创建时间戳(ms) */
createdAt: number;
}
/** 行业薪资参考(计算器对比用) */ /** 行业薪资参考(计算器对比用) */
export interface SalaryBracket { export interface SalaryBracket {
/** 行业名称 */ /** 行业名称 */

Loading…
Cancel
Save