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,
} from 'react-native';
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 {
CITIES,
DEFAULT_SOCIAL_PROFILE,
SOCIAL_SECURITY_CITY_PROFILES,
} from '../../src/constants';
import type { SalaryCalcInput, SalaryToolLink } from '../../src/types';
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())}`;
}
/* ===================== 子组件:顶部导航 ===================== */
@ -232,7 +240,34 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
export default function SalaryCalculatorScreen() {
const router = useRouter();
const { inputs, setInputs, result } = useSalaryCalculator();
const records = useSalaryRecords();
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;
@ -260,7 +295,7 @@ export default function SalaryCalculatorScreen() {
return (
<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}>
{/* 输入卡片 */}
<View style={styles.card}>
@ -347,7 +382,7 @@ export default function SalaryCalculatorScreen() {
))}
</View>
<Pressable style={styles.saveBtn} onPress={() => Alert.alert('保存计算记录', '保存功能开发中,敬请期待')}>
<Pressable style={styles.saveBtn} onPress={() => setSaveSheetVisible(true)}>
<Text style={styles.saveBtnText}>保存计算记录</Text>
</Pressable>
</ScrollView>
@ -358,6 +393,26 @@ export default function SalaryCalculatorScreen() {
onClose={() => setCityPickerVisible(false)}
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>
);
}
@ -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({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
@ -589,4 +767,46 @@ const styles = StyleSheet.create({
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 },
});

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(薪资计算器业务规则)', () => {
it('低薪且五险一金+起征点覆盖时个税为 0', () => {
const input = {
monthlyGross: 5000,
city: '北京',
bonusMonths: 0,
socialBase: 5000,
socialBase: 10000,
providentFundRate: 0.12,
specialDeduction: 0,
};
const r = computeSalaryResult(input as any);
// monthlySocial = 5000*(0.08+0.02+0.005+0.12) = 1125
expect(r.monthlySocial).toBe(1125);
// taxable = max(0, 5000-1125-5000) = 0 → 个税 0
// 社保基数 10000 在北京市上下限(6821-33891)内,不触发 clamp
// monthlySocial = 10000*(0.08+0.02+0.005+0.12) = 2250
expect(r.monthlySocial).toBe(2250);
// taxable = max(0, 5000-2250-5000) = 0 → 个税 0
expect(r.monthlyTax).toBe(0);
// monthlyNet = 5000 - 1125 = 3875
expect(r.monthlyNet).toBe(3875);
// monthlyNet = 5000 - 2250 = 2750
expect(r.monthlyNet).toBe(2750);
expect(r.annualGross).toBe(60000);
expect(r.annualNet).toBe(46500);
expect(r.annualNet).toBe(33000);
});
it('含年终奖时月度税前合计摊入', () => {
@ -33,3 +50,44 @@ describe('computeSalaryResult(薪资计算器业务规则)', () => {
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,
SalaryCalcInput,
SalaryCalcResult,
SalaryCalcRecord,
SalaryInsuranceItem,
SalaryStructureItem,
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 {
if (t <= 0) return 0;

3
src/api/index.ts

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

1
src/hooks/index.ts

@ -54,6 +54,7 @@ export { useResume } from './useResume';
export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator';
export { useSalaryRecords } from './useSalaryRecords';
export { useSkillCloud } from './useSkillCloud';
export { useTalentRecommendation } from './useTalentRecommendation';
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,
SalaryCalcInput,
SalaryCalcResult,
SalaryCalcRecord,
SalaryInsuranceItem,
SalaryStructureItem,
SalaryTaxItem,

14
src/types/tools.ts

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

Loading…
Cancel
Save