第一次上传
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<section-card title="账单筛选" subtitle="按月份、分类、账户、金额区间和关键词组合查询">
|
||||
<view class="chip-row">
|
||||
<view
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
class="pill-button"
|
||||
:class="{ active: filters.type === item.value }"
|
||||
@click="filters.type = item.value; filters.categoryId = ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="chip-row compact-row">
|
||||
<view
|
||||
v-for="item in periodOptions"
|
||||
:key="item.value"
|
||||
class="pill-button mini-pill"
|
||||
:class="{ active: filters.period === item.value }"
|
||||
@click="filters.period = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
<picker mode="date" fields="month" :value="monthPickerValue" @change="onMonthChange">
|
||||
<view class="pill-button mini-pill">{{ selectedMonthLabel }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-grid">
|
||||
<view class="input-shell"><input v-model="filters.keyword" placeholder="备注、分类或账户关键词" /></view>
|
||||
<view class="double-grid">
|
||||
<view class="input-shell"><input v-model="filters.minAmount" type="digit" placeholder="最低金额" /></view>
|
||||
<view class="input-shell"><input v-model="filters.maxAmount" type="digit" placeholder="最高金额" /></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="picker-row">
|
||||
<picker :range="categoryOptionNames" :value="selectedCategoryIndex" @change="onCategoryChange">
|
||||
<view class="input-shell picker-shell"><text>{{ selectedCategoryName }}</text><text class="tiny-text">分类</text></view>
|
||||
</picker>
|
||||
<picker :range="accountOptionNames" :value="selectedAccountIndex" @change="onAccountChange">
|
||||
<view class="input-shell picker-shell"><text>{{ selectedAccountName }}</text><text class="tiny-text">账户</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="action-row">
|
||||
<view class="ghost-button" @click="clearFilters">清空筛选</view>
|
||||
<view class="primary-button" @click="openEditor()">新增账单</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="账单概览" :subtitle="`共 ${filteredBills.length} 笔记录,点击编辑,长按可删除`">
|
||||
<template #action>
|
||||
<text class="section-link" @click="toggleSelectionMode">{{ selectionMode ? '退出批量' : '批量删除' }}</text>
|
||||
</template>
|
||||
<view class="summary-row">
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">支出</text>
|
||||
<text style="margin-left: 16rpx;"></text>
|
||||
<text class="summary-value negative">{{ formatCurrency(summaryExpense) }}</text>
|
||||
</view>
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">收入</text>
|
||||
<text style="margin-left: 16rpx;"></text>
|
||||
<text class="summary-value positive">{{ formatCurrency(summaryIncome) }}</text>
|
||||
</view>
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">结余</text>
|
||||
<text style="margin-left: 16rpx;"></text>
|
||||
<text class="summary-value">{{ formatCurrency(summaryIncome - summaryExpense) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="filteredBills.length" class="bill-list">
|
||||
<view
|
||||
v-for="bill in filteredBills"
|
||||
:key="bill.id"
|
||||
class="bill-row"
|
||||
@click="selectionMode ? toggleChecked(bill.id) : openEditor(bill)"
|
||||
@longpress="handleBillLongPress(bill)"
|
||||
>
|
||||
<view class="row-main">
|
||||
<view v-if="selectionMode" class="check-box" :class="{ checked: selectedIds.includes(bill.id) }"></view>
|
||||
<view class="bill-dot" :style="{ background: getCategory(bill).color || '#7b8794' }"></view>
|
||||
<view class="bill-body">
|
||||
<text class="bill-title">{{ getCategory(bill).name || '未分类' }}</text>
|
||||
<text class="bill-meta">{{ getAccount(bill).name || '账户' }} · {{ formatDateLabel(bill.date) }} · {{ bill.note || '无备注' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="bill-amount" :class="bill.type === 'income' ? 'positive' : 'negative'">
|
||||
{{ bill.type === 'income' ? '+' : '-' }}{{ formatCurrency(bill.amount).replace('¥', '') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card">
|
||||
<text class="section-subtitle">当前筛选条件下没有符合条件的账单记录。</text>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<view v-if="selectionMode" class="surface-card batch-bar">
|
||||
<text class="batch-text">已选 {{ selectedIds.length }} 笔</text>
|
||||
<view class="ghost-button" @click="selectAllVisible">全选</view>
|
||||
<view class="ghost-button danger-button" @click="removeSelected">删除所选</view>
|
||||
</view>
|
||||
|
||||
<app-tab-bar current="bills" />
|
||||
<bill-editor-popup
|
||||
:visible="editorVisible"
|
||||
:entry="editingBill"
|
||||
:categories="store.state.categories"
|
||||
:accounts="store.state.accounts"
|
||||
@close="closeEditor"
|
||||
@save="saveBill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import SectionCard from '../../components/SectionCard.vue'
|
||||
import AppTabBar from '../../components/AppTabBar.vue'
|
||||
import BillEditorPopup from '../../components/BillEditorPopup.vue'
|
||||
import { useAppStore } from '../../utils/store'
|
||||
import { formatDateLabel, formatMonthLabel, isSameMonth, parseDate, toMonthKey } from '../../utils/date'
|
||||
import { formatCurrency } from '../../utils/money'
|
||||
|
||||
const store = useAppStore()
|
||||
const editorVisible = ref(false)
|
||||
const editingBill = ref(null)
|
||||
const selectionMode = ref(false)
|
||||
const selectedIds = ref([])
|
||||
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const filters = reactive({
|
||||
type: 'all',
|
||||
period: 'month',
|
||||
month: toMonthKey(),
|
||||
keyword: '',
|
||||
categoryId: '',
|
||||
accountId: '',
|
||||
minAmount: '',
|
||||
maxAmount: ''
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '支出', value: 'expense' },
|
||||
{ label: '收入', value: 'income' }
|
||||
]
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '近 7 天', value: '7d' },
|
||||
{ label: '近 30 天', value: '30d' },
|
||||
{ label: '全部', value: 'all' }
|
||||
]
|
||||
|
||||
const sortedBills = computed(() => [...store.state.bills].sort((left, right) => right.createdAt - left.createdAt))
|
||||
const categoryOptions = computed(() => {
|
||||
const categories = filters.type === 'all'
|
||||
? [...store.state.categories.expense, ...store.state.categories.income]
|
||||
: store.state.categories[filters.type]
|
||||
return [{ id: '', name: '全部分类' }, ...categories]
|
||||
})
|
||||
const accountOptions = computed(() => [{ id: '', name: '全部账户' }, ...store.state.accounts])
|
||||
const categoryOptionNames = computed(() => categoryOptions.value.map((item) => item.name))
|
||||
const accountOptionNames = computed(() => accountOptions.value.map((item) => item.name))
|
||||
const selectedCategoryIndex = computed(() => Math.max(0, categoryOptions.value.findIndex((item) => item.id === filters.categoryId)))
|
||||
const selectedAccountIndex = computed(() => Math.max(0, accountOptions.value.findIndex((item) => item.id === filters.accountId)))
|
||||
const selectedCategoryName = computed(() => categoryOptions.value[selectedCategoryIndex.value]?.name || '全部分类')
|
||||
const selectedAccountName = computed(() => accountOptions.value[selectedAccountIndex.value]?.name || '全部账户')
|
||||
const selectedMonthLabel = computed(() => formatMonthLabel(filters.month))
|
||||
const monthPickerValue = computed(() => filters.month)
|
||||
|
||||
function getDiffDays(dateKey) {
|
||||
const today = new Date()
|
||||
const current = parseDate(dateKey)
|
||||
return (today.getTime() - current.getTime()) / (1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
const filteredBills = computed(() => sortedBills.value.filter((bill) => {
|
||||
if (filters.type !== 'all' && bill.type !== filters.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.period === 'month' && !isSameMonth(bill.date, filters.month)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.period === '7d' && getDiffDays(bill.date) > 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.period === '30d' && getDiffDays(bill.date) > 30) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.categoryId && bill.categoryId !== filters.categoryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.accountId && bill.accountId !== filters.accountId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.minAmount && Number(bill.amount) < Number(filters.minAmount)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.maxAmount && Number(bill.amount) > Number(filters.maxAmount)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.keyword) {
|
||||
const categoryName = getCategory(bill).name || ''
|
||||
const accountName = getAccount(bill).name || ''
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const target = `${bill.note || ''} ${categoryName} ${accountName}`.toLowerCase()
|
||||
if (!target.includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}))
|
||||
|
||||
const summaryExpense = computed(() => filteredBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const summaryIncome = computed(() => filteredBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
|
||||
function getCategory(bill) {
|
||||
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId) || {}
|
||||
}
|
||||
|
||||
function getAccount(bill) {
|
||||
return store.state.accounts.find((item) => item.id === bill.accountId) || {}
|
||||
}
|
||||
|
||||
function onCategoryChange(event) {
|
||||
filters.categoryId = categoryOptions.value[Number(event.detail.value)]?.id || ''
|
||||
}
|
||||
|
||||
function onAccountChange(event) {
|
||||
filters.accountId = accountOptions.value[Number(event.detail.value)]?.id || ''
|
||||
}
|
||||
|
||||
function onMonthChange(event) {
|
||||
filters.month = String(event.detail.value).slice(0, 7)
|
||||
filters.period = 'month'
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.type = 'all'
|
||||
filters.period = 'month'
|
||||
filters.month = toMonthKey()
|
||||
filters.keyword = ''
|
||||
filters.categoryId = ''
|
||||
filters.accountId = ''
|
||||
filters.minAmount = ''
|
||||
filters.maxAmount = ''
|
||||
}
|
||||
|
||||
function openEditor(bill = null) {
|
||||
editingBill.value = bill ? { ...bill } : null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorVisible.value = false
|
||||
editingBill.value = null
|
||||
}
|
||||
|
||||
function saveBill(payload) {
|
||||
store.saveBill(payload)
|
||||
uni.showToast({
|
||||
title: payload.id ? '账单已更新' : '账单已保存',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
function removeBill(bill) {
|
||||
uni.showModal({
|
||||
title: '删除账单',
|
||||
content: `确认删除 ${getCategory(bill).name || '该账单'} 吗?`,
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) {
|
||||
store.deleteBill(bill.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleBillLongPress(bill) {
|
||||
uni.showActionSheet({
|
||||
itemList: ['编辑账单', '删除账单'],
|
||||
success: ({ tapIndex }) => {
|
||||
if (tapIndex === 0) {
|
||||
openEditor(bill)
|
||||
}
|
||||
if (tapIndex === 1) {
|
||||
removeBill(bill)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
selectionMode.value = !selectionMode.value
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function toggleChecked(id) {
|
||||
if (!selectionMode.value) {
|
||||
return
|
||||
}
|
||||
selectedIds.value = selectedIds.value.includes(id)
|
||||
? selectedIds.value.filter((item) => item !== id)
|
||||
: [...selectedIds.value, id]
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
selectedIds.value = filteredBills.value.map((item) => item.id)
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
if (!selectedIds.value.length) {
|
||||
uni.showToast({ title: '请选择账单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '批量删除',
|
||||
content: `确认删除 ${selectedIds.value.length} 笔账单吗?`,
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) {
|
||||
store.deleteBills(selectedIds.value)
|
||||
selectedIds.value = []
|
||||
selectionMode.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 200)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chip-row,
|
||||
.action-row,
|
||||
.picker-row,
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.compact-row {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.mini-pill {
|
||||
padding: 16rpx 22rpx;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin: 18rpx 0;
|
||||
}
|
||||
|
||||
.double-grid,
|
||||
.picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.picker-shell {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.action-row .ghost-button,
|
||||
.action-row .primary-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: 24rpx;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
margin-top: 10rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.bill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check-box {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 10rpx;
|
||||
border: 2rpx solid var(--brand);
|
||||
}
|
||||
|
||||
.check-box.checked {
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.bill-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bill-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bill-title,
|
||||
.bill-amount {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bill-meta {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
padding: 40rpx 0 10rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
position: fixed;
|
||||
left: 24rpx;
|
||||
right: 24rpx;
|
||||
bottom: 176rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
.batch-text {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<section-card title="月度预算" subtitle="设置总预算与分类预算,控制消费节奏">
|
||||
<view class="budget-hero surface-strong">
|
||||
<view>
|
||||
<text class="tiny-text">本月总预算</text>
|
||||
<text class="budget-number">{{ formatCurrency(totalBudget) }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="tiny-text">本月已支出</text>
|
||||
<text class="budget-number negative">{{ formatCurrency(spentTotal) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: usagePercentWidth }"></view>
|
||||
</view>
|
||||
<view class="budget-foot">
|
||||
<text class="tiny-text">使用进度 {{ usagePercentLabel }}</text>
|
||||
<text class="tiny-text">{{ remainingBudget >= 0 ? `剩余 ${formatCurrency(remainingBudget)}` : `已超支 ${formatCurrency(Math.abs(remainingBudget))}` }}</text>
|
||||
</view>
|
||||
<view class="budget-foot secondary-foot">
|
||||
<text class="tiny-text">{{ totalBudget ? `日均可用 ${formatCurrency(dailyAllowance)}` : '设置预算后可查看剩余额度分配' }}</text>
|
||||
</view>
|
||||
<view class="editor-row">
|
||||
<view class="input-shell"><input v-model="totalBudgetInput" type="digit" placeholder="输入本月总预算" /></view>
|
||||
<view class="primary-button save-btn" @click="saveTotalBudget">保存</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card v-if="overBudgetRows.length" title="超支提醒" subtitle="当前分类预算已被突破,建议尽快调整">
|
||||
<view class="alert-list">
|
||||
<view v-for="item in overBudgetRows" :key="item.id" class="alert-item">
|
||||
<text class="alert-title">{{ item.name }}</text>
|
||||
<text class="alert-text">预算 {{ formatCurrency(item.budget) }},已支出 {{ formatCurrency(item.spent) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="分类预算" subtitle="为高频分类分别设置预算,减少超支风险">
|
||||
<view class="category-list">
|
||||
<view v-for="item in categoryRows" :key="item.id" class="category-card surface-strong">
|
||||
<view class="category-head">
|
||||
<view class="category-title-row">
|
||||
<view class="category-dot" :style="{ background: item.color }"></view>
|
||||
<text class="category-title">{{ item.name }}</text>
|
||||
</view>
|
||||
<text class="tiny-text">已花 {{ formatCurrency(item.spent) }}</text>
|
||||
</view>
|
||||
<view class="progress-track thin-track">
|
||||
<view class="progress-fill" :style="{ width: item.progressWidth }"></view>
|
||||
</view>
|
||||
<view class="budget-foot">
|
||||
<text class="tiny-text">预算 {{ formatCurrency(item.budget) }}</text>
|
||||
<text class="tiny-text" :class="item.budget > 0 && item.spent > item.budget ? 'negative' : ''">{{ item.progressLabel }}</text>
|
||||
</view>
|
||||
<view class="editor-row">
|
||||
<view class="input-shell"><input v-model="categoryBudgetDrafts[item.id]" type="digit" placeholder="设置分类预算" /></view>
|
||||
<view class="ghost-button save-btn" @click="saveCategoryBudget(item.id)">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<app-tab-bar current="budget" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import SectionCard from '../../components/SectionCard.vue'
|
||||
import AppTabBar from '../../components/AppTabBar.vue'
|
||||
import { useAppStore } from '../../utils/store'
|
||||
import { getDaysLeftInMonth, isSameMonth, toMonthKey } from '../../utils/date'
|
||||
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
|
||||
|
||||
const store = useAppStore()
|
||||
const currentMonth = computed(() => toMonthKey())
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const totalBudgetInput = ref(String(store.state.budgets.total || ''))
|
||||
const categoryBudgetDrafts = reactive({})
|
||||
|
||||
const expenseBills = computed(() => store.state.bills.filter((item) => item.type === 'expense' && isSameMonth(item.date, currentMonth.value)))
|
||||
const spentTotal = computed(() => expenseBills.value.reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const totalBudget = computed(() => Number(store.state.budgets.total) || 0)
|
||||
const remainingBudget = computed(() => totalBudget.value - spentTotal.value)
|
||||
const dailyAllowance = computed(() => Math.max(remainingBudget.value, 0) / Math.max(1, getDaysLeftInMonth(currentMonth.value)))
|
||||
const usagePercentLabel = computed(() => (totalBudget.value ? formatPercent(spentTotal.value / Math.max(totalBudget.value, 1)) : '未设置'))
|
||||
const usagePercentWidth = computed(() => clampPercent(spentTotal.value / Math.max(totalBudget.value || 1, 1)))
|
||||
|
||||
const categoryRows = computed(() => store.state.categories.expense.map((category) => {
|
||||
const spent = expenseBills.value
|
||||
.filter((item) => item.categoryId === category.id)
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
const budget = Number(store.state.budgets.categoryBudgets[category.id] || 0)
|
||||
return {
|
||||
...category,
|
||||
spent,
|
||||
budget,
|
||||
progressLabel: budget ? formatPercent(spent / Math.max(budget, 1)) : '未设置',
|
||||
progressWidth: clampPercent(spent / Math.max(budget || 1, 1))
|
||||
}
|
||||
}))
|
||||
|
||||
const overBudgetRows = computed(() => categoryRows.value.filter((item) => item.budget > 0 && item.spent > item.budget))
|
||||
|
||||
watch(
|
||||
() => store.state.budgets.total,
|
||||
(value) => {
|
||||
totalBudgetInput.value = String(value || '')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
categoryRows,
|
||||
(rows) => {
|
||||
rows.forEach((item) => {
|
||||
categoryBudgetDrafts[item.id] = String(item.budget || '')
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function saveTotalBudget() {
|
||||
store.setBudgetTotal(totalBudgetInput.value)
|
||||
uni.showToast({ title: '总预算已保存', icon: 'none' })
|
||||
}
|
||||
|
||||
function saveCategoryBudget(categoryId) {
|
||||
store.setCategoryBudget(categoryId, categoryBudgetDrafts[categoryId])
|
||||
uni.showToast({ title: '分类预算已保存', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.budget-hero,
|
||||
.category-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.budget-hero,
|
||||
.category-head,
|
||||
.category-title-row,
|
||||
.budget-foot,
|
||||
.editor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.secondary-foot {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.budget-number {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
overflow: hidden;
|
||||
height: 18rpx;
|
||||
margin: 24rpx 0 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.thin-track {
|
||||
height: 14rpx;
|
||||
margin: 18rpx 0 14rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #5f8df5 0%, #1f6f5f 100%);
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.editor-row .input-shell {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
flex: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.alert-list,
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 20rpx 22rpx;
|
||||
border-radius: 22rpx;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.category-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<view class="surface-card hero-card">
|
||||
<text class="hero-date">{{ todayLabel }}</text>
|
||||
<text class="hero-title">账单小管家</text>
|
||||
<!-- <text class="hero-subtitle">轻量记账、预算管理、消费统计,所有账单仅保存在当前设备。</text> -->
|
||||
<view class="hero-metrics">
|
||||
<view class="metric-block">
|
||||
<text class="tiny-text">今日支出</text>
|
||||
<text class="metric-value negative">{{ formatCurrency(todayExpense) }}</text>
|
||||
</view>
|
||||
<view class="metric-block">
|
||||
<text class="tiny-text">本月支出</text>
|
||||
<text class="metric-value negative">{{ formatCurrency(monthExpense) }}</text>
|
||||
</view>
|
||||
<view class="metric-block">
|
||||
<text class="tiny-text">本月收入</text>
|
||||
<text class="metric-value positive">{{ formatCurrency(monthIncome) }}</text>
|
||||
</view>
|
||||
<view class="metric-block">
|
||||
<text class="tiny-text">本月结余</text>
|
||||
<text class="metric-value">{{ formatCurrency(balance) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<ad-custom unit-id="adunit-74730c6c27c95a37"></ad-custom>
|
||||
|
||||
<section-card title="预算概览" subtitle="实时查看预算执行情况与剩余额度">
|
||||
<view class="budget-head">
|
||||
<view>
|
||||
<text class="budget-value">{{ formatCurrency(remainingBudget) }}</text>
|
||||
<text style="margin-left: 16rpx;"></text>
|
||||
<text class="tiny-text">剩余预算</text>
|
||||
</view>
|
||||
<view class="budget-side">
|
||||
<text class="tiny-text">预算使用</text>
|
||||
<text style="margin-left: 16rpx;"></text>
|
||||
<text class="budget-percent">{{ budgetProgressLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: budgetProgressWidth }"></view>
|
||||
</view>
|
||||
<view class="budget-note-row">
|
||||
<text class="tiny-text">{{ totalBudget ? `总预算 ${formatCurrency(totalBudget)}` : '当前尚未设置月预算' }}</text>
|
||||
<text class="tiny-text">{{ dailyBudgetText }}</text>
|
||||
</view>
|
||||
<view v-if="!totalBudget" class="budget-action-row">
|
||||
<view class="ghost-button" @click="goBudget">去设置预算</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="快捷记账" subtitle="常用场景一步录入,提高日常记录效率">
|
||||
<view class="quick-action-row">
|
||||
<view class="primary-button quick-main" @click="openQuickAdd('', 'expense')">记录支出</view>
|
||||
<view class="ghost-button quick-main" @click="openQuickAdd('', 'income')">记录收入</view>
|
||||
</view>
|
||||
<view class="quick-grid">
|
||||
<view
|
||||
v-for="item in quickCategories"
|
||||
:key="item.id"
|
||||
class="quick-chip"
|
||||
@click="openQuickAdd(item.id, 'expense')"
|
||||
>
|
||||
<view class="quick-dot" :style="{ background: item.color }"></view>
|
||||
<text>{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="最近记录" subtitle="保留最近 5 笔账单,长按可编辑或删除">
|
||||
<template #action>
|
||||
<text class="section-link" @click="goBills">查看全部</text>
|
||||
</template>
|
||||
<view v-if="recentBills.length" class="bill-list">
|
||||
<view
|
||||
v-for="bill in recentBills"
|
||||
:key="bill.id"
|
||||
class="bill-item"
|
||||
@longpress="handleBillLongPress(bill)"
|
||||
>
|
||||
<view class="bill-leading">
|
||||
<view class="bill-dot" :style="{ background: getCategory(bill).color || '#7b8794' }"></view>
|
||||
<view>
|
||||
<text class="bill-title">{{ getCategory(bill).name || '未分类' }}</text>
|
||||
<text class="bill-meta">{{ getAccount(bill).name || '账户' }} · {{ formatDateLabel(bill.date) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bill-right">
|
||||
<text class="bill-amount" :class="bill.type === 'income' ? 'positive' : 'negative'">
|
||||
{{ bill.type === 'income' ? '+' : '-' }}{{ formatCurrency(bill.amount).replace('¥', '') }}
|
||||
</text>
|
||||
<text class="tiny-text">{{ bill.note || '无备注' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card">
|
||||
<text class="section-subtitle">还没有账单记录,先记下第一笔收支。</text>
|
||||
<view class="ghost-button empty-action" @click="openQuickAdd('', 'expense')">立即记账</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<view class="fab-button" @click="openQuickAdd('', 'expense')">+ 记一笔</view>
|
||||
<app-tab-bar current="home" />
|
||||
<bill-editor-popup
|
||||
:visible="editorVisible"
|
||||
:entry="editingBill"
|
||||
:categories="store.state.categories"
|
||||
:accounts="store.state.accounts"
|
||||
:default-type="quickType"
|
||||
:initial-category-id="quickCategoryId"
|
||||
@close="closeEditor"
|
||||
@save="saveBill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh,onShareAppMessage,onShow,onLoad } from '@dcloudio/uni-app'
|
||||
import SectionCard from '../../components/SectionCard.vue'
|
||||
import AppTabBar from '../../components/AppTabBar.vue'
|
||||
import BillEditorPopup from '../../components/BillEditorPopup.vue'
|
||||
import { useAppStore } from '../../utils/store'
|
||||
import { formatDateLabel, getDaysLeftInMonth, isSameMonth, toDateKey, toMonthKey } from '../../utils/date'
|
||||
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
|
||||
|
||||
const store = useAppStore()
|
||||
const editorVisible = ref(false)
|
||||
const editingBill = ref(null)
|
||||
const quickCategoryId = ref('')
|
||||
const quickType = ref('expense')
|
||||
|
||||
const todayKey = computed(() => toDateKey())
|
||||
const currentMonth = computed(() => toMonthKey())
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const todayLabel = computed(() => formatDateLabel(todayKey.value))
|
||||
|
||||
const sortedBills = computed(() => [...store.state.bills].sort((left, right) => right.createdAt - left.createdAt))
|
||||
const recentBills = computed(() => sortedBills.value.slice(0, 5))
|
||||
const todayBills = computed(() => sortedBills.value.filter((item) => item.date === todayKey.value))
|
||||
const monthBills = computed(() => sortedBills.value.filter((item) => isSameMonth(item.date, currentMonth.value)))
|
||||
const todayExpense = computed(() => todayBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const monthExpense = computed(() => monthBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const monthIncome = computed(() => monthBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const balance = computed(() => monthIncome.value - monthExpense.value)
|
||||
const totalBudget = computed(() => Number(store.state.budgets.total) || 0)
|
||||
const remainingBudget = computed(() => totalBudget.value - monthExpense.value)
|
||||
const dailyAllowance = computed(() => Math.max(remainingBudget.value, 0) / Math.max(1, getDaysLeftInMonth(currentMonth.value)))
|
||||
const budgetProgressWidth = computed(() => clampPercent(monthExpense.value / Math.max(totalBudget.value || 1, 1)))
|
||||
const budgetProgressLabel = computed(() => (totalBudget.value ? formatPercent(monthExpense.value / Math.max(totalBudget.value, 1)) : '未设置'))
|
||||
const dailyBudgetText = computed(() => {
|
||||
if (!totalBudget.value) {
|
||||
return '设置预算后可查看日均可用额度'
|
||||
}
|
||||
|
||||
if (remainingBudget.value < 0) {
|
||||
return `已超支 ${formatCurrency(Math.abs(remainingBudget.value))}`
|
||||
}
|
||||
|
||||
return `日均可用 ${formatCurrency(dailyAllowance.value)}`
|
||||
})
|
||||
const quickCategories = computed(() => store.state.categories.expense.slice(0, 6))
|
||||
|
||||
function getCategory(bill) {
|
||||
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId) || {}
|
||||
}
|
||||
|
||||
function getAccount(bill) {
|
||||
return store.state.accounts.find((item) => item.id === bill.accountId) || {}
|
||||
}
|
||||
|
||||
function openQuickAdd(categoryId = '', type = 'expense') {
|
||||
quickCategoryId.value = categoryId
|
||||
quickType.value = type
|
||||
editingBill.value = null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorVisible.value = false
|
||||
editingBill.value = null
|
||||
quickCategoryId.value = ''
|
||||
quickType.value = 'expense'
|
||||
}
|
||||
|
||||
function saveBill(payload) {
|
||||
store.saveBill(payload)
|
||||
uni.showToast({
|
||||
title: payload.id ? '账单已更新' : '账单已保存',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDelete(bill) {
|
||||
uni.showModal({
|
||||
title: '删除账单',
|
||||
content: `确认删除 ${getCategory(bill).name || '这笔账单'} 吗?`,
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) {
|
||||
store.deleteBill(bill.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleBillLongPress(bill) {
|
||||
uni.showActionSheet({
|
||||
itemList: ['编辑账单', '删除账单'],
|
||||
success: ({ tapIndex }) => {
|
||||
if (tapIndex === 0) {
|
||||
editingBill.value = { ...bill }
|
||||
editorVisible.value = true
|
||||
}
|
||||
if (tapIndex === 1) {
|
||||
confirmDelete(bill)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBills() {
|
||||
uni.redirectTo({
|
||||
url: '/pages/bills/index'
|
||||
})
|
||||
}
|
||||
|
||||
function goBudget() {
|
||||
uni.redirectTo({
|
||||
url: '/pages/budget/index'
|
||||
})
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
const showFlag = ref(false);
|
||||
|
||||
onLoad(()=>{
|
||||
showIt()
|
||||
})
|
||||
|
||||
onShow(()=>{
|
||||
showIt()
|
||||
})
|
||||
|
||||
function showIt(){
|
||||
if (showFlag.value){
|
||||
return
|
||||
}
|
||||
showFlag.value = true
|
||||
let interstitialAd = null;
|
||||
if (wx.createInterstitialAd) {
|
||||
interstitialAd = wx.createInterstitialAd({
|
||||
adUnitId: 'adunit-0abc32053b19a4e9'
|
||||
})
|
||||
interstitialAd.onLoad(() => {})
|
||||
interstitialAd.onError((err) => {
|
||||
console.error('插屏广告加载失败', err)
|
||||
})
|
||||
interstitialAd.onClose(() => {})
|
||||
}
|
||||
|
||||
setTimeout(()=>{
|
||||
if (interstitialAd) {
|
||||
|
||||
interstitialAd.show().catch((err) => {
|
||||
console.error('插屏广告显示失败', err)
|
||||
})
|
||||
showFlag.value = false
|
||||
}
|
||||
}, 5480)
|
||||
}
|
||||
|
||||
onShareAppMessage((res) => {
|
||||
// res.from === 'button' 代表来自页面内按钮
|
||||
// res.from === 'menu' 代表来自右上角菜单
|
||||
return {
|
||||
title: '账单助手', // 分享卡片标题
|
||||
desc:'本地单机极简记账,支持收支记录、预算管控、消费报表,数据安全私密,轻便好用的个人账单管家。',
|
||||
path: '/pages/home/index', // 分享后点击跳转的页面(必须是绝对路径)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hero-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
padding: 32rpx;
|
||||
background: var(--bg-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-date,
|
||||
.hero-subtitle,
|
||||
.hero-card .tiny-text {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 46rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.metric-block {
|
||||
padding: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.budget-head,
|
||||
.budget-note-row,
|
||||
.quick-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.budget-side {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.budget-value,
|
||||
.budget-percent {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
overflow: hidden;
|
||||
height: 18rpx;
|
||||
margin: 24rpx 0 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #d36c43 0%, #1f6f5f 100%);
|
||||
}
|
||||
|
||||
.budget-action-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.quick-action-row .quick-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.quick-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
background: var(--surface-muted);
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quick-dot,
|
||||
.bill-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: 24rpx;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.bill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.bill-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.bill-leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bill-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.bill-title,
|
||||
.bill-amount {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bill-meta {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
padding: 28rpx 0 8rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
bottom: 348rpx;
|
||||
padding: 24rpx 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--bg-accent);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 20rpx 42rpx rgba(16, 42, 67, 0.24);
|
||||
z-index: 18;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<view class="content">
|
||||
<image class="logo" src="/static/logo.png"></image>
|
||||
<view class="text-area">
|
||||
<text class="title">{{title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
title: 'Hello'
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 200rpx;
|
||||
width: 200rpx;
|
||||
margin-top: 200rpx;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 50rpx;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
color: #8f8f94;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<view class="surface-card page-hero">
|
||||
<text class="hero-kicker">ABOUT</text>
|
||||
<text class="hero-title">关于与隐私</text>
|
||||
<text class="hero-desc">查看应用定位、数据说明与发布前应补齐的正式信息。</text>
|
||||
<view class="hero-tags">
|
||||
<!-- <text class="hero-tag">版本 1.0.0</text> -->
|
||||
<text class="hero-tag soft">发布说明</text>
|
||||
</view>
|
||||
</view>
|
||||
<ad-custom unit-id="adunit-64707ea333329399"></ad-custom>
|
||||
<section-card title="关于应用" subtitle="面向日常收支记录、预算控制与月度复盘的轻量工具">
|
||||
<view class="about-card surface-strong">
|
||||
<text class="app-name">账单小管家</text>
|
||||
<!-- <text class="app-version">版本 1.0.0</text> -->
|
||||
<text class="about-text">定位为轻量、无广告的本地记账工具,适合学生、情侣、合租和个人日常记账场景。</text>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="隐私与数据" subtitle="">
|
||||
<view class="info-list">
|
||||
<view class="info-item surface-strong">
|
||||
<text class="info-title">本地存储</text>
|
||||
<text class="info-text">账单、预算、分类、账户和设置默认保存在当前设备本地。</text>
|
||||
</view>
|
||||
<view class="info-item surface-strong">
|
||||
<text class="info-title">本地昵称</text>
|
||||
<text class="info-text">昵称仅保存在当前设备,用于个人页展示和首字头像。</text>
|
||||
</view>
|
||||
<view class="info-item surface-strong">
|
||||
<text class="info-title">数据迁移</text>
|
||||
<text class="info-text">如需换机迁移,可在“备份与恢复”中导出 JSON 并在新设备恢复。</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import SectionCard from '../../../components/SectionCard.vue'
|
||||
import { useAppStore } from '../../../utils/store'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
onShow(()=>{
|
||||
showIt()
|
||||
})
|
||||
|
||||
function showIt(){
|
||||
let interstitialAd = null;
|
||||
if (wx.createInterstitialAd) {
|
||||
interstitialAd = wx.createInterstitialAd({
|
||||
adUnitId: 'adunit-0abc32053b19a4e9'
|
||||
})
|
||||
interstitialAd.onLoad(() => {})
|
||||
interstitialAd.onError((err) => {
|
||||
console.error('插屏广告加载失败', err)
|
||||
})
|
||||
interstitialAd.onClose(() => {})
|
||||
}
|
||||
|
||||
setTimeout(()=>{
|
||||
if (interstitialAd) {
|
||||
interstitialAd.show().catch((err) => {
|
||||
console.error('插屏广告显示失败', err)
|
||||
})
|
||||
}
|
||||
}, 2280)
|
||||
}
|
||||
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-hero {
|
||||
padding: 30rpx;
|
||||
background: linear-gradient(145deg, rgba(16, 42, 67, 0.96) 0%, rgba(31, 111, 95, 0.88) 60%, rgba(212, 108, 67, 0.82) 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-kicker,
|
||||
.hero-desc,
|
||||
.hero-tag.soft {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.about-card,
|
||||
.info-item,
|
||||
.tips-card {
|
||||
padding: 26rpx;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.app-name,
|
||||
.info-title {
|
||||
display: block;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.about-text,
|
||||
.info-text,
|
||||
.tip-line {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tip-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.tip-index {
|
||||
width: 56rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<view class="surface-card page-hero">
|
||||
<text class="hero-kicker">BACKUP</text>
|
||||
<text class="hero-title">备份与恢复</text>
|
||||
<text class="hero-desc">导出本地 JSON 备份,恢复时覆盖当前设备数据,适合换机或手动留档。</text>
|
||||
<view class="hero-tags">
|
||||
<text class="hero-tag">本地文件</text>
|
||||
<text class="hero-tag soft">{{ store.state.settings.lastBackupAt ? '最近已备份' : '尚未备份' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<ad-custom unit-id="adunit-64707ea333329399"></ad-custom>
|
||||
<section-card title="导出备份" subtitle="将账单、预算和设置导出为 JSON 文件,便于迁移设备或手动保存">
|
||||
<view class="hero-panel surface-strong">
|
||||
<view>
|
||||
<text class="panel-title">安全备份当前数据</text>
|
||||
<text class="panel-desc">所有数据均为本地文件,不会自动上传服务器。</text>
|
||||
</view>
|
||||
<view class="status-chip">{{ store.state.settings.lastBackupAt ? '可继续备份' : '建议先备份' }}</view>
|
||||
</view>
|
||||
<view class="action-row single-row">
|
||||
<view class="primary-button" @click="exportBackupFile">导出备份</view>
|
||||
</view>
|
||||
<text class="tiny-text info-line" v-if="store.state.settings.lastBackupAt">最近备份:{{ store.state.settings.lastBackupAt }}</text>
|
||||
</section-card>
|
||||
|
||||
<section-card title="恢复备份" subtitle="粘贴之前导出的 JSON 内容,恢复后将覆盖当前设备数据">
|
||||
<view class="input-shell textarea-shell">
|
||||
<textarea v-model="importText" placeholder="请粘贴备份 JSON"></textarea>
|
||||
</view>
|
||||
<view class="action-row">
|
||||
<view class="ghost-button" @click="importText = ''">清空内容</view>
|
||||
<view class="primary-button" @click="restoreBackup">开始恢复</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="数据操作" subtitle="谨慎执行不可撤销的本地清理操作">
|
||||
<view class="menu-list">
|
||||
<view class="menu-item surface-strong" @click="copyBackupText">
|
||||
<view>
|
||||
<text class="menu-title">复制备份内容</text>
|
||||
</view>
|
||||
<text class="status-chip">复制</text>
|
||||
</view>
|
||||
<view class="menu-item surface-strong danger-shell" @click="clearCache">
|
||||
<view>
|
||||
<text class="menu-title negative">清空全部数据</text>
|
||||
</view>
|
||||
<text class="danger-text">执行</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import SectionCard from '../../../components/SectionCard.vue'
|
||||
import { useAppStore } from '../../../utils/store'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
const store = useAppStore()
|
||||
const importText = ref('')
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
|
||||
function writeBackupFile(content) {
|
||||
const timeLabel = new Date().toLocaleString()
|
||||
if (typeof wx !== 'undefined' && wx.getFileSystemManager) {
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/bill-helper-backup.json`
|
||||
wx.getFileSystemManager().writeFile({
|
||||
filePath,
|
||||
data: content,
|
||||
encoding: 'utf8',
|
||||
success: () => {
|
||||
store.markBackup(timeLabel)
|
||||
uni.showModal({ title: '备份成功', content: `文件已生成:${filePath}`, showCancel: false })
|
||||
},
|
||||
fail: () => {
|
||||
uni.setClipboardData({ data: content })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
store.markBackup(timeLabel)
|
||||
uni.setClipboardData({ data: content })
|
||||
}
|
||||
|
||||
function exportBackupFile() {
|
||||
writeBackupFile(store.exportBackup())
|
||||
}
|
||||
|
||||
function restoreBackup() {
|
||||
if (!importText.value.trim()) {
|
||||
uni.showToast({ title: '请先粘贴备份内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
store.importBackup(importText.value)
|
||||
importText.value = ''
|
||||
uni.showToast({ title: '备份恢复成功', icon: 'none' })
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '备份内容无效', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBackupText() {
|
||||
uni.setClipboardData({ data: store.exportBackup() })
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
uni.showModal({
|
||||
title: '清空全部数据',
|
||||
content: '确认删除当前设备中的账单、预算和设置吗?此操作不可撤销。',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) {
|
||||
store.resetAll()
|
||||
uni.showToast({ title: '本地数据已清空', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onShow(()=>{
|
||||
showIt()
|
||||
})
|
||||
|
||||
function showIt(){
|
||||
let interstitialAd = null;
|
||||
if (wx.createInterstitialAd) {
|
||||
interstitialAd = wx.createInterstitialAd({
|
||||
adUnitId: 'adunit-0abc32053b19a4e9'
|
||||
})
|
||||
interstitialAd.onLoad(() => {})
|
||||
interstitialAd.onError((err) => {
|
||||
console.error('插屏广告加载失败', err)
|
||||
})
|
||||
interstitialAd.onClose(() => {})
|
||||
}
|
||||
|
||||
setTimeout(()=>{
|
||||
if (interstitialAd) {
|
||||
interstitialAd.show().catch((err) => {
|
||||
console.error('插屏广告显示失败', err)
|
||||
})
|
||||
}
|
||||
}, 2280)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-hero {
|
||||
padding: 30rpx;
|
||||
background: linear-gradient(145deg, rgba(16, 42, 67, 0.96) 0%, rgba(61, 102, 178, 0.92) 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-kicker,
|
||||
.hero-desc,
|
||||
.hero-tag.soft {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.hero-tag,
|
||||
.status-chip {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.menu-item,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.menu-item {
|
||||
padding: 26rpx;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.menu-title {
|
||||
display: block;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-desc,
|
||||
|
||||
.status-chip {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.single-row .primary-button,
|
||||
.action-row .ghost-button,
|
||||
.action-row .primary-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-line {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.textarea-shell {
|
||||
align-items: flex-start;
|
||||
min-height: 280rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.textarea-shell textarea {
|
||||
min-height: 240rpx;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.danger-shell {
|
||||
border: 1rpx solid rgba(210, 85, 67, 0.12);
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<view class="surface-card page-hero">
|
||||
<text class="hero-kicker">GUIDE</text>
|
||||
<text class="hero-title">使用帮助</text>
|
||||
<text class="hero-desc">通过上手步骤、常见问题和快捷入口,快速熟悉整个记账流程。</text>
|
||||
<view class="hero-tags">
|
||||
<text class="hero-tag">4 个步骤</text>
|
||||
<text class="hero-tag soft">FAQ 指南</text>
|
||||
</view>
|
||||
</view>
|
||||
<ad-custom unit-id="adunit-64707ea333329399"></ad-custom>
|
||||
<section-card title="快速上手" subtitle="初次使用建议先完成下面 4 个动作">
|
||||
<view class="step-list">
|
||||
<view v-for="item in quickSteps" :key="item.title" class="step-item surface-strong">
|
||||
<text class="step-index">{{ item.index }}</text>
|
||||
<view class="step-body">
|
||||
<text class="step-title">{{ item.title }}</text>
|
||||
<text class="step-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="常见问题" subtitle="">
|
||||
<view class="faq-list">
|
||||
<view v-for="item in faqList" :key="item.q" class="faq-item surface-strong">
|
||||
<text class="faq-question">{{ item.q }}</text>
|
||||
<text class="faq-answer">{{ item.a }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="功能入口" subtitle="需要操作时可直接跳转到对应模块">
|
||||
<view class="entry-grid">
|
||||
<view class="entry-item surface-strong" @click="go('/pages/home/index')">
|
||||
<text class="entry-title">首页记账</text>
|
||||
</view>
|
||||
<view class="entry-item surface-strong" @click="go('/pages/budget/index')">
|
||||
<text class="entry-title">预算设置</text>
|
||||
</view>
|
||||
<view class="entry-item surface-strong" @click="go('/pages/stats/index')">
|
||||
<text class="entry-title">查看报表</text>
|
||||
</view>
|
||||
<view class="entry-item surface-strong" @click="go('/pages/mine/backup/index')">
|
||||
<text class="entry-title">备份恢复</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import SectionCard from '../../../components/SectionCard.vue'
|
||||
import { useAppStore } from '../../../utils/store'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
|
||||
const store = useAppStore()
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
|
||||
const quickSteps = [
|
||||
{ index: '01', title: '先设置月预算', desc: '进入预算页设置总预算与分类预算,首页会同步显示剩余额度。' },
|
||||
{ index: '02', title: '用首页快捷记账', desc: '首页支持支出/收入快速录入,也能通过常用分类一键记账。' },
|
||||
{ index: '03', title: '到账单页做筛选', desc: '账单页支持按月份、账户、金额区间和关键词精确筛选。' },
|
||||
{ index: '04', title: '每月查看报表', desc: '报表页可导出 CSV,并查看支出结构、近 7 日趋势和月度对比。' }
|
||||
]
|
||||
|
||||
const faqList = [
|
||||
{ q: '账单数据保存在哪里?', a: '默认仅保存在当前设备的本地存储中,不会自动上传云端。' },
|
||||
{ q: '换手机后如何迁移?', a: '先进入“备份与恢复”导出 JSON 备份,再在新设备粘贴恢复。' },
|
||||
{ q: '为什么预算进度显示超出 100%?', a: '这代表本月支出已经超过预算,条形进度会封顶,但文字会继续显示真实比例。' },
|
||||
{ q: '昵称可以怎么修改?', a: '进入“我的-账户资料”后可直接修改本地昵称,留空时默认显示为“用户”。' }
|
||||
]
|
||||
|
||||
function go(url) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
onShow(()=>{
|
||||
showIt()
|
||||
})
|
||||
|
||||
function showIt(){
|
||||
let interstitialAd = null;
|
||||
if (wx.createInterstitialAd) {
|
||||
interstitialAd = wx.createInterstitialAd({
|
||||
adUnitId: 'adunit-0abc32053b19a4e9'
|
||||
})
|
||||
interstitialAd.onLoad(() => {})
|
||||
interstitialAd.onError((err) => {
|
||||
console.error('插屏广告加载失败', err)
|
||||
})
|
||||
interstitialAd.onClose(() => {})
|
||||
}
|
||||
|
||||
setTimeout(()=>{
|
||||
if (interstitialAd) {
|
||||
interstitialAd.show().catch((err) => {
|
||||
console.error('插屏广告显示失败', err)
|
||||
})
|
||||
}
|
||||
}, 2280)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-hero {
|
||||
padding: 30rpx;
|
||||
background: linear-gradient(145deg, rgba(16, 42, 67, 0.96) 0%, rgba(127, 86, 217, 0.9) 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-kicker,
|
||||
.hero-desc,
|
||||
.hero-tag.soft {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.step-list,
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.step-item,
|
||||
.faq-item {
|
||||
padding: 24rpx;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.step-index {
|
||||
width: 74rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.step-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title,
|
||||
.faq-question,
|
||||
.entry-title {
|
||||
display: block;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-desc,
|
||||
.faq-answer {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.75;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<section-card title="账户概览" subtitle="集中管理本地昵称与主题设置">
|
||||
<view class="profile-card surface-strong" @click="go('/pages/mine/profile/index')">
|
||||
<view class="avatar-shell">{{ avatarText }}</view>
|
||||
<view class="profile-body">
|
||||
<text class="profile-name">{{ profileName }}</text>
|
||||
<text class="section-subtitle"></text>
|
||||
</view>
|
||||
<text class="arrow-text">{{right}}</text>
|
||||
</view>
|
||||
<view class="theme-row">
|
||||
<view class="pill-button" :class="{ active: store.state.settings.theme === 'light' }" @click.stop="setTheme('light')">浅色</view>
|
||||
<view class="pill-button" :class="{ active: store.state.settings.theme === 'dark' }" @click.stop="setTheme('dark')">深色</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<ad-custom unit-id="adunit-74730c6c27c95a37"></ad-custom>
|
||||
|
||||
<section-card title="数据管理" subtitle="备份、恢复和清理等高频操作统一收口">
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="go('/pages/mine/backup/index')">
|
||||
<view>
|
||||
<text class="menu-title">备份与恢复</text>
|
||||
</view>
|
||||
<text class="arrow-text">{{right}}</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="go('/pages/mine/guide/index')">
|
||||
<view>
|
||||
<text class="menu-title">使用帮助</text>
|
||||
</view>
|
||||
<text class="arrow-text">{{right}}</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="go('/pages/mine/about/index')">
|
||||
<view>
|
||||
<text class="menu-title">关于与隐私</text>
|
||||
</view>
|
||||
<text class="arrow-text">{{right}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
|
||||
|
||||
<app-tab-bar current="mine" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed,ref } from 'vue'
|
||||
import SectionCard from '../../components/SectionCard.vue'
|
||||
import AppTabBar from '../../components/AppTabBar.vue'
|
||||
import { useAppStore } from '../../utils/store'
|
||||
|
||||
const store = useAppStore()
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const profileName = computed(() => store.state.settings.profile.nickname || '用户')
|
||||
const avatarText = computed(() => (store.state.settings.profile.nickname || '用户').slice(0, 1))
|
||||
|
||||
const right = ref(">")
|
||||
|
||||
function setTheme(theme) {
|
||||
store.setTheme(theme)
|
||||
}
|
||||
|
||||
function go(url) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-card,
|
||||
.theme-row,
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.avatar-shell {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-accent);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-name,
|
||||
.menu-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.arrow-text {
|
||||
font-size: 32rpx;
|
||||
// color: var(--brand);
|
||||
|
||||
}
|
||||
|
||||
.theme-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.theme-row .pill-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-list,
|
||||
.action-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
justify-content: space-between;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<view class="surface-card page-hero">
|
||||
<text class="hero-kicker">ACCOUNT</text>
|
||||
<text class="hero-title">账户资料</text>
|
||||
<text class="hero-desc">管理本地昵称、显示资料与本机记账模式说明。</text>
|
||||
<view class="hero-tags">
|
||||
<text class="hero-tag">本地资料</text>
|
||||
<text class="hero-tag soft">本地存储</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<section-card title="昵称设置" subtitle="修改后仅用于个人页展示和首字头像,不参与账单计算">
|
||||
<view class="profile-card surface-strong">
|
||||
<view class="avatar-shell">{{ avatarText }}</view>
|
||||
<view class="profile-body">
|
||||
<text class="profile-name">{{ profileName }}</text>
|
||||
<text class="profile-meta">当前昵称仅保存在本地设备,可随时修改。</text>
|
||||
</view>
|
||||
<view class="status-badge">本地</view>
|
||||
</view>
|
||||
<view class="editor-block">
|
||||
<view class="input-shell">
|
||||
<input v-model="nicknameInput" maxlength="12" placeholder="请输入昵称" />
|
||||
</view>
|
||||
<text class="tiny-text editor-tip">留空时页面会统一显示“用户”。</text>
|
||||
</view>
|
||||
<view class="action-row">
|
||||
<view class="ghost-button" @click="clearNickname">清空昵称</view>
|
||||
<view class="primary-button" @click="saveNickname">保存昵称</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="显示与模式" subtitle="集中展示当前账户页的生效状态">
|
||||
<view class="info-list">
|
||||
<view class="info-item surface-strong">
|
||||
<view>
|
||||
<text class="info-title">昵称首字头像</text>
|
||||
<text class="info-desc">当前显示 {{ avatarText }},自动根据昵称生成</text>
|
||||
</view>
|
||||
<text class="info-mark">已启用</text>
|
||||
</view>
|
||||
<view class="info-item surface-strong">
|
||||
<view>
|
||||
<text class="info-title">本机记账模式</text>
|
||||
<text class="info-desc">账单与预算默认仅保存在当前设备本地</text>
|
||||
</view>
|
||||
<text class="info-mark">默认</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="使用提示" subtitle="帮助用户理解昵称显示与数据边界">
|
||||
<view class="tips-card surface-strong">
|
||||
<view v-for="(tip, index) in tips" :key="tip" class="tip-row">
|
||||
<text class="tip-index">0{{ index + 1 }}</text>
|
||||
<text class="tip-line">{{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import SectionCard from '../../../components/SectionCard.vue'
|
||||
import { useAppStore } from '../../../utils/store'
|
||||
|
||||
const store = useAppStore()
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const profileName = computed(() => store.state.settings.profile.nickname || '用户')
|
||||
const avatarText = computed(() => profileName.value.slice(0, 1))
|
||||
const nicknameInput = ref(store.state.settings.profile.nickname || '')
|
||||
const tips = [
|
||||
'昵称仅用于个人页展示和首字头像,不参与账单计算。',
|
||||
'账单、预算和设置默认不会自动上传云端。',
|
||||
'如需更换设备,请先在“备份与恢复”页面导出 JSON 备份。'
|
||||
]
|
||||
|
||||
watch(
|
||||
() => store.state.settings.profile.nickname,
|
||||
(value) => {
|
||||
nicknameInput.value = value || ''
|
||||
}
|
||||
)
|
||||
|
||||
function saveNickname() {
|
||||
const nextName = nicknameInput.value.trim()
|
||||
store.setProfile({
|
||||
authorized: false,
|
||||
nickname: nextName,
|
||||
avatarUrl: ''
|
||||
})
|
||||
uni.showToast({ title: '昵称已保存', icon: 'none' })
|
||||
}
|
||||
|
||||
function clearNickname() {
|
||||
nicknameInput.value = ''
|
||||
store.setProfile({
|
||||
authorized: false,
|
||||
nickname: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
uni.showToast({ title: '昵称已清空', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-hero {
|
||||
padding: 30rpx;
|
||||
background: linear-gradient(145deg, rgba(16, 42, 67, 0.96) 0%, rgba(31, 111, 95, 0.92) 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-kicker,
|
||||
.hero-desc,
|
||||
.hero-tag.soft {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.info-item,
|
||||
.action-row,
|
||||
.tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.profile-card,
|
||||
.tips-card {
|
||||
padding: 26rpx;
|
||||
border-radius: 28rpx;
|
||||
}
|
||||
|
||||
.avatar-shell {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 32rpx;
|
||||
background: var(--bg-accent);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 18rpx 32rpx rgba(16, 42, 67, 0.16);
|
||||
}
|
||||
|
||||
.profile-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-name,
|
||||
.info-title {
|
||||
display: block;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-meta,
|
||||
.info-desc,
|
||||
.tip-line {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge,
|
||||
.info-mark {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: var(--brand-soft);
|
||||
font-size: 22rpx;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.editor-block {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.editor-tip {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.action-row .ghost-button,
|
||||
.action-row .primary-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
border-radius: 26rpx;
|
||||
}
|
||||
|
||||
.tip-row {
|
||||
align-items: flex-start;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.tip-index {
|
||||
width: 56rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<view class="app-page" :class="themeClass">
|
||||
<section-card title="报表总览" subtitle="按月份查看收支分布、消费趋势和月度对比">
|
||||
<template #action>
|
||||
<picker mode="date" fields="month" :value="monthPickerValue" @change="onMonthChange">
|
||||
<text class="section-link">{{ selectedMonthLabel }}</text>
|
||||
</picker>
|
||||
</template>
|
||||
<view class="summary-row">
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">支出</text>
|
||||
<text class="summary-value negative">{{ formatCurrency(monthExpenseTotal) }}</text>
|
||||
</view>
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">收入</text>
|
||||
<text class="summary-value positive">{{ formatCurrency(monthIncomeTotal) }}</text>
|
||||
</view>
|
||||
<view class="summary-item surface-strong">
|
||||
<text class="tiny-text">结余</text>
|
||||
<text class="summary-value">{{ formatCurrency(monthIncomeTotal - monthExpenseTotal) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
<ad-custom unit-id="adunit-74730c6c27c95a37"></ad-custom>
|
||||
<section-card title="支出分类" subtitle="查看本月主要消费去向与占比结构">
|
||||
<view v-if="categoryStats.length" class="chart-list">
|
||||
<view v-for="item in categoryStats" :key="item.id" class="chart-row">
|
||||
<view class="chart-head">
|
||||
<view class="chart-title-row">
|
||||
<view class="chart-dot" :style="{ background: item.color }"></view>
|
||||
<text class="chart-title">{{ item.name }}</text>
|
||||
</view>
|
||||
<text class="tiny-text">{{ formatCurrency(item.total) }} · {{ item.percentLabel }}</text>
|
||||
</view>
|
||||
<view class="bar-track"><view class="bar-fill" :style="{ width: item.percentWidth, background: item.color }"></view></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card"><text class="section-subtitle">当前月份暂无支出数据,记一笔后会自动生成图表。</text></view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="近 7 日趋势" subtitle="观察近一周消费变化,便于发现异常高峰">
|
||||
<view class="column-chart">
|
||||
<view v-for="item in dailySeries" :key="item.date" class="column-item">
|
||||
<view class="column-track"><view class="column-fill" :style="{ height: item.height }"></view></view>
|
||||
<text class="tiny-text">{{ item.label }}</text>
|
||||
<text class="tiny-text">{{ item.value === 0 ? '-' : item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="月度对比" subtitle="最近 6 个月收入与支出走势一目了然">
|
||||
<view class="compare-list">
|
||||
<view v-for="item in monthCompare" :key="item.month" class="compare-row">
|
||||
<text class="compare-label">{{ item.label }}</text>
|
||||
<view class="compare-bars">
|
||||
<view class="mini-track"><view class="mini-fill expense-fill" :style="{ width: item.expenseWidth }"></view></view>
|
||||
<view class="mini-track"><view class="mini-fill income-fill" :style="{ width: item.incomeWidth }"></view></view>
|
||||
</view>
|
||||
<text class="tiny-text">{{ formatCurrency(item.expense) }} / {{ formatCurrency(item.income) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<section-card title="导出与分享" subtitle="支持导出当月 CSV 账单与生成分享文案">
|
||||
<view class="action-grid">
|
||||
<view class="primary-button" @click="exportCsv">导出 CSV</view>
|
||||
<view class="ghost-button" @click="posterVisible = true">分享摘要</view>
|
||||
</view>
|
||||
</section-card>
|
||||
|
||||
<view v-if="posterVisible" class="poster-shell" @touchmove.stop.prevent="">
|
||||
<view class="poster-mask" @click="posterVisible = false"></view>
|
||||
<view class="surface-card poster-panel">
|
||||
<view class="poster-card">
|
||||
<text class="poster-month">{{ selectedMonthLabel }}</text>
|
||||
<text class="poster-title">收支月报</text>
|
||||
<text class="poster-line">支出 {{ formatCurrency(monthExpenseTotal) }}</text>
|
||||
<text class="poster-line">收入 {{ formatCurrency(monthIncomeTotal) }}</text>
|
||||
<text class="poster-line">结余 {{ formatCurrency(monthIncomeTotal - monthExpenseTotal) }}</text>
|
||||
<text class="poster-tip">内容本地生成,可复制摘要或直接截图分享。</text>
|
||||
</view>
|
||||
<view class="action-grid">
|
||||
<view class="ghost-button" @click="copyPosterText">复制摘要</view>
|
||||
<view class="primary-button" @click="posterVisible = false">关闭</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<app-tab-bar current="stats" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import SectionCard from '../../components/SectionCard.vue'
|
||||
import AppTabBar from '../../components/AppTabBar.vue'
|
||||
import { useAppStore } from '../../utils/store'
|
||||
import { formatMonthLabel, getMonthSeries, getRecentDateKeys, isSameMonth, toDateKey, toMonthKey } from '../../utils/date'
|
||||
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
|
||||
|
||||
const store = useAppStore()
|
||||
const monthValue = ref(toMonthKey())
|
||||
const posterVisible = ref(false)
|
||||
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
|
||||
const selectedMonthKey = computed(() => monthValue.value.slice(0, 7))
|
||||
const selectedMonthLabel = computed(() => formatMonthLabel(selectedMonthKey.value))
|
||||
const monthPickerValue = computed(() => monthValue.value)
|
||||
|
||||
const monthBills = computed(() => store.state.bills.filter((item) => isSameMonth(item.date, selectedMonthKey.value)))
|
||||
const monthExpenseBills = computed(() => monthBills.value.filter((item) => item.type === 'expense'))
|
||||
const monthExpenseTotal = computed(() => monthExpenseBills.value.reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
const monthIncomeTotal = computed(() => monthBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
|
||||
|
||||
const categoryStats = computed(() => {
|
||||
const total = Math.max(monthExpenseTotal.value, 1)
|
||||
return store.state.categories.expense
|
||||
.map((category) => {
|
||||
const categoryTotal = monthExpenseBills.value
|
||||
.filter((item) => item.categoryId === category.id)
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
return {
|
||||
...category,
|
||||
total: categoryTotal,
|
||||
percentLabel: formatPercent(categoryTotal / total),
|
||||
percentWidth: clampPercent(categoryTotal / total)
|
||||
}
|
||||
})
|
||||
.filter((item) => item.total > 0)
|
||||
.sort((left, right) => right.total - left.total)
|
||||
})
|
||||
|
||||
const dailySeries = computed(() => {
|
||||
const [year, month] = selectedMonthKey.value.split('-').map(Number)
|
||||
const today = toDateKey()
|
||||
const monthEnd = new Date(year, month, 0)
|
||||
const endDate = toMonthKey(today) === selectedMonthKey.value ? new Date() : monthEnd
|
||||
const dateKeys = getRecentDateKeys(7, endDate)
|
||||
const maxValue = Math.max(1, ...dateKeys.map((dateKey) => monthExpenseBills.value
|
||||
.filter((item) => item.date === dateKey)
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)))
|
||||
|
||||
return dateKeys.map((dateKey) => {
|
||||
const value = monthExpenseBills.value
|
||||
.filter((item) => item.date === dateKey)
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
return {
|
||||
date: dateKey,
|
||||
label: dateKey.slice(5),
|
||||
value: Number(value.toFixed(2)),
|
||||
height: `${Math.max(8, (value / maxValue) * 100)}%`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const monthCompare = computed(() => {
|
||||
const series = getMonthSeries(6, selectedMonthKey.value)
|
||||
const maxExpense = Math.max(1, ...series.map((month) => store.state.bills
|
||||
.filter((item) => item.type === 'expense' && isSameMonth(item.date, month))
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)))
|
||||
const maxIncome = Math.max(1, ...series.map((month) => store.state.bills
|
||||
.filter((item) => item.type === 'income' && isSameMonth(item.date, month))
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)))
|
||||
|
||||
return series.map((month) => {
|
||||
const expense = store.state.bills
|
||||
.filter((item) => item.type === 'expense' && isSameMonth(item.date, month))
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
const income = store.state.bills
|
||||
.filter((item) => item.type === 'income' && isSameMonth(item.date, month))
|
||||
.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
return {
|
||||
month,
|
||||
label: month.slice(5),
|
||||
expense,
|
||||
income,
|
||||
expenseWidth: `${Math.max(8, (expense / maxExpense) * 100)}%`,
|
||||
incomeWidth: `${Math.max(8, (income / maxIncome) * 100)}%`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function onMonthChange(event) {
|
||||
monthValue.value = String(event.detail.value).slice(0, 7)
|
||||
}
|
||||
|
||||
function categoryNameOf(bill) {
|
||||
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId)?.name || '未分类'
|
||||
}
|
||||
|
||||
function accountNameOf(bill) {
|
||||
return store.state.accounts.find((item) => item.id === bill.accountId)?.name || '未知账户'
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const text = String(value ?? '')
|
||||
if (!/[",\n]/.test(text)) {
|
||||
return text
|
||||
}
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function writeTextToFile(fileName, content, successText) {
|
||||
if (typeof wx !== 'undefined' && wx.getFileSystemManager) {
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||
wx.getFileSystemManager().writeFile({
|
||||
filePath,
|
||||
data: content,
|
||||
encoding: 'utf8',
|
||||
success: () => {
|
||||
uni.showModal({
|
||||
title: successText,
|
||||
content: `文件已生成:${filePath}`,
|
||||
showCancel: false
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.setClipboardData({ data: content })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.setClipboardData({ data: content })
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const header = '\uFEFF日期,类型,分类,账户,金额,备注'
|
||||
const rows = monthBills.value.map((bill) => [
|
||||
escapeCsvCell(bill.date),
|
||||
escapeCsvCell(bill.type === 'income' ? '收入' : '支出'),
|
||||
escapeCsvCell(categoryNameOf(bill)),
|
||||
escapeCsvCell(accountNameOf(bill)),
|
||||
escapeCsvCell(Number(bill.amount).toFixed(2)),
|
||||
escapeCsvCell(bill.note || '')
|
||||
].join(','))
|
||||
writeTextToFile(`账单-${selectedMonthKey.value}.csv`, [header, ...rows].join('\n'), 'CSV 导出成功')
|
||||
}
|
||||
|
||||
function copyPosterText() {
|
||||
const message = `${selectedMonthLabel.value},支出 ${formatCurrency(monthExpenseTotal.value)},收入 ${formatCurrency(monthIncomeTotal.value)},结余 ${formatCurrency(monthIncomeTotal.value - monthExpenseTotal.value)}。`
|
||||
uni.setClipboardData({ data: message })
|
||||
}
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
title: `${selectedMonthLabel.value}收支摘要`,
|
||||
path: '/pages/stats/index'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.summary-row,
|
||||
.action-grid {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-link {
|
||||
font-size: 24rpx;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.chart-list,
|
||||
.compare-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.chart-head,
|
||||
.chart-title-row,
|
||||
.compare-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.chart-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bar-track,
|
||||
.mini-track,
|
||||
.column-track {
|
||||
overflow: hidden;
|
||||
border-radius: 999rpx;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
height: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.bar-fill,
|
||||
.mini-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.column-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12rpx;
|
||||
align-items: end;
|
||||
height: 260rpx;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.column-track {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 180rpx;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.column-fill {
|
||||
width: 100%;
|
||||
border-radius: 999rpx 999rpx 16rpx 16rpx;
|
||||
background: linear-gradient(180deg, #5f8df5 0%, #1f6f5f 100%);
|
||||
}
|
||||
|
||||
.compare-label {
|
||||
width: 64rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compare-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.mini-track {
|
||||
height: 12rpx;
|
||||
}
|
||||
|
||||
.expense-fill {
|
||||
background: #d36c43;
|
||||
}
|
||||
|
||||
.income-fill {
|
||||
background: #1f6f5f;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.action-grid .primary-button,
|
||||
.action-grid .ghost-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
padding: 32rpx 0 10rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.poster-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.poster-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(4, 12, 18, 0.42);
|
||||
}
|
||||
|
||||
.poster-panel {
|
||||
position: absolute;
|
||||
left: 24rpx;
|
||||
right: 24rpx;
|
||||
top: 16vh;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
padding: 32rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(145deg, #102a43 0%, #1f6f5f 100%);
|
||||
color: #ffffff;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.poster-month,
|
||||
.poster-tip {
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.poster-title {
|
||||
display: block;
|
||||
margin: 12rpx 0 20rpx;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.poster-line {
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.poster-tip {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user