feat: 周期账单 — 自动生成定期交易(房租/订阅/工资)
This commit is contained in:
500
client/src/pages/recurring/index.vue
Normal file
500
client/src/pages/recurring/index.vue
Normal file
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||
</view>
|
||||
<text class="nav-title">周期账单</text>
|
||||
<view class="nav-action" @tap="openAddModal">
|
||||
<Icon name="plus" :size="40" color="#FF8C69" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 同步提示 -->
|
||||
<view class="sync-bar" v-if="syncCount > 0" @tap="syncCount = 0">
|
||||
<Icon name="check" :size="28" color="#43A047" />
|
||||
<text class="sync-text">已自动生成 {{ syncCount }} 笔记录</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载/空/列表 -->
|
||||
<view v-if="loading" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-box">
|
||||
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">暂无周期账单</text>
|
||||
<text class="empty-hint">设置房租、订阅、工资等定期收支</text>
|
||||
<view class="empty-btn" @tap="openAddModal">
|
||||
<text class="empty-btn-text">添加周期账单</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else class="list" scroll-y>
|
||||
<view v-for="item in list" :key="item.id" class="template-card" :class="{ inactive: !item.is_active }">
|
||||
<view class="card-main" @tap="openEditModal(item)">
|
||||
<CategoryIcon :label="(item.category_name || '?')[0]" :color="item.category_color || '#FF8C69'" :bg-color="(item.category_color || '#FF8C69') + '15'" size="md" />
|
||||
<view class="card-info">
|
||||
<view class="card-title-row">
|
||||
<text class="card-name">{{ item.category_name || '未分类' }}</text>
|
||||
<text class="card-frequency">{{ freqMap[item.frequency] }}</text>
|
||||
</view>
|
||||
<text class="card-note" v-if="item.note">{{ item.note }}</text>
|
||||
<text class="card-date">下次:{{ item.next_date }} {{ item.end_date ? '· 至 ' + item.end_date : '' }}</text>
|
||||
</view>
|
||||
<text class="card-amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||||
</view>
|
||||
<view class="card-actions">
|
||||
<view class="card-switch" @tap="toggleActive(item)">
|
||||
<view class="switch-track" :class="{ on: item.is_active }">
|
||||
<view class="switch-thumb"></view>
|
||||
</view>
|
||||
<text class="switch-label">{{ item.is_active ? '开启' : '暂停' }}</text>
|
||||
</view>
|
||||
<view class="card-btn" @tap="openEditModal(item)">
|
||||
<Icon name="edit" :size="24" color="#8B7E7E" />
|
||||
</view>
|
||||
<view class="card-btn" @tap="handleDelete(item)">
|
||||
<Icon name="trash" :size="24" color="#FF6B6B" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<view class="modal-mask" v-if="showModal" @tap="showModal = false">
|
||||
<view class="form-modal" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text>
|
||||
<view class="modal-close" @tap="showModal = false">
|
||||
<text class="modal-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="modal-body" scroll-y>
|
||||
<!-- 类型切换 -->
|
||||
<view class="type-toggle-wrap">
|
||||
<view class="type-toggle" :class="{ income: form.type === 'income' }">
|
||||
<view class="slider"></view>
|
||||
<view class="tab" :class="{ active: form.type === 'expense' }" @tap="form.type = 'expense'">支出</view>
|
||||
<view class="tab" :class="{ active: form.type === 'income' }" @tap="form.type = 'income'">收入</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 金额 -->
|
||||
<view class="amount-row">
|
||||
<text class="currency">¥</text>
|
||||
<text class="digits" :class="{ placeholder: !amountStr }">{{ amountStr || '0' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类 -->
|
||||
<scroll-view class="category-scroll" scroll-x>
|
||||
<view class="category-row">
|
||||
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id">
|
||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" />
|
||||
<text class="cat-name" :class="{ active: form.category_id === cat.id }">{{ cat.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="form-item">
|
||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" />
|
||||
</view>
|
||||
|
||||
<!-- 周期 -->
|
||||
<view class="form-item">
|
||||
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||
<view class="freq-options">
|
||||
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="form.frequency = f.value">
|
||||
<text class="freq-text">{{ f.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 开始日期 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">开始日期</text>
|
||||
<picker mode="date" :value="form.next_date" @change="e => form.next_date = e.detail.value">
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text">{{ form.next_date }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 结束日期 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">结束日期</text>
|
||||
<picker mode="date" :value="form.end_date || ''" :start="form.next_date" @change="e => form.end_date = e.detail.value">
|
||||
<view class="form-picker">
|
||||
<text class="form-picker-text" :class="{ placeholder: !form.end_date }">{{ form.end_date || '永久(不自动结束)' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer" @tap="handleSubmit">
|
||||
<text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Numpad -->
|
||||
<Numpad v-model="amountStr" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import type { RecurringTemplate } from '@/api/recurring'
|
||||
import type { Category } from '@/stores/category'
|
||||
|
||||
const catStore = useCategoryStore()
|
||||
|
||||
const list = ref<RecurringTemplate[]>([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const amountStr = ref('')
|
||||
const syncCount = ref(0)
|
||||
|
||||
const freqMap: Record<string, string> = {
|
||||
weekly: '每周', monthly: '每月', yearly: '每年'
|
||||
}
|
||||
const frequencies = [
|
||||
{ value: 'monthly' as const, label: '每月' },
|
||||
{ value: 'weekly' as const, label: '每周' },
|
||||
{ value: 'yearly' as const, label: '每年' },
|
||||
]
|
||||
|
||||
const defaultForm = {
|
||||
type: 'expense' as 'expense' | 'income',
|
||||
category_id: 0,
|
||||
note: '',
|
||||
frequency: 'monthly' as 'weekly' | 'monthly' | 'yearly',
|
||||
next_date: getTodayStr(),
|
||||
end_date: '' as string,
|
||||
}
|
||||
const form = ref({ ...defaultForm })
|
||||
|
||||
const currentCategories = computed(() => catStore.getByType(form.value.type))
|
||||
|
||||
function getTodayStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
await Promise.all([loadList(), syncAndLoad()])
|
||||
})
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await getRecurringTemplates()
|
||||
} catch { } finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAndLoad() {
|
||||
try {
|
||||
// 同步到期账单(静默,失败不影响主流程)
|
||||
const result = await syncRecurring()
|
||||
if (result.generated > 0) {
|
||||
syncCount.value = result.generated
|
||||
setTimeout(() => { syncCount.value = 0 }, 5000)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
editingId.value = null
|
||||
form.value = { ...defaultForm, next_date: getTodayStr() }
|
||||
const cats = catStore.getByType('expense')
|
||||
if (cats.length > 0) form.value.category_id = cats[0].id
|
||||
amountStr.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(item: RecurringTemplate) {
|
||||
editingId.value = item.id
|
||||
form.value = {
|
||||
type: item.type,
|
||||
category_id: item.category_id,
|
||||
note: item.note,
|
||||
frequency: item.frequency,
|
||||
next_date: item.next_date,
|
||||
end_date: item.end_date || '',
|
||||
}
|
||||
amountStr.value = (item.amount / 100).toString()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function toggleActive(item: RecurringTemplate) {
|
||||
const newState = item.is_active ? 0 : 1
|
||||
try {
|
||||
await updateRecurringTemplate(item.id, { is_active: newState })
|
||||
item.is_active = newState
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
uni.showToast({ title: '请输入金额', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!form.value.category_id) {
|
||||
uni.showToast({ title: '请选择分类', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
amount,
|
||||
type: form.value.type,
|
||||
category_id: form.value.category_id,
|
||||
note: form.value.note,
|
||||
frequency: form.value.frequency,
|
||||
next_date: form.value.next_date,
|
||||
end_date: form.value.end_date || undefined,
|
||||
}
|
||||
if (editingId.value) {
|
||||
await updateRecurringTemplate(editingId.value, data)
|
||||
} else {
|
||||
await createRecurringTemplate(data)
|
||||
}
|
||||
showModal.value = false
|
||||
await loadList()
|
||||
uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: RecurringTemplate) {
|
||||
uni.showModal({
|
||||
title: '删除周期账单',
|
||||
content: `确定删除「${item.category_name || ''} ${formatAmount(item.amount)}」?已生成的记录不受影响。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteRecurringTemplate(item.id)
|
||||
list.value = list.value.filter(t => t.id !== item.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page { min-height: 100vh; background: $bg; padding-bottom: 120rpx; }
|
||||
.header-fixed { @include sticky-header; }
|
||||
.status-bar { @include status-bar; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex; align-items: center;
|
||||
padding: $space-sm $space-xl $space-md;
|
||||
}
|
||||
.nav-back { @include nav-back; }
|
||||
.nav-title {
|
||||
flex: 1; text-align: center;
|
||||
font-size: $font-2xl; font-weight: 600; color: $text;
|
||||
}
|
||||
.nav-action {
|
||||
width: 88rpx; height: 88rpx; @include flex-center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.sync-bar {
|
||||
display: flex; align-items: center; gap: $space-xs;
|
||||
margin: $space-sm $space-xl; padding: $space-sm $space-md;
|
||||
background: #E8F5E9; border-radius: $radius-lg;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
.sync-text { font-size: $font-md; color: #43A047; font-weight: 500; }
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 120rpx 0; gap: $space-sm;
|
||||
}
|
||||
.loading-text, .empty-text { font-size: $font-lg; color: $text-muted; }
|
||||
.empty-hint { font-size: $font-md; color: $text-muted; margin-top: -$space-xs; }
|
||||
.empty-btn {
|
||||
margin-top: $space-lg; padding: $space-sm $space-xl;
|
||||
background: $primary; border-radius: $radius-lg;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
.empty-btn-text { font-size: $font-base; color: $surface; font-weight: 600; }
|
||||
|
||||
.list { padding: 0 $space-xl; max-height: calc(100vh - 200rpx); }
|
||||
|
||||
.template-card {
|
||||
background: $surface; border-radius: $radius-xl;
|
||||
border: 2rpx solid $border; box-shadow: $shadow-md;
|
||||
padding: $space-lg; margin-bottom: $space-sm;
|
||||
&.inactive { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex; align-items: center; gap: $space-md;
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.card-info { flex: 1; min-width: 0; }
|
||||
.card-title-row { display: flex; align-items: center; gap: $space-sm; }
|
||||
.card-name { font-size: $font-lg; font-weight: 600; color: $text; }
|
||||
.card-frequency {
|
||||
font-size: $font-xs; color: $primary; background: $primary-light;
|
||||
padding: 2rpx 10rpx; border-radius: $radius-xs;
|
||||
}
|
||||
.card-note {
|
||||
font-size: $font-sm; color: $text-sec;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
.card-date { font-size: $font-sm; color: $text-muted; margin-top: 2rpx; display: block; }
|
||||
.card-amount {
|
||||
font-family: 'Fredoka', sans-serif; font-size: $font-xl; font-weight: 600;
|
||||
&.expense { color: $text; } &.income { color: $success; }
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex; align-items: center; gap: $space-md;
|
||||
margin-top: $space-sm; padding-top: $space-sm;
|
||||
border-top: 2rpx solid $border;
|
||||
}
|
||||
.card-switch {
|
||||
display: flex; align-items: center; gap: $space-sm;
|
||||
flex: 1;
|
||||
}
|
||||
.switch-track {
|
||||
width: 44rpx; height: 24rpx; border-radius: 12rpx;
|
||||
background: #D0C4C4; position: relative; transition: background $transition-normal;
|
||||
&.on { background: $primary; }
|
||||
}
|
||||
.switch-thumb {
|
||||
position: absolute; top: 2rpx; left: 2rpx;
|
||||
width: 20rpx; height: 20rpx; border-radius: 50%;
|
||||
background: $surface; transition: transform $transition-normal;
|
||||
.on & { transform: translateX(20rpx); }
|
||||
}
|
||||
.switch-label { font-size: $font-sm; color: $text-muted; }
|
||||
.card-btn {
|
||||
width: 64rpx; height: 64rpx; @include flex-center; border-radius: 50%;
|
||||
&:active { background: $bg; }
|
||||
}
|
||||
|
||||
/* 表单弹窗 */
|
||||
.modal-mask { @include modal-mask; }
|
||||
.form-modal {
|
||||
width: 100%; max-height: 90vh; background: $surface;
|
||||
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||
display: flex; flex-direction: column; margin-top: auto;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: $space-lg $space-xl $space-md; border-bottom: 2rpx solid $border;
|
||||
}
|
||||
.modal-title { font-size: $font-xl; font-weight: 600; color: $text; }
|
||||
.modal-close {
|
||||
width: 56rpx; height: 56rpx; @include flex-center; border-radius: 50%; background: $border;
|
||||
&:active { background: darken($border, 5%); }
|
||||
}
|
||||
.modal-close-text { font-size: $font-2xl; color: $text-sec; line-height: 1; }
|
||||
.modal-body { flex: 1; padding: $space-md $space-xl; max-height: 60vh; }
|
||||
.modal-footer {
|
||||
padding: $space-md $space-xl; border-top: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.submit-text {
|
||||
display: block; text-align: center; font-size: 30rpx; font-weight: 600; color: $surface;
|
||||
padding: $space-lg; background: linear-gradient(135deg, $primary, #E67355);
|
||||
border-radius: $radius-lg;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* 类型切换 */
|
||||
.type-toggle-wrap { display: flex; justify-content: center; margin-bottom: $space-md; }
|
||||
.type-toggle {
|
||||
display: inline-flex; background: $surface-warm; border-radius: $radius-lg;
|
||||
padding: $space-xs; position: relative;
|
||||
}
|
||||
.tab {
|
||||
width: 140rpx; height: 64rpx; display: flex; align-items: center; justify-content: center;
|
||||
font-size: $font-md; color: $text-sec; border-radius: $radius-md; position: relative; z-index: 1;
|
||||
&.active { color: $primary; font-weight: 600; }
|
||||
}
|
||||
.slider {
|
||||
position: absolute; top: $space-xs; left: $space-xs; width: 140rpx; height: 64rpx;
|
||||
background: $surface; border-radius: $radius-md; box-shadow: $shadow-sm;
|
||||
transition: transform $transition-normal;
|
||||
}
|
||||
.income .slider { transform: translateX(140rpx); }
|
||||
|
||||
/* 金额 */
|
||||
.amount-row {
|
||||
text-align: center; padding: $space-sm 0 $space-md;
|
||||
}
|
||||
.currency {
|
||||
font-family: 'Fredoka', sans-serif; font-size: $space-2xl; font-weight: 500;
|
||||
color: $text-muted; margin-right: $space-xs;
|
||||
}
|
||||
.digits {
|
||||
font-family: 'Fredoka', sans-serif; font-size: 72rpx; font-weight: 600; color: $text;
|
||||
&.placeholder { color: #D0C4C4; }
|
||||
}
|
||||
|
||||
/* 分类 */
|
||||
.category-scroll { margin-bottom: $space-md; }
|
||||
.category-row { display: flex; gap: $space-sm; padding: $space-xs 0; }
|
||||
.cat-item {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6rpx;
|
||||
padding: 8rpx 12rpx; min-width: 100rpx;
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
.cat-name { font-size: $font-sm; color: $text-sec; &.active { color: $primary; font-weight: 600; } }
|
||||
|
||||
/* 表单项 */
|
||||
.form-item {
|
||||
display: flex; align-items: center; gap: $space-sm;
|
||||
height: 80rpx; margin-bottom: $space-sm;
|
||||
padding: 0 $space-md; background: $bg; border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
}
|
||||
.form-label { font-size: $font-md; color: $text-sec; flex-shrink: 0; margin-right: $space-sm; }
|
||||
.form-input { flex: 1; font-size: $font-md; color: $text; }
|
||||
.form-picker { flex: 1; }
|
||||
.form-picker-text { font-size: $font-md; color: $text; &.placeholder { color: $text-muted; } }
|
||||
.freq-options { display: flex; gap: $space-xs; }
|
||||
.freq-tab {
|
||||
padding: $space-xs $space-lg; border-radius: $radius-md;
|
||||
font-size: $font-md; color: $text-sec; background: $surface;
|
||||
border: 2rpx solid $border;
|
||||
&.active { background: $primary; color: $surface; border-color: $primary; }
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user