From 2afe503b32e07afe2a8191ce0797088c21583583 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Fri, 11 Sep 2026 17:20:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(salary):=20=E8=96=AA=E8=B5=84=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E5=99=A8=E4=BF=9D=E5=AD=98=E8=AE=B0=E5=BD=95=E4=B8=8E?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types: 新增 SalaryCalcRecord(输入快照+结果快照+备注+时间戳) - api: salary:records 本地持久化 fetch/save/delete,以 storage 为唯一真相源 - hooks: 新增 useSalaryRecords(useAsyncData 取列表 + save/remove 变更) - page: 历史按钮→历史弹层(加载/错误重试/空态/列表,点按回填参数); 保存按钮→保存弹层(备注+确认),清除两处占位 Alert - test: 新增保存/倒序读取/删除用例;同步修正城市基数 clamp 影响的既有用例 --- app/tools/salary-calculator.tsx | 228 +++++++++++++++++++++++++++++++- src/__tests__/salary.test.ts | 74 +++++++++-- src/api/api.ts | 72 ++++++++++ src/api/index.ts | 3 + src/hooks/index.ts | 1 + src/hooks/useSalaryRecords.ts | 46 +++++++ src/types/index.ts | 1 + src/types/tools.ts | 14 ++ 8 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useSalaryRecords.ts diff --git a/app/tools/salary-calculator.tsx b/app/tools/salary-calculator.tsx index de696ba..cef52e4 100644 --- a/app/tools/salary-calculator.tsx +++ b/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 ( -
router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} /> +
router.back()} onHistory={() => setHistoryVisible(true)} /> {/* 输入卡片 */} @@ -347,7 +382,7 @@ export default function SalaryCalculatorScreen() { ))} - Alert.alert('保存计算记录', '保存功能开发中,敬请期待')}> + setSaveSheetVisible(true)}> 保存计算记录 @@ -358,6 +393,26 @@ export default function SalaryCalculatorScreen() { onClose={() => setCityPickerVisible(false)} onSelect={handleSelectCity} /> + + setSaveSheetVisible(false)} + saving={saving} + /> + + setHistoryVisible(false)} + /> ); } @@ -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 ( + + + + + 保存计算记录 + 备注(选填,便于区分不同方案) + + + + 取消 + + + {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: { @@ -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 }, }); diff --git a/src/__tests__/salary.test.ts b/src/__tests__/salary.test.ts index e43a2e0..7924dda 100644 --- a/src/__tests__/salary.test.ts +++ b/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 = {}; + 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>((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); + }); +}); + diff --git a/src/api/api.ts b/src/api/api.ts index 6f4ae30..8dfcac6 100644 --- a/src/api/api.ts +++ b/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 { + const raw = await storageGet(SALARY_RECORD_KEY); + return raw ? (JSON.parse(raw) as SalaryCalcRecord[]) : []; +} + +/** + * 获取薪资计算保存记录(按创建时间倒序) + * @method GET + * @url /api/salary/records + * @returns Promise 已保存的计算记录列表 + */ +export async function fetchSalaryRecords(): Promise { + // 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 新建的记录 + */ +export async function saveSalaryRecord( + input: SalaryCalcInput, + result: SalaryCalcResult, + note?: string, +): Promise { + // 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 + */ +export async function deleteSalaryRecord(id: string): Promise { + // 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; diff --git a/src/api/index.ts b/src/api/index.ts index 14458e4..b2b8c06 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -93,6 +93,9 @@ export { fetchResumeExportSettings, fetchSalaryBrackets, fetchSalaryData, + fetchSalaryRecords, + saveSalaryRecord, + deleteSalaryRecord, fetchSkillCloud, fetchUserProfile, fetchUserStats, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index da14531..f550117 100644 --- a/src/hooks/index.ts +++ b/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'; diff --git a/src/hooks/useSalaryRecords.ts b/src/hooks/useSalaryRecords.ts new file mode 100644 index 0000000..0fa942a --- /dev/null +++ b/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(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, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 47e3ed6..aea8fe1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -163,6 +163,7 @@ export type { CitySocialProfile, SalaryCalcInput, SalaryCalcResult, + SalaryCalcRecord, SalaryInsuranceItem, SalaryStructureItem, SalaryTaxItem, diff --git a/src/types/tools.ts b/src/types/tools.ts index b197aeb..d053238 100644 --- a/src/types/tools.ts +++ b/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 { /** 行业名称 */