stage 2: FIRE calculator, holdings tracker, settings store update

- calculator.vue: compound-growth formula to compute months/date to FIRE,
  Barista FIRE logic breakdown card, collapsible editable params (bound to settings store)
- holdings.vue: manual portfolio entry with add/edit/delete, P&L display
- holdings.ts: new pinia store with persist; totalValue/Cost/PnL computed
- settings.ts: add annualReturn (6% default)
- progress.vue: enhanced empty-state onboarding prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ila
2026-06-08 15:40:04 +08:00
co-authored by Claude Sonnet 4.6
parent fc1f6a7098
commit dea78bf04b
5 changed files with 485 additions and 14 deletions
+213 -4
View File
@@ -1,14 +1,223 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useProgressStore } from '@/store/progress'
import { useSettingsStore } from '@/store/settings'
defineOptions({ name: 'Calculator' }) defineOptions({ name: 'Calculator' })
definePage({ definePage({
style: { navigationBarTitleText: 'FIRE 测算' }, style: { navigationBarTitleText: 'FIRE 测算' },
}) })
const progress = useProgressStore()
const settings = useSettingsStore()
const currentAssets = computed(() =>
progress.netAssets > 0 ? progress.netAssets : settings.initialCapital,
)
const netAnnualExpense = computed(() => settings.annualExpense - settings.partTimeIncome)
const computedTarget = computed(() =>
Math.round(netAnnualExpense.value / settings.withdrawRate),
)
const monthlyRate = computed(() => (1 + settings.annualReturn) ** (1 / 12) - 1)
// n = log((FV·r + PMT) / (PV·r + PMT)) / log(1+r)
const monthsToFire = computed(() => {
const pv = currentAssets.value
const fv = settings.target
const pmt = settings.monthlyInvest
const r = monthlyRate.value
if (pv >= fv) return 0
if (r <= 0) return pmt > 0 ? Math.ceil((fv - pv) / pmt) : Infinity
const ratio = (fv * r + pmt) / (pv * r + pmt)
if (ratio <= 0) return Infinity
return Math.log(ratio) / Math.log(1 + r)
})
const yearsLeft = computed(() => Math.floor(monthsToFire.value / 12))
const monthsLeft = computed(() => Math.floor(monthsToFire.value % 12))
const fireDate = computed(() => {
if (!isFinite(monthsToFire.value)) return '无法达成'
const d = new Date()
d.setMonth(d.getMonth() + Math.ceil(monthsToFire.value))
return `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`
})
const showParams = ref(false)
const paramItems = computed(() => [
{
key: 'annualReturn',
label: '年化收益率',
unit: '%',
getValue: () => (settings.annualReturn * 100).toFixed(1),
setValue: (v: string) => { settings.annualReturn = Math.max(0, Number(v)) / 100 },
},
{
key: 'monthlyInvest',
label: '每月定投',
unit: '元',
getValue: () => String(settings.monthlyInvest),
setValue: (v: string) => { settings.monthlyInvest = Math.max(0, Number(v)) },
},
{
key: 'annualExpense',
label: '年支出',
unit: '元',
getValue: () => String(settings.annualExpense),
setValue: (v: string) => { settings.annualExpense = Math.max(0, Number(v)) },
},
{
key: 'partTimeIncome',
label: '兼职年收入',
unit: '元',
getValue: () => String(settings.partTimeIncome),
setValue: (v: string) => { settings.partTimeIncome = Math.max(0, Number(v)) },
},
{
key: 'withdrawRate',
label: '安全提取率',
unit: '%',
getValue: () => (settings.withdrawRate * 100).toFixed(1),
setValue: (v: string) => { settings.withdrawRate = Math.max(0.1, Number(v)) / 100 },
},
{
key: 'target',
label: '目标本金',
unit: '元',
getValue: () => String(settings.target),
setValue: (v: string) => { settings.target = Math.max(0, Number(v)) },
},
])
</script> </script>
<template> <template>
<view class="min-h-screen bg-gray-50 px-4 pt-12 pb-24 flex flex-col items-center justify-center"> <view class="min-h-screen bg-gray-50 px-4 pt-12 pb-24">
<view class="text-6xl mb-4">🚧</view> <view class="mb-6 text-2xl font-bold text-gray-800">
<view class="text-lg font-medium text-gray-600">测算器</view> FIRE 测算
<view class="mt-2 text-sm text-gray-400">阶段 2 开发中</view> </view>
<!-- 核心结论 -->
<view class="mb-4 rounded-2xl bg-green-500 p-5 shadow-sm text-white">
<view class="text-sm opacity-80 mb-1">
按当前定投速度,预计再过
</view>
<view class="text-3xl font-bold mb-1">
<template v-if="monthsToFire === 0">
已达成!🎉
</template>
<template v-else-if="isFinite(monthsToFire)">
{{ yearsLeft }} 年 {{ monthsLeft }} 个月
</template>
<template v-else>
请调整参数
</template>
</view>
<view class="text-sm opacity-80">
达到 Barista FIRE
</view>
<view v-if="isFinite(monthsToFire) && monthsToFire > 0" class="mt-3 text-base font-medium">
预计时间:{{ fireDate }}
</view>
</view>
<!-- 关键数字 -->
<view class="mb-4 grid grid-cols-2 gap-3">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="text-sm text-gray-500">
FIRE 目标本金
</view>
<view class="mt-1 text-xl font-bold text-gray-800">
¥{{ (settings.target / 10000).toFixed(0) }} 万
</view>
<view class="mt-1 text-xs text-gray-400">
按参数计算 {{ (computedTarget / 10000).toFixed(1) }} 万
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="text-sm text-gray-500">
当前净资产
</view>
<view class="mt-1 text-xl font-bold text-gray-800">
¥{{ (currentAssets / 10000).toFixed(2) }} 万
</view>
<view class="mt-1 text-xs text-gray-400">
{{ progress.netAssets > 0 ? '进度页最新记录' : '使用初始资本' }}
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="text-sm text-gray-500">
每月定投
</view>
<view class="mt-1 text-xl font-bold text-blue-500">
¥{{ settings.monthlyInvest.toLocaleString() }}
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="text-sm text-gray-500">
年化收益假设
</view>
<view class="mt-1 text-xl font-bold text-purple-500">
{{ (settings.annualReturn * 100).toFixed(1) }}%
</view>
</view>
</view>
<!-- Barista FIRE 逻辑拆解 -->
<view class="mb-4 rounded-2xl bg-white p-5 shadow-sm">
<view class="font-medium text-gray-700 mb-3">
Barista FIRE 逻辑
</view>
<view class="text-sm text-gray-600 space-y-2">
<view class="flex justify-between">
<text>年支出</text>
<text class="font-medium text-gray-800">¥{{ settings.annualExpense.toLocaleString() }}</text>
</view>
<view class="flex justify-between">
<text>兼职年收入</text>
<text class="font-medium text-green-600">− ¥{{ settings.partTimeIncome.toLocaleString() }}</text>
</view>
<view class="h-px bg-gray-100 my-1" />
<view class="flex justify-between">
<text>投资需覆盖</text>
<text class="font-medium text-gray-800">¥{{ netAnnualExpense.toLocaleString() }}/年</text>
</view>
<view class="flex justify-between">
<text>÷ 提取率 {{ (settings.withdrawRate * 100).toFixed(1) }}%</text>
<text class="font-medium text-green-600 text-right">= ¥{{ (computedTarget / 10000).toFixed(1) }} 万</text>
</view>
</view>
</view>
<!-- 调整参数(可展开) -->
<view class="mb-4 rounded-2xl bg-white shadow-sm overflow-hidden">
<view
class="px-5 py-3 border-b border-gray-100 font-medium text-gray-700 flex justify-between items-center"
@tap="showParams = !showParams"
>
<text>调整参数</text>
<text class="text-gray-400 text-sm">{{ showParams ? '收起 ▲' : '展开 ▼' }}</text>
</view>
<view v-if="showParams" class="divide-y divide-gray-50">
<view
v-for="item in paramItems"
:key="item.key"
class="flex items-center justify-between px-5 py-3"
>
<text class="text-sm text-gray-700">{{ item.label }}({{ item.unit }})</text>
<input
:value="item.getValue()"
type="digit"
class="w-28 text-right border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-800"
@blur="(e: any) => item.setValue(e.detail.value)"
/>
</view>
<view class="px-5 py-3 text-xs text-gray-400">
修改后自动保存,数据实时更新
</view>
</view>
</view>
</view> </view>
</template> </template>
+211 -4
View File
@@ -1,14 +1,221 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useHoldingsStore } from '@/store/holdings'
defineOptions({ name: 'Holdings' }) defineOptions({ name: 'Holdings' })
definePage({ definePage({
style: { navigationBarTitleText: '持仓' }, style: { navigationBarTitleText: '持仓' },
}) })
const store = useHoldingsStore()
const showForm = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({ name: '', currentValue: '', costBasis: '' })
function openAdd() {
editingId.value = null
form.name = ''
form.currentValue = ''
form.costBasis = ''
showForm.value = true
}
function openEdit(id: string) {
const h = store.holdings.find(h => h.id === id)
if (!h) return
editingId.value = id
form.name = h.name
form.currentValue = String(h.currentValue)
form.costBasis = String(h.costBasis)
showForm.value = true
}
function saveForm() {
const cv = Number(form.currentValue)
const cb = Number(form.costBasis)
if (!form.name.trim() || cv <= 0 || cb <= 0) return
if (editingId.value) {
store.update(editingId.value, cv, cb)
}
else {
store.add(form.name.trim(), cv, cb)
}
showForm.value = false
}
function confirmDelete(id: string) {
uni.showModal({
title: '删除持仓',
content: '确认删除这条记录?',
success: (res) => {
if (res.confirm) store.remove(id)
},
})
}
function pnlColor(pnl: number) {
return pnl >= 0 ? 'text-red-500' : 'text-green-600'
}
function pnlSign(pnl: number) {
return pnl >= 0 ? '+' : ''
}
</script> </script>
<template> <template>
<view class="min-h-screen bg-gray-50 px-4 pt-12 pb-24 flex flex-col items-center justify-center"> <view class="min-h-screen bg-gray-50 px-4 pt-12 pb-24">
<view class="text-6xl mb-4">🚧</view> <view class="mb-6 text-2xl font-bold text-gray-800">
<view class="text-lg font-medium text-gray-600">持仓跟踪</view> 持仓
<view class="mt-2 text-sm text-gray-400">阶段 2 开发中</view> </view>
<!-- 总览卡片 -->
<view class="mb-4 rounded-2xl bg-white p-5 shadow-sm">
<view class="mb-3 text-sm text-gray-500">
投资组合总览
</view>
<view class="flex justify-between items-end mb-4">
<view>
<view class="text-xs text-gray-400 mb-1">
总市值
</view>
<view class="text-2xl font-bold text-gray-800">
¥{{ store.totalValue.toLocaleString() }}
</view>
</view>
<view class="text-right">
<view class="text-xs text-gray-400 mb-1">
总浮盈
</view>
<view class="text-xl font-bold" :class="pnlColor(store.totalPnl)">
{{ pnlSign(store.totalPnl) }}¥{{ Math.abs(store.totalPnl).toLocaleString() }}
</view>
</view>
</view>
<view class="flex justify-between text-sm text-gray-500">
<view>
总成本 ¥{{ store.totalCost.toLocaleString() }}
</view>
<view :class="pnlColor(store.totalPnl)">
{{ pnlSign(store.totalPnlPct) }}{{ store.totalPnlPct.toFixed(2) }}%
</view>
</view>
</view>
<!-- 持仓列表 -->
<view class="mb-4 rounded-2xl bg-white shadow-sm overflow-hidden">
<view class="px-5 py-3 border-b border-gray-100 font-medium text-gray-700">
持仓明细
</view>
<view v-if="store.holdings.length === 0" class="px-5 py-10 flex flex-col items-center gap-3">
<view class="text-4xl">
📈
</view>
<view class="text-base font-medium text-gray-700">
暂无持仓记录
</view>
<view class="text-sm text-gray-400 text-center">
点击右下角 <text class="font-bold text-green-500">+</text> 按钮添加持仓
</view>
</view>
<view
v-for="h in store.holdings"
:key="h.id"
class="px-5 py-4 border-b border-gray-50 last:border-none"
>
<view class="flex justify-between items-start mb-1">
<text class="font-medium text-gray-800 text-base">{{ h.name }}</text>
<view class="text-right">
<view class="font-bold text-gray-800">
¥{{ h.currentValue.toLocaleString() }}
</view>
<view class="text-sm" :class="pnlColor(h.currentValue - h.costBasis)">
{{ pnlSign(h.currentValue - h.costBasis) }}{{ ((h.currentValue - h.costBasis) / h.costBasis * 100).toFixed(2) }}%
</view>
</view>
</view>
<view class="flex justify-between items-center mt-1">
<text class="text-xs text-gray-400">成本 ¥{{ h.costBasis.toLocaleString() }}</text>
<view class="flex gap-3">
<text class="text-xs text-blue-400" @tap="openEdit(h.id)">编辑</text>
<text class="text-xs text-red-400" @tap="confirmDelete(h.id)">删除</text>
</view>
</view>
</view>
</view>
<!-- FAB -->
<view
class="fixed bottom-20 right-5 h-14 w-14 rounded-full bg-green-500 shadow-lg flex items-center justify-center"
@tap="openAdd"
>
<text class="text-white text-2xl leading-none">+</text>
</view>
<!-- 录入/编辑弹窗 -->
<view
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center px-6"
@tap.self="showForm = false"
>
<view class="w-full rounded-2xl bg-white p-6">
<view class="mb-4 text-lg font-medium text-gray-800">
{{ editingId ? '编辑持仓' : '添加持仓' }}
</view>
<view class="space-y-3">
<view>
<view class="text-sm text-gray-500 mb-1">
名称(如:沪深300ETF)
</view>
<input
v-model="form.name"
type="text"
placeholder="请输入持仓名称"
class="w-full rounded-xl border border-gray-200 px-4 py-3 text-base"
:adjust-position="false"
/>
</view>
<view>
<view class="text-sm text-gray-500 mb-1">
当前市值(元)
</view>
<input
v-model="form.currentValue"
type="digit"
placeholder="当前总市值"
class="w-full rounded-xl border border-gray-200 px-4 py-3 text-base"
:adjust-position="false"
/>
</view>
<view>
<view class="text-sm text-gray-500 mb-1">
成本(元)
</view>
<input
v-model="form.costBasis"
type="digit"
placeholder="买入总成本"
class="w-full rounded-xl border border-gray-200 px-4 py-3 text-base"
:adjust-position="false"
/>
</view>
</view>
<view class="mt-5 flex gap-3">
<view
class="flex-1 rounded-xl border border-gray-200 py-3 text-center text-gray-500"
@tap="showForm = false"
>
取消
</view>
<view
class="flex-1 rounded-xl bg-green-500 py-3 text-center text-white font-medium"
@tap="saveForm"
>
保存
</view>
</view>
</view>
</view>
</view> </view>
</template> </template>
+13 -2
View File
@@ -83,8 +83,19 @@ function saveSnapshot() {
<view class="px-5 py-3 border-b border-gray-100 font-medium text-gray-700"> <view class="px-5 py-3 border-b border-gray-100 font-medium text-gray-700">
历史记录 历史记录
</view> </view>
<view v-if="store.snapshots.length === 0" class="px-5 py-8 text-center text-gray-400"> <view v-if="store.snapshots.length === 0" class="px-5 py-10 flex flex-col items-center gap-3">
暂无记录,点击下方按钮录入 <view class="text-4xl">
📊
</view>
<view class="text-base font-medium text-gray-700">
记录你的第一笔净资产
</view>
<view class="text-sm text-gray-400 text-center leading-relaxed">
净资产 = 所有存款 + 投资账户 − 负债<br>点击右下角 <text class="font-bold text-green-500">+</text> 按钮录入当前总资产,开始追踪 FIRE 进度。
</view>
<view class="mt-1 text-xs text-gray-300">
建议每月或每季度更新一次
</view>
</view> </view>
<view <view
v-for="s in [...store.snapshots].reverse().slice(0, 10)" v-for="s in [...store.snapshots].reverse().slice(0, 10)"
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
export interface Holding {
id: string
name: string
currentValue: number
costBasis: number
}
export const useHoldingsStore = defineStore('holdings', () => {
const holdings = ref<Holding[]>([])
const totalValue = computed(() => holdings.value.reduce((s, h) => s + h.currentValue, 0))
const totalCost = computed(() => holdings.value.reduce((s, h) => s + h.costBasis, 0))
const totalPnl = computed(() => totalValue.value - totalCost.value)
const totalPnlPct = computed(() =>
totalCost.value > 0 ? (totalPnl.value / totalCost.value) * 100 : 0,
)
function add(name: string, currentValue: number, costBasis: number) {
holdings.value.push({
id: Date.now().toString(),
name,
currentValue,
costBasis,
})
}
function update(id: string, currentValue: number, costBasis: number) {
const h = holdings.value.find(h => h.id === id)
if (h) {
h.currentValue = currentValue
h.costBasis = costBasis
}
}
function remove(id: string) {
holdings.value = holdings.value.filter(h => h.id !== id)
}
return { holdings, totalValue, totalCost, totalPnl, totalPnlPct, add, update, remove }
}, { persist: true })
+6 -4
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const target = ref(710000) // 目標本金(元) const target = ref(710000) // 目标本金(元)
const withdrawRate = ref(0.035) // 安全提取率 3.5% const withdrawRate = ref(0.035) // 安全提取率 3.5%
const monthlyInvest = ref(1500) // 每月定投(元) const monthlyInvest = ref(1500) // 每月定投(元)
const partTimeIncome = ref(40000) // 兼職年收入(元) const partTimeIncome = ref(40000) // 兼职年收入(元)
const annualExpense = ref(65000) // 年開支(元) const annualExpense = ref(65000) // 年支出(元)
const initialCapital = ref(70000) // 起始本金(元) const initialCapital = ref(70000) // 起始本金(元),无进度记录时的回退值
const annualReturn = ref(0.06) // 投资年化收益率 6%
return { return {
target, target,
@@ -15,6 +16,7 @@ export const useSettingsStore = defineStore('settings', () => {
partTimeIncome, partTimeIncome,
annualExpense, annualExpense,
initialCapital, initialCapital,
annualReturn,
} }
}, { }, {
persist: true, persist: true,