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 {
/** 行业名称 */