From f6d42023beacef0d0aa8537940f38eba725fbd89 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Thu, 10 Sep 2026 20:47:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=96=AA=E8=B5=84=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E5=99=A8=E5=9F=8E=E5=B8=82=E9=80=89=E6=8B=A9=20+=20=E6=96=87?= =?UTF-8?q?=E4=B9=A6=E6=A8=A1=E6=9D=BF=E9=A2=84=E8=A7=88=EF=BC=88=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=AE=A1=E8=AE=A1=E6=96=AD=E7=82=B9=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 薪资计算器:新增城市社保/公积金基数上下限表,选城市后 clamp 社保基数并计入计算(断点②) - 文书模板:新增模板正文与预览页 /legal/template,正文 selectable 长按可复制(断点③) - 分层:types/constants/api/页面;tsc --noEmit 0 错误、eslint 0 警告、路由审计 0 死链 --- app/legal/template.tsx | 145 ++++++++++++++++++++++++++++++++ app/legal/templates.tsx | 6 +- app/tools/salary-calculator.tsx | 131 ++++++++++++++++++++++++++++- src/api/api.ts | 68 +++++++++++++-- src/constants/index.ts | 26 +++++- src/types/index.ts | 1 + src/types/legal.ts | 4 +- src/types/tools.ts | 16 +++- 8 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 app/legal/template.tsx diff --git a/app/legal/template.tsx b/app/legal/template.tsx new file mode 100644 index 0000000..b7f263f --- /dev/null +++ b/app/legal/template.tsx @@ -0,0 +1,145 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useLegalData } from '../../src/hooks'; +import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; +import { colors } from '../../src/theme'; + +/* ===================== 顶部导航 ===================== */ + +function Header({ onBack }: { onBack: () => void }) { + return ( + + + + + 文书预览 + + + ); +} + +/* ===================== 页面 ===================== */ + +export default function LegalTemplateScreen() { + const router = useRouter(); + const { id } = useLocalSearchParams(); + const { templates, loading, error, refresh } = useLegalData(); + + const templateId = Array.isArray(id) ? id[0] : id; + const template = templateId ? templates.find((t) => t.id === templateId) : undefined; + + return ( + +
router.back()} /> + {loading ? ( + + ) : error ? ( + + ) : !template ? ( + + ) : ( + + + + + {template.name} + {template.description} + + + + + {/* selectable:长按正文即可复制,无需额外原生模块(mock 阶段) */} + + {template.content} + + + + + 长按正文即可复制;正式下载 / 导出为 Word·PDF 需后端文件支持,当前为内容预览。 + + + )} + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.pageBg, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 8, + height: 48, + backgroundColor: colors.white, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + headerBtn: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 17, + fontWeight: '700', + color: colors.textPrimary, + }, + scroll: { + flex: 1, + }, + content: { + padding: 12, + paddingBottom: 24, + }, + headCard: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + backgroundColor: colors.white, + marginBottom: 12, + }, + headBody: { + flex: 1, + marginLeft: 12, + }, + name: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary, + }, + desc: { + marginTop: 2, + fontSize: 12, + color: colors.textSecondary, + }, + contentCard: { + padding: 16, + borderRadius: 12, + backgroundColor: colors.white, + borderWidth: 1, + borderColor: colors.borderLight, + }, + contentText: { + fontSize: 14, + lineHeight: 22, + color: colors.textPrimary, + }, + tip: { + marginTop: 12, + fontSize: 12, + color: colors.textHint, + lineHeight: 18, + }, +}); diff --git a/app/legal/templates.tsx b/app/legal/templates.tsx index 2d50346..c32ca49 100644 --- a/app/legal/templates.tsx +++ b/app/legal/templates.tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLegalData } from '../../src/hooks'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; @@ -62,9 +62,7 @@ export default function LegalTemplatesScreen() { - Alert.alert('文书预览', `「${template.name}」预览与下载功能即将上线`) - } + onPress={() => router.push(`/legal/template?id=${template.id}`)} /> ))} diff --git a/app/tools/salary-calculator.tsx b/app/tools/salary-calculator.tsx index bc884fe..de696ba 100644 --- a/app/tools/salary-calculator.tsx +++ b/app/tools/salary-calculator.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Alert, + Modal, Pressable, ScrollView, StyleSheet, @@ -14,6 +15,11 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useSalaryCalculator } from '../../src/hooks'; 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 { formatCurrency } from '../../src/utils'; @@ -226,11 +232,32 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) { export default function SalaryCalculatorScreen() { const router = useRouter(); const { inputs, setInputs, result } = useSalaryCalculator(); + const [cityPickerVisible, setCityPickerVisible] = useState(false); + + const cityProfile = + SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; const update = (key: K, value: SalaryCalcInput[K]) => { setInputs({ ...inputs, [key]: value }); }; + /** 将社保基数 clamp 到当前城市上下限内 */ + const clampSocialBase = (v: number) => { + const profile = SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; + return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, v)); + }; + + /** 选城市:切换城市并把社保基数 clamp 到新城市上下限 */ + const handleSelectCity = (city: string) => { + const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; + const clamped = Math.min( + profile.socialBaseUpper, + Math.max(profile.socialBaseLower, inputs.socialBase), + ); + setInputs({ ...inputs, city, socialBase: clamped }); + setCityPickerVisible(false); + }; + return (
router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} /> @@ -244,13 +271,17 @@ export default function SalaryCalculatorScreen() { suffix="元" onChange={(v) => update('monthlyGross', Math.max(0, v))} /> - Alert.alert('选择城市', '城市选择功能开发中,敬请期待')} /> + setCityPickerVisible(true)} /> update('socialBase', Math.max(0, v))} + onChange={(v) => update('socialBase', clampSocialBase(Math.max(0, v)))} /> + + 基数范围 ¥{formatCurrency(cityProfile.socialBaseLower, 0)} – ¥ + {formatCurrency(cityProfile.socialBaseUpper, 0)}({inputs.city}) + 保存计算记录 + + setCityPickerVisible(false)} + onSelect={handleSelectCity} + /> ); } +/* ===================== 子组件:城市选择底部弹层 ===================== */ + +function CityPicker({ + visible, + current, + onClose, + onSelect, +}: { + visible: boolean; + current: string; + onClose: () => void; + onSelect: (city: string) => void; +}) { + return ( + + + + + 选择所在城市 + + {CITIES.map((city) => { + const active = city === current; + return ( + onSelect(city)} + > + + {city} + + {active && } + + ); + })} + + + + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.pageBg }, header: { @@ -462,4 +542,51 @@ const styles = StyleSheet.create({ marginTop: 8, }, saveBtnText: { fontSize: 16, fontWeight: '700', color: colors.white }, + baseHint: { + fontSize: 12, + color: colors.textHint, + marginTop: 6, + paddingHorizontal: 2, + }, + sheetBackdrop: { + flex: 1, + backgroundColor: colors.scrim, + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: colors.white, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingTop: 10, + paddingBottom: 24, + maxHeight: '72%', + }, + sheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.borderLight, + alignSelf: 'center', + marginBottom: 10, + }, + sheetTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary, + textAlign: 'center', + marginBottom: 12, + }, + sheetList: { maxHeight: 360 }, + sheetItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 20, + borderTopWidth: 1, + borderTopColor: colors.borderLight, + }, + sheetItemActive: { backgroundColor: colors.primaryLight }, + sheetItemText: { fontSize: 15, color: colors.textPrimary }, + sheetItemTextActive: { color: colors.primary, fontWeight: '700' }, }); diff --git a/src/api/api.ts b/src/api/api.ts index 59e9527..6f4ae30 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -124,7 +124,7 @@ import type { WechatBindPhonePayload, WechatSession, } from '../types'; -import { MOCK_DELAY, SALARY_RANGES, TOKEN_TTL_MS } from '../constants'; +import { DEFAULT_SOCIAL_PROFILE, MOCK_DELAY, SALARY_RANGES, SOCIAL_SECURITY_CITY_PROFILES, TOKEN_TTL_MS } from '../constants'; import { colors } from '../theme'; import { storageGet, storageSet, storageRemove } from '../lib/storage'; @@ -1747,11 +1747,18 @@ export function computeSalaryResult(input: SalaryCalcInput): SalaryCalcResult { // 年终奖摊入月度,得到月度税前合计 const monthlyTotal = monthlyGross * (1 + input.bonusMonths / 12); + // 按所在城市 clamp 社保基数到该城市上下限(养老/医疗/失业比例全国一致,城市差异在基数上下限) + const socialProfile = SOCIAL_SECURITY_CITY_PROFILES[input.city] ?? DEFAULT_SOCIAL_PROFILE; + const socialBase = Math.min( + socialProfile.socialBaseUpper, + Math.max(socialProfile.socialBaseLower, input.socialBase), + ); + // 社保个人缴纳比例:养老 8% + 医疗 2% + 失业 0.5% - const pension = input.socialBase * 0.08; - const medical = input.socialBase * 0.02; - const unemployment = input.socialBase * 0.005; - const providentFund = input.socialBase * input.providentFundRate; + const pension = socialBase * 0.08; + const medical = socialBase * 0.02; + const unemployment = socialBase * 0.005; + const providentFund = socialBase * input.providentFundRate; const monthlySocial = pension + medical + unemployment + providentFund; // 个税:月度应纳税所得额 = 税前 - 五险一金 - 5000 起征点 - 专项附加扣除 @@ -3918,6 +3925,20 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [ description: '入职前必查 12 项关键条款', icon: 'checkbox-outline', url: 'https://dev.api.com/legal/templates/contract-checklist', + content: `劳动合同审查清单(入职前必查 12 项) + +1. 用人单位名称、法定代表人、注册地址是否与营业执照一致 +2. 合同期限(固定/无固定/以完成一定工作任务为期限)与试用期约定是否合法(试用期不超合同期 1/12,且最长 6 个月) +3. 工作地点、岗位名称、工作内容是否明确,有无「公司可随意调岗」等霸王条款 +4. 工时制度(标准/综合计算/不定时)及加班工资计算基数与倍数 +5. 劳动报酬:基本工资、绩效、补贴构成,发放时间与方式 +6. 社会保险与住房公积金的缴纳基数、比例、起始时间 +7. 试用期工资是否不低于转正工资 80% 且不低于当地最低工资 +8. 竞业限制范围、期限(不超 2 年)、补偿标准是否明确 +9. 培训服务期约定及违约金上限(不超培训费用) +10. 解除劳动合同的条件与经济补偿(N / N+1 / 2N)是否合法 +11. 违约金条款是否超出法定范围(仅培训、竞业可约定违约金) +12. 争议解决方式(仲裁/诉讼)与管辖地是否合理`, }, { id: 'tmpl-2', @@ -3925,6 +3946,22 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [ description: '规范离职通知,保留证据', icon: 'exit-outline', url: 'https://dev.api.com/legal/templates/resignation-letter', + content: `离职申请书 + +致:____(公司名称)____ 人力资源部 + +本人 ____(姓名)____,身份证号 ____,于 ____(入职日期)____ 入职,现任 ____(部门 / 岗位)____。 + +现因 ____(离职原因,如:个人职业发展)____,根据《劳动合同法》第三十七条,本人提前三十日以书面形式通知公司,拟于 ____(最后工作日期)____ 正式解除劳动关系。 + +请公司按约定办理工作交接、出具离职证明,并结清工资、加班费及未休年假工资;社会保险与住房公积金请缴存至 ____(年 / 月)____。 + +此致 +敬礼 + +申请人:____(签名)____ +联系电话:____ +日期:____ 年 ____ 月 ____ 日`, }, { id: 'tmpl-3', @@ -3932,6 +3969,27 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [ description: '劳动争议维权起步文书', icon: 'gavel-outline', url: 'https://dev.api.com/legal/templates/arbitration-application', + content: `劳动仲裁申请书 + +申请人:____(姓名)____,性别 ____,身份证号 ____,联系电话 ____,住址 ____ + +被申请人:____(公司全称)____,统一社会信用代码 ____,住所地 ____,法定代表人 ____ + +仲裁请求: +1. 请求裁决被申请人支付违法解除劳动合同赔偿金 ____ 元; +2. 请求裁决被申请人支付拖欠工资 ____ 元及加班费 ____ 元; +3. 请求裁决被申请人补缴 ____ 年 ____ 月至 ____ 年 ____ 月的社会保险与住房公积金。 + +事实与理由: +____(简述入职时间、岗位、工资标准、争议发生经过及证据,如劳动合同、工资条、考勤记录、聊天记录等)____ + +此致 +____(用人单位所在地或合同履行地)____ 劳动人事争议仲裁委员会 + +申请人:____(签名)____ +日期:____ 年 ____ 月 ____ 日 + +附:1. 申请人身份证复印件;2. 证据清单及材料 ____ 份`, }, ]; diff --git a/src/constants/index.ts b/src/constants/index.ts index 75101fe..3a15791 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,7 +1,7 @@ /** * 常量统一导出(枚举、配置项等) */ -import type { JobLevel } from '../types'; +import type { CitySocialProfile, JobLevel } from '../types'; /** 请求失败自动重试次数 */ export const MAX_RETRY_COUNT = 3; @@ -35,6 +35,30 @@ export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕 /** 热门城市(定位/城市选择用) */ export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; +/** + * 城市社保/公积金缴费基数上下限(mock 数值,真实接入后端按城市下发)。 + * 养老 8% / 医疗 2% / 失业 0.5% 全国一致,城市差异体现在基数上下限,故按城市 clamp 基数即可反映差异。 + */ +export const SOCIAL_SECURITY_CITY_PROFILES: Record = { + 北京: { city: '北京', socialBaseUpper: 33891, socialBaseLower: 6821, providentBaseUpper: 33891, providentBaseLower: 2420 }, + 上海: { city: '上海', socialBaseUpper: 36549, socialBaseLower: 7384, providentBaseUpper: 36549, providentBaseLower: 2690 }, + 深圳: { city: '深圳', socialBaseUpper: 43659, socialBaseLower: 4492, providentBaseUpper: 43659, providentBaseLower: 2360 }, + 广州: { city: '广州', socialBaseUpper: 27501, socialBaseLower: 5500, providentBaseUpper: 39579, providentBaseLower: 2300 }, + 杭州: { city: '杭州', socialBaseUpper: 24060, socialBaseLower: 4812, providentBaseUpper: 36675, providentBaseLower: 2490 }, + 成都: { city: '成都', socialBaseUpper: 22555, socialBaseLower: 4511, providentBaseUpper: 29331, providentBaseLower: 2100 }, + 南京: { city: '南京', socialBaseUpper: 24396, socialBaseLower: 4878, providentBaseUpper: 39900, providentBaseLower: 2280 }, + 武汉: { city: '武汉', socialBaseUpper: 22467, socialBaseLower: 4494, providentBaseUpper: 29330, providentBaseLower: 2210 }, +}; + +/** 未匹配城市时的兜底上下限(参考一线城市,mock 阶段使用) */ +export const DEFAULT_SOCIAL_PROFILE: CitySocialProfile = { + city: '全国', + socialBaseUpper: 33891, + socialBaseLower: 6821, + providentBaseUpper: 33891, + providentBaseLower: 2420, +}; + /** 一天的毫秒数,用于令牌有效期等时间计算 */ export const ONE_DAY_MS = 24 * 60 * 60 * 1000; diff --git a/src/types/index.ts b/src/types/index.ts index 494e4ec..47e3ed6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -160,6 +160,7 @@ export type { ProgressStatus, ProgressStep, SalaryBracket, + CitySocialProfile, SalaryCalcInput, SalaryCalcResult, SalaryInsuranceItem, diff --git a/src/types/legal.ts b/src/types/legal.ts index 4373d64..2c6a7be 100644 --- a/src/types/legal.ts +++ b/src/types/legal.ts @@ -46,8 +46,10 @@ export interface LegalTemplate { description: string; /** 图标名(Ionicons) */ icon: string; - /** 下载/预览地址(mock 阶段为占位) */ + /** 下载/预览地址(mock 阶段为占位,真实下载由后端文件提供) */ url: string; + /** 模板正文(mock 阶段提供可预览/复制的内容;____ 表示需填写项) */ + content: string; } /** 会员套餐 */ diff --git a/src/types/tools.ts b/src/types/tools.ts index be5e78f..b197aeb 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -139,7 +139,7 @@ export interface SkillCloudData { export interface SalaryCalcInput { /** 税前月薪(元) */ monthlyGross: number; - /** 所在城市(决定社保公积金上下限,mock 阶段仅展示) */ + /** 所在城市(决定社保/公积金缴费基数上下限,选择后实际参与计算) */ city: string; /** 社保缴费基数(元) */ socialBase: number; @@ -151,6 +151,20 @@ export interface SalaryCalcInput { bonusMonths: number; } +/** 城市社保/公积金缴费基数上下限(mock 阶段内置,真实后端按城市下发) */ +export interface CitySocialProfile { + /** 城市名(与 CITIES 常量对应) */ + city: string; + /** 社保缴费基数上限(元/月) */ + socialBaseUpper: number; + /** 社保缴费基数下限(元/月) */ + socialBaseLower: number; + /** 公积金缴存基数上限(元/月) */ + providentBaseUpper: number; + /** 公积金缴存基数下限(元/月) */ + providentBaseLower: number; +} + /** 五险一金单项明细 */ export interface SalaryInsuranceItem { /** 险种名称 */