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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user