- users 表添加 nickname、avatar_url 字段 - 用户信息 API(GET/PUT /me)+ 头像上传(multer) - 头像通过 API 路由获取(公开,不经过 auth) - 登录接口返回用户昵称和头像 - 用户信息 Store + 个人资料编辑页面 - 我的页面和首页显示真实用户信息 - 群组表(groups、group_members)+ transactions 添加 group_id - 群组 API(创建/加入/退出/解散) - 交易和统计 API 支持 group_id 视图切换 - 群组客户端 Store + 身份切换 - 群组管理页面 - App 初始化群组 Store - UPLOAD_DIR 配置化 - Node.js 备份替代 mysqldump - 统一前端 BASE_URL(config.ts) - uploads 加入 .gitignore - 修复 trust proxy 警告
369 lines
9.5 KiB
Vue
369 lines
9.5 KiB
Vue
<template>
|
|
<view class="page">
|
|
<view class="header-fixed">
|
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
|
<view class="header">
|
|
<view class="nav-back" @tap="goBack">
|
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
|
</view>
|
|
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
|
|
<view style="width: 88rpx;"></view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="type-toggle-wrap">
|
|
<view class="type-toggle" :class="{ income: type === 'income' }">
|
|
<view class="slider"></view>
|
|
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
|
|
<view class="tab" :class="{ active: type === 'income' }" @tap="switchType('income')">收入</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="amount-display">
|
|
<text class="currency">¥</text>
|
|
<text class="digits">{{ displayAmount }}</text>
|
|
<text class="cursor">|</text>
|
|
</view>
|
|
|
|
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
|
<view class="category-grid">
|
|
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id">
|
|
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
|
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<view class="extra-fields">
|
|
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
|
|
<view class="extra-row">
|
|
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
|
<text class="extra-text">{{ dateLabel }}</text>
|
|
</view>
|
|
</picker>
|
|
<view class="extra-row">
|
|
<Icon name="edit" :size="28" color="#8B7E7E" />
|
|
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
|
|
</view>
|
|
</view>
|
|
|
|
<Numpad v-model="amountStr" @confirm="save" />
|
|
|
|
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
|
import { useCategoryStore } from '@/stores/category'
|
|
import { useTransactionStore } from '@/stores/transaction'
|
|
import Numpad from '@/components/Numpad/Numpad.vue'
|
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
|
import Icon from '@/components/Icon/Icon.vue'
|
|
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
|
import { statusBarHeight } from '@/utils/system'
|
|
|
|
const catStore = useCategoryStore()
|
|
const txStore = useTransactionStore()
|
|
|
|
const type = ref<'expense' | 'income'>('expense')
|
|
const amountStr = ref('0')
|
|
const selectedCat = ref<number | null>(null)
|
|
const note = ref('')
|
|
const now = new Date()
|
|
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
const selectedDate = ref(localToday)
|
|
const saving = ref(false)
|
|
const editId = ref<number | null>(null)
|
|
const showSuccess = ref(false)
|
|
const scrollToCat = ref('')
|
|
const todayStr = localToday
|
|
// Remember last selected category per type
|
|
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
|
|
|
const dateLabel = computed(() => {
|
|
const d = new Date(selectedDate.value)
|
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
|
})
|
|
|
|
const displayAmount = computed(() => {
|
|
if (!amountStr.value) return ''
|
|
return amountStr.value
|
|
})
|
|
|
|
const currentCategories = computed(() => catStore.getByType(type.value))
|
|
|
|
function switchType(newType: 'expense' | 'income') {
|
|
// Remember current selection before switching
|
|
lastCatByType[type.value] = selectedCat.value
|
|
type.value = newType
|
|
const cats = catStore.getByType(newType)
|
|
// Restore last selection for this type, or fall back to first category
|
|
const saved = lastCatByType[newType]
|
|
if (saved && cats.find(c => c.id === saved)) {
|
|
selectedCat.value = saved
|
|
} else {
|
|
selectedCat.value = cats.length > 0 ? cats[0].id : null
|
|
}
|
|
}
|
|
|
|
watch(currentCategories, (cats) => {
|
|
if (cats.length > 0 && !cats.find(c => c.id === selectedCat.value)) {
|
|
selectedCat.value = cats[0].id
|
|
}
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await catStore.fetchCategories()
|
|
const pages = getCurrentPages()
|
|
const page = pages[pages.length - 1] as any
|
|
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
|
|
type.value = page.options.type
|
|
}
|
|
if (page?.options?.editId) {
|
|
editId.value = Number(page.options.editId)
|
|
// Try to find in store first, otherwise fetch from server
|
|
let existing = txStore.transactions.find(t => t.id === editId.value)
|
|
if (!existing) {
|
|
existing = await txStore.fetchTransactionById(editId.value!)
|
|
}
|
|
if (existing) {
|
|
type.value = existing.type
|
|
amountStr.value = (existing.amount / 100).toFixed(2)
|
|
selectedCat.value = existing.category_id
|
|
lastCatByType[existing.type] = existing.category_id
|
|
note.value = existing.note || ''
|
|
selectedDate.value = existing.date.slice(0, 10)
|
|
// 等待分类列表渲染完成后滚动到选中项
|
|
nextTick(() => {
|
|
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
|
})
|
|
} else {
|
|
uni.showToast({ title: '记录不存在', icon: 'none' })
|
|
setTimeout(() => uni.navigateBack(), 1500)
|
|
}
|
|
}
|
|
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
|
selectedCat.value = currentCategories.value[0].id
|
|
}
|
|
})
|
|
|
|
function onDateChange(e: any) {
|
|
selectedDate.value = e.detail.value
|
|
}
|
|
|
|
async function save() {
|
|
if (saving.value) return
|
|
const amount = Math.round(parseFloat(amountStr.value) * 100)
|
|
if (amount <= 0) {
|
|
uni.showToast({ title: '请输入金额', icon: 'none' })
|
|
return
|
|
}
|
|
if (!selectedCat.value) {
|
|
uni.showToast({ title: '请选择分类', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
saving.value = true
|
|
try {
|
|
const data = {
|
|
amount,
|
|
type: type.value,
|
|
category_id: selectedCat.value,
|
|
note: note.value,
|
|
date: selectedDate.value
|
|
}
|
|
if (editId.value) {
|
|
await txStore.updateTransaction(editId.value, data)
|
|
} else {
|
|
await txStore.addTransaction(data)
|
|
}
|
|
showSuccess.value = true
|
|
setTimeout(() => uni.navigateBack(), 2500)
|
|
} catch (e) {
|
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
const hasInput = amountStr.value !== '0' || note.value
|
|
if (hasInput) {
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: '放弃当前记录?',
|
|
success: (res) => {
|
|
if (res.confirm) uni.navigateBack()
|
|
}
|
|
})
|
|
} else {
|
|
uni.navigateBack()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.page {
|
|
min-height: 100vh;
|
|
background: #FFF8F0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header-fixed {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
background: #FFF8F0;
|
|
}
|
|
|
|
.status-bar {
|
|
background: #FFF8F0;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16rpx 40rpx;
|
|
}
|
|
|
|
.nav-back {
|
|
width: 88rpx;
|
|
height: 88rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
|
|
&:active { background: #F0E0D6; }
|
|
}
|
|
|
|
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
|
|
|
|
.type-toggle-wrap { display: flex; justify-content: center; margin: 16rpx 0; }
|
|
|
|
.type-toggle {
|
|
display: inline-flex;
|
|
background: #FFF0E6;
|
|
border-radius: 24rpx;
|
|
padding: 8rpx;
|
|
position: relative;
|
|
}
|
|
|
|
.tab {
|
|
width: 160rpx;
|
|
height: 72rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 28rpx;
|
|
color: #8B7E7E;
|
|
border-radius: 20rpx;
|
|
position: relative;
|
|
z-index: 1;
|
|
transition: color 0.2s;
|
|
|
|
&.active { color: #FF8C69; font-weight: 600; }
|
|
}
|
|
|
|
.slider {
|
|
position: absolute;
|
|
top: 8rpx;
|
|
left: 8rpx;
|
|
width: 160rpx;
|
|
height: 72rpx;
|
|
background: #FFFFFF;
|
|
border-radius: 20rpx;
|
|
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.08);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.income .slider { transform: translateX(160rpx); }
|
|
|
|
.amount-display {
|
|
text-align: center;
|
|
padding: 32rpx 40rpx 24rpx;
|
|
}
|
|
|
|
.currency {
|
|
font-family: 'Fredoka', sans-serif;
|
|
font-size: 48rpx;
|
|
font-weight: 500;
|
|
color: #BFB3B3;
|
|
margin-right: 8rpx;
|
|
}
|
|
|
|
.digits {
|
|
font-family: 'Fredoka', sans-serif;
|
|
font-size: 88rpx;
|
|
font-weight: 700;
|
|
color: #2D1B1B;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.cursor {
|
|
font-family: 'Fredoka', monospace;
|
|
font-size: 88rpx;
|
|
font-weight: 300;
|
|
color: #FF8C69;
|
|
animation: blink 1s step-end infinite;
|
|
}
|
|
|
|
@keyframes blink {
|
|
50% { opacity: 0; }
|
|
}
|
|
|
|
.category-scroll {
|
|
max-height: 320rpx;
|
|
}
|
|
|
|
.category-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16rpx;
|
|
padding: 8rpx 0;
|
|
}
|
|
|
|
.cat-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6rpx;
|
|
padding: 12rpx 0;
|
|
|
|
&:active { transform: scale(0.95); }
|
|
}
|
|
|
|
.cat-name { font-size: 22rpx; color: #8B7E7E; }
|
|
|
|
.cat-name.active {
|
|
color: #FF8C69;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.extra-fields {
|
|
padding: 16rpx 40rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12rpx;
|
|
}
|
|
|
|
.extra-row {
|
|
height: 88rpx;
|
|
background: #FFFFFF;
|
|
border-radius: 20rpx;
|
|
border: 2rpx solid #F0E0D6;
|
|
padding: 0 24rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12rpx;
|
|
}
|
|
|
|
.extra-text { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
|
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
|
|
|
.placeholder { color: #BFB3B3; }
|
|
</style>
|