feat: 周期账单 — 自动生成定期交易(房租/订阅/工资)
This commit is contained in:
64
client/src/api/recurring.ts
Normal file
64
client/src/api/recurring.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 周期模板 */
|
||||||
|
export interface RecurringTemplate {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
amount: number
|
||||||
|
type: 'expense' | 'income'
|
||||||
|
category_id: number
|
||||||
|
note: string
|
||||||
|
frequency: 'weekly' | 'monthly' | 'yearly'
|
||||||
|
next_date: string
|
||||||
|
end_date: string | null
|
||||||
|
is_active: number
|
||||||
|
group_id: number | null
|
||||||
|
category_name?: string
|
||||||
|
category_icon?: string
|
||||||
|
category_color?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取周期模板列表 */
|
||||||
|
export function getRecurringTemplates() {
|
||||||
|
return request<RecurringTemplate[]>({ url: '/recurring' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建周期模板 */
|
||||||
|
export function createRecurringTemplate(data: {
|
||||||
|
amount: number
|
||||||
|
type: 'expense' | 'income'
|
||||||
|
category_id: number
|
||||||
|
note?: string
|
||||||
|
frequency: 'weekly' | 'monthly' | 'yearly'
|
||||||
|
next_date: string
|
||||||
|
end_date?: string | null
|
||||||
|
group_id?: number | null
|
||||||
|
}) {
|
||||||
|
return request<{ id: number }>({ url: '/recurring', method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新周期模板 */
|
||||||
|
export function updateRecurringTemplate(id: number, data: {
|
||||||
|
amount?: number
|
||||||
|
type?: 'expense' | 'income'
|
||||||
|
category_id?: number
|
||||||
|
note?: string
|
||||||
|
frequency?: 'weekly' | 'monthly' | 'yearly'
|
||||||
|
next_date?: string
|
||||||
|
end_date?: string | null
|
||||||
|
is_active?: number
|
||||||
|
}) {
|
||||||
|
return request({ url: `/recurring/${id}`, method: 'PUT', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除周期模板 */
|
||||||
|
export function deleteRecurringTemplate(id: number) {
|
||||||
|
return request({ url: `/recurring/${id}`, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步周期账单(生成到期交易) */
|
||||||
|
export function syncRecurring() {
|
||||||
|
return request<{ generated: number }>({ url: '/recurring/sync', method: 'POST' })
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@
|
|||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/recurring/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "周期账单"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/budget/index",
|
"path": "pages/budget/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -108,7 +108,6 @@
|
|||||||
<text class="reply-btn-text">{{ replyAction === 'processed' ? '回复并处理' : '回复并忽略' }}</text>
|
<text class="reply-btn-text">{{ replyAction === 'processed' ? '回复并处理' : '回复并忽略' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ import { useGroupStore } from '@/stores/group'
|
|||||||
import { useNotificationStore } from '@/stores/notification'
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
|
import { syncRecurring } from '@/api/recurring'
|
||||||
import type { Transaction } from '@/api/transaction'
|
import type { Transaction } from '@/api/transaction'
|
||||||
import { getNotificationImageUrl } from '@/api/notification'
|
import { getNotificationImageUrl } from '@/api/notification'
|
||||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||||
@@ -237,7 +238,8 @@ async function loadData(silent = false, force = false) {
|
|||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
userStore.fetchUserInfo(),
|
userStore.fetchUserInfo(),
|
||||||
notifStore.fetchUnreadCount(),
|
notifStore.fetchUnreadCount(),
|
||||||
notifStore.fetchUrgentNotifications()
|
notifStore.fetchUrgentNotifications(),
|
||||||
|
syncRecurring().catch(() => {})
|
||||||
]).catch(() => {})
|
]).catch(() => {})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Load error:', e)
|
console.error('Load error:', e)
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
<text class="mi-label">分类管理</text>
|
<text class="mi-label">分类管理</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @tap="goRecurring">
|
||||||
|
<text class="mi-label">周期账单</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<view class="menu-item" @tap="openExportPanel">
|
<view class="menu-item" @tap="openExportPanel">
|
||||||
<text class="mi-label">数据导出</text>
|
<text class="mi-label">数据导出</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
@@ -301,6 +305,10 @@ function goCategoryManage() {
|
|||||||
uni.navigateTo({ url: '/pages/category-manage/index' })
|
uni.navigateTo({ url: '/pages/category-manage/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goRecurring() {
|
||||||
|
uni.navigateTo({ url: '/pages/recurring/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function goPrivacy() {
|
function goPrivacy() {
|
||||||
uni.navigateTo({ url: '/pages/privacy/index' })
|
uni.navigateTo({ url: '/pages/privacy/index' })
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -243,6 +243,40 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 周期账单模板表
|
||||||
|
const hasRecurring = await tableExists(conn, 'recurring_templates')
|
||||||
|
if (!hasRecurring) {
|
||||||
|
console.log('[DB] Migrating: creating recurring_templates table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
note VARCHAR(200) DEFAULT '',
|
||||||
|
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||||
|
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||||
|
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
group_id INT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_next_date (next_date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transactions 表添加 recurring_id
|
||||||
|
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
|
||||||
|
if (!hasRecurringId) {
|
||||||
|
console.log('[DB] Migrating: adding recurring_id to transactions')
|
||||||
|
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS transactions (
|
|||||||
note VARCHAR(200),
|
note VARCHAR(200),
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
|
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
|
||||||
|
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_user_date (user_id, date),
|
INDEX idx_user_date (user_id, date),
|
||||||
@@ -149,3 +150,23 @@ CREATE TABLE IF NOT EXISTS track_events (
|
|||||||
INDEX idx_created (created_at),
|
INDEX idx_created (created_at),
|
||||||
INDEX idx_user (user_id)
|
INDEX idx_user (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
note VARCHAR(200) DEFAULT '',
|
||||||
|
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||||
|
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||||
|
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
group_id INT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_next_date (next_date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import feedbackRoutes from './routes/feedback'
|
|||||||
import configRoutes from './routes/config'
|
import configRoutes from './routes/config'
|
||||||
import trackRoutes from './routes/track'
|
import trackRoutes from './routes/track'
|
||||||
import logsRoutes from './routes/logs'
|
import logsRoutes from './routes/logs'
|
||||||
|
import recurringRoutes from './routes/recurring'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// Warn if token secret is using default fallback
|
||||||
@@ -161,6 +162,7 @@ app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
|||||||
app.use('/api/config', apiLimiter, configRoutes)
|
app.use('/api/config', apiLimiter, configRoutes)
|
||||||
app.use('/api/track', apiLimiter, trackRoutes)
|
app.use('/api/track', apiLimiter, trackRoutes)
|
||||||
app.use('/api/logs', apiLimiter, logsRoutes)
|
app.use('/api/logs', apiLimiter, logsRoutes)
|
||||||
|
app.use('/api/recurring', apiLimiter, recurringRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
205
server/src/routes/recurring.ts
Normal file
205
server/src/routes/recurring.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { authMiddleware } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authMiddleware)
|
||||||
|
|
||||||
|
/** 获取用户的周期模板列表 */
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||||
|
FROM recurring_templates rt
|
||||||
|
LEFT JOIN categories c ON rt.category_id = c.id
|
||||||
|
WHERE rt.user_id = ?
|
||||||
|
ORDER BY rt.is_active DESC, rt.next_date ASC`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recurring templates error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 创建周期模板 */
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请输入金额' })
|
||||||
|
}
|
||||||
|
if (!type || !['expense', 'income'].includes(type)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择类型' })
|
||||||
|
}
|
||||||
|
if (!category_id) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择分类' })
|
||||||
|
}
|
||||||
|
if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择周期' })
|
||||||
|
}
|
||||||
|
if (!next_date) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择开始日期' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '创建失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 更新周期模板 */
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { id } = req.params
|
||||||
|
const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body
|
||||||
|
|
||||||
|
// 校验所有权
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
if ((rows as any[]).length === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) }
|
||||||
|
if (type !== undefined) { updates.push('type = ?'); params.push(type) }
|
||||||
|
if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) }
|
||||||
|
if (note !== undefined) { updates.push('note = ?'); params.push(note) }
|
||||||
|
if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) }
|
||||||
|
if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) }
|
||||||
|
if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) }
|
||||||
|
if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) }
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无更新字段' })
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id, userId)
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '更新失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 删除周期模板 */
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
if ((rows as any[]).length === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '删除失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 同步:检查所有活跃模板,生成到期交易 */
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
// 获取该用户所有活跃且到期(next_date <= 今天)的模板
|
||||||
|
const [templates] = await pool.execute(
|
||||||
|
`SELECT * FROM recurring_templates
|
||||||
|
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
|
||||||
|
ORDER BY next_date ASC`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
let generated = 0
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = today.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
for (const t of templates as any[]) {
|
||||||
|
let next = new Date(t.next_date)
|
||||||
|
const end = t.end_date ? new Date(t.end_date) : null
|
||||||
|
|
||||||
|
// 生成所有错过的交易
|
||||||
|
while (next <= today) {
|
||||||
|
if (end && next > end) break
|
||||||
|
|
||||||
|
// 检查是否已生成过(避免重复)
|
||||||
|
const [existing] = await pool.execute(
|
||||||
|
`SELECT id FROM transactions
|
||||||
|
WHERE user_id = ? AND recurring_id = ? AND date = ?`,
|
||||||
|
[userId, t.id, next.toISOString().slice(0, 10)]
|
||||||
|
)
|
||||||
|
if ((existing as any[]).length === 0) {
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, t.amount, t.type, t.category_id, t.note,
|
||||||
|
next.toISOString().slice(0, 10), t.group_id, t.id]
|
||||||
|
)
|
||||||
|
generated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推进到下一周期
|
||||||
|
if (t.frequency === 'weekly') {
|
||||||
|
next = new Date(next.getTime() + 7 * 86400000)
|
||||||
|
} else if (t.frequency === 'monthly') {
|
||||||
|
next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate())
|
||||||
|
} else if (t.frequency === 'yearly') {
|
||||||
|
next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 next_date 到下一周期
|
||||||
|
let newNext: Date
|
||||||
|
if (t.frequency === 'weekly') {
|
||||||
|
newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000)
|
||||||
|
} else if (t.frequency === 'monthly') {
|
||||||
|
const d = new Date(t.next_date)
|
||||||
|
newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
|
||||||
|
} else {
|
||||||
|
const d = new Date(t.next_date)
|
||||||
|
newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
|
||||||
|
[newNext.toISOString().slice(0, 10), t.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { generated } })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync recurring error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '同步失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
Reference in New Issue
Block a user