@@ -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 : 100 vh ; background : $bg ; padding - bottom : 120 rpx ; }
. 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 - 2 xl ; font - weight : 600 ; color : $text ;
}
. nav - action {
width : 88 rpx ; height : 88 rpx ; @ 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 : # 43 A047 ; font - weight : 500 ; }
. loading - box , . empty - box {
display : flex ; flex - direction : column ; align - items : center ;
padding : 120 rpx 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 ( 100 vh - 200 rpx ) ; }
. template - card {
background : $surface ; border - radius : $radius - xl ;
border : 2 rpx 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 : 2 rpx 10 rpx ; border - radius : $radius - xs ;
}
. card - note {
font - size : $font - sm ; color : $text - sec ;
overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ;
margin - top : 2 rpx ;
}
. card - date { font - size : $font - sm ; color : $text - muted ; margin - top : 2 rpx ; 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 : 2 rpx solid $border ;
}
. card - switch {
display : flex ; align - items : center ; gap : $space - sm ;
flex : 1 ;
}
. switch - track {
width : 44 rpx ; height : 24 rpx ; border - radius : 12 rpx ;
background : # D0C4C4 ; position : relative ; transition : background $transition - normal ;
& . on { background : $primary ; }
}
. switch - thumb {
position : absolute ; top : 2 rpx ; left : 2 rpx ;
width : 20 rpx ; height : 20 rpx ; border - radius : 50 % ;
background : $surface ; transition : transform $transition - normal ;
. on & { transform : translateX ( 20 rpx ) ; }
}
. switch - label { font - size : $font - sm ; color : $text - muted ; }
. card - btn {
width : 64 rpx ; height : 64 rpx ; @ include flex - center ; border - radius : 50 % ;
& : active { background : $bg ; }
}
/* 表单弹窗 */
. modal - mask { @ include modal - mask ; }
. form - modal {
width : 100 % ; max - height : 90 vh ; background : $surface ;
border - radius : $radius - 2 xl $radius - 2 xl 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 : 2 rpx solid $border ;
}
. modal - title { font - size : $font - xl ; font - weight : 600 ; color : $text ; }
. modal - close {
width : 56 rpx ; height : 56 rpx ; @ include flex - center ; border - radius : 50 % ; background : $border ;
& : active { background : darken ( $border , 5 % ) ; }
}
. modal - close - text { font - size : $font - 2 xl ; color : $text - sec ; line - height : 1 ; }
. modal - body { flex : 1 ; padding : $space - md $space - xl ; max - height : 60 vh ; }
. modal - footer {
padding : $space - md $space - xl ; border - top : 2 rpx solid $border ;
}
. submit - text {
display : block ; text - align : center ; font - size : 30 rpx ; font - weight : 600 ; color : $surface ;
padding : $space - lg ; background : linear - gradient ( 135 deg , $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 : 140 rpx ; height : 64 rpx ; 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 : 140 rpx ; height : 64 rpx ;
background : $surface ; border - radius : $radius - md ; box - shadow : $shadow - sm ;
transition : transform $transition - normal ;
}
. income . slider { transform : translateX ( 140 rpx ) ; }
/* 金额 */
. amount - row {
text - align : center ; padding : $space - sm 0 $space - md ;
}
. currency {
font - family : 'Fredoka' , sans - serif ; font - size : $space - 2 xl ; font - weight : 500 ;
color : $text - muted ; margin - right : $space - xs ;
}
. digits {
font - family : 'Fredoka' , sans - serif ; font - size : 72 rpx ; 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 : 6 rpx ;
padding : 8 rpx 12 rpx ; min - width : 100 rpx ;
& : 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 : 80 rpx ; margin - bottom : $space - sm ;
padding : 0 $space - md ; background : $bg ; border - radius : $radius - lg ;
border : 2 rpx 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 : 2 rpx solid $border ;
& . active { background : $primary ; color : $surface ; border - color : $primary ; }
& : active { opacity : 0.8 ; }
}
< / style >