@@ -0,0 +1,906 @@
< script setup lang = "ts" >
// 日历页面:月视图展示每日收支汇总(收入/支出/结余)+ 年视图展示每月汇总,点击日期在浮窗中查看当日交易明细
import { ref , computed , onMounted , watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute , useRouter } from 'vue-router'
import {
NCalendar , NCard , NSpace , NGrid , NGridItem , NStatistic , NModal , NSpin ,
NEmpty , NButton , NIcon , NSelect , NText , useMessage ,
} from 'naive-ui'
import { ChevronBackOutline , ChevronForwardOutline } from '@vicons/ionicons5'
import { getStatisticsGroup } from '../api/statistics'
import { getTransactionPage } from '../api/transaction'
import { getChannelLabel } from '../utils/channel'
import { saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
import { useStringPreference } from '../composables/usePreference'
import { useBreakpoints } from '../composables/useBreakpoints'
import { STORAGE _KEYS } from '../utils/storageKeys'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
/** 每日/每月汇总数据 */
interface Summary {
income : number
expense : number
count : number
}
/** 当日交易项(与账单详情弹窗的展示字段对齐,供明细列表与详情复用) */
interface CalendarTransaction {
id : number
type : string
amount : number
channel : string
remark : string
remarkHidden : boolean
transactionTime : string
includeInStats : boolean
categoryName : string
categoryIcon : string
tags : { id : number ; name : string ; color : string } [ ]
imageIds : number [ ]
rawData : string | null
createdAt : string
}
/** 明细浮窗单次拉取的最大条数 */
const DETAIL _PAGE _SIZE = 50
/** 年份下拉可选范围:当前年往前 10 年、往后 1 年 */
const YEAR _RANGE _BACK = 10
const { t , locale } = useI18n ( )
const router = useRouter ( )
const route = useRoute ( )
const message = useMessage ( )
const { isMobile } = useBreakpoints ( )
const pad = ( n : number ) => String ( n ) . padStart ( 2 , '0' )
// 视图模式(月/年),持久化记忆
const viewMode = useStringPreference ( STORAGE _KEYS . calendarView , 'month' , {
validate : ( v ) => v === 'month' || v === 'year' ,
} )
/** 合法的统计口径取值 */
const SCOPE _VALUES = [ 'INCLUDED' , 'ALL' , 'EXCLUDED' ]
// 统计口径(是否计入统计),持久化记忆:INCLUDED-仅计入 / ALL-全部 / EXCLUDED-仅不计入
const statsScope = useStringPreference ( STORAGE _KEYS . calendarStatsScope , 'INCLUDED' , {
validate : ( v ) => SCOPE _VALUES . includes ( v ) ,
} )
/** 传给统计接口的口径参数(限定为接口联合类型) */
const scopeParam = computed ( ( ) => statsScope . value as 'INCLUDED' | 'ALL' | 'EXCLUDED' )
/** 统计口径选项 */
const statsScopeOptions = computed ( ( ) => [
{ label : t ( 'dashboard.statsScope.included' ) , value : 'INCLUDED' } ,
{ label : t ( 'dashboard.statsScope.all' ) , value : 'ALL' } ,
{ label : t ( 'dashboard.statsScope.excluded' ) , value : 'EXCLUDED' } ,
] )
// 月视图面板年月(月份为 1-based)
const panelYear = ref ( new Date ( ) . getFullYear ( ) )
const panelMonth = ref ( new Date ( ) . getMonth ( ) + 1 )
// 年视图当前年份
const yearViewYear = ref ( new Date ( ) . getFullYear ( ) )
// 选中日期的时间戳(默认今天)
const selectedTs = ref < number > ( new Date ( ) . setHours ( 0 , 0 , 0 , 0 ) )
// NCalendar 依赖 defaultValue 决定初始面板,程序化切月时通过自增该 key 重建组件
const reloadKey = ref ( 0 )
// URL 优先还原视图与年月,支持刷新保持与链接分享(?view=month|year&y=YYYY&m=MM)
const queryView = String ( route . query . view ? ? '' )
const queryYear = Number ( route . query . y )
const queryMonth = Number ( route . query . m )
const validMonth = Number . isInteger ( queryMonth ) && queryMonth >= 1 && queryMonth <= 12
// 带 view 时以 view 为准;仅带月份参数时按月视图还原;都没有才沿用持久化偏好
if ( queryView === 'year' ) {
viewMode . value = 'year'
} else if ( queryView === 'month' || validMonth ) {
viewMode . value = 'month'
}
if ( Number . isInteger ( queryYear ) && queryYear > 0 ) {
panelYear . value = queryYear
yearViewYear . value = queryYear
}
if ( validMonth ) {
panelMonth . value = queryMonth
}
// 按日期键(YYYY-MM-DD)索引的每日汇总,仅包含有交易的日期
const dayMap = ref < Record < string , Summary > > ( { } )
// 按月份键(YYYY-MM)索引的每月汇总,仅包含有交易的月份
const yearMap = ref < Record < string , Summary > > ( { } )
const loading = ref ( false )
const yearLoading = ref ( false )
// 当日明细浮窗状态
const detailShow = ref ( false )
const detailLoading = ref ( false )
const detailDate = ref ( '' )
const detailList = ref < CalendarTransaction [ ] > ( [ ] )
const detailTotal = ref ( 0 )
// 账单详情弹窗(复用收支记录页的详情组件,日历中只读)
const showTransactionDetail = ref ( false )
const detailTransaction = ref < CalendarTransaction | null > ( null )
/** 当前明细浮窗所查看日期的合计:与日历同源,不受明细列表条数上限影响 */
const detailSummary = computed (
( ) => dayMap . value [ detailDate . value ] ? ? { income : 0 , expense : 0 , count : 0 } ,
)
/** 当前面板首日时间戳(NDatePicker 与 NCalendar defaultValue 共用) */
const panelTs = computed ( ( ) => new Date ( panelYear . value , panelMonth . value - 1 , 1 ) . getTime ( ) )
/** 顶部汇总卡片数据:月视图取当月每日汇总累加,年视图取当年每月汇总累加 */
const summaryCards = computed ( ( ) => {
const map = viewMode . value === 'year' ? yearMap . value : dayMap . value
let income = 0
let expense = 0
let count = 0
for ( const key in map ) {
income += map [ key ] . income
expense += map [ key ] . expense
count += map [ key ] . count
}
return { income , expense , balance : income - expense , count }
} )
/** 月份下拉选项(用当前语言生成月份名;小屏用短名避免选择器过宽) */
const monthOptions = computed ( ( ) => {
const fmt : Intl . DateTimeFormatOptions = { month : isMobile . value ? 'short' : 'long' }
return Array . from ( { length : 12 } , ( _ , i ) => ( {
label : new Intl . DateTimeFormat ( locale . value , fmt ) . format ( new Date ( 2000 , i , 1 ) ) ,
value : i + 1 ,
} ) )
} )
/** 年份下拉选项:当前年往前 10 年、往后 1 年,并保证当前面板年份可选 */
const yearOptions = computed ( ( ) => {
const base = new Date ( ) . getFullYear ( )
const options : { label : string ; value : number } [ ] = [ ]
const seen = new Set < number > ( )
const yearFmt = new Intl . DateTimeFormat ( locale . value , { year : 'numeric' } )
for ( let y = base + 1 ; y >= base - YEAR _RANGE _BACK ; y -- ) {
options . push ( { label : yearFmt . format ( new Date ( y , 0 , 1 ) ) , value : y } )
seen . add ( y )
}
for ( const y of [ panelYear . value , yearViewYear . value ] ) {
if ( ! seen . has ( y ) ) {
options . push ( { label : yearFmt . format ( new Date ( y , 0 , 1 ) ) , value : y } )
seen . add ( y )
}
}
options . sort ( ( a , b ) => b . value - a . value )
return options
} )
/** 生成日期键 YYYY-MM-DD */
function dateKey ( year : number , month : number , date : number ) : string {
return ` ${ year } - ${ pad ( month ) } - ${ pad ( date ) } `
}
/** 生成月份键 YYYY-MM */
function monthKey ( year : number , month : number ) : string {
return ` ${ year } - ${ pad ( month ) } `
}
/** 获取指定日期的汇总,无交易返回 null */
function daySummary ( key : string ) : Summary | null {
return dayMap . value [ key ] ? ? null
}
/** 金额格式化为两位小数 */
function money ( v : number ) : string {
return Number ( v ? ? 0 ) . toFixed ( 2 )
}
/** 小屏金额格式:取整,避免窄单元格内数字换行 */
function moneyCompact ( v : number ) : string {
return String ( Math . round ( Number ( v ? ? 0 ) ) )
}
/** 交易时间取 HH:mm */
function timeOf ( value : string ) : string {
return value ? value . substring ( 11 , 16 ) : ''
}
/** 生成指定月份的长月份名 */
function monthLabel ( month : number ) : string {
return new Intl . DateTimeFormat ( locale . value , { month : 'long' } ) . format ( new Date ( 2000 , month - 1 , 1 ) )
}
/** 是否为今天所在的月份(年视图高亮用) */
function isCurrentMonth ( month : number ) : boolean {
const now = new Date ( )
return yearViewYear . value === now . getFullYear ( ) && month === now . getMonth ( ) + 1
}
/** 加载指定年月的每日统计(接口只返回有交易的日期,无交易的日期由模板按需忽略) */
async function loadMonth ( year : number , month : number ) {
loading . value = true
const startTime = ` ${ year } - ${ pad ( month ) } -01T00:00:00 `
const endTime = ` ${ year } - ${ pad ( month ) } - ${ new Date ( year , month , 0 ) . getDate ( ) } T23:59:59 `
try {
const res : any = await getStatisticsGroup ( {
dimension : 'day' ,
startTime ,
endTime ,
statsScope : scopeParam . value ,
} )
const map : Record < string , Summary > = { }
for ( const item of res . data || [ ] ) {
map [ item . key ] = {
income : Number ( item . income ? ? 0 ) ,
expense : Number ( item . expense ? ? 0 ) ,
count : Number ( item . count ? ? 0 ) ,
}
}
dayMap . value = map
} catch ( e : any ) {
message . error ( e ? . message || t ( 'calendar.loadFailed' ) )
} finally {
loading . value = false
}
}
/** 加载指定年份的每月汇总 */
async function loadYear ( year : number ) {
yearLoading . value = true
try {
const res : any = await getStatisticsGroup ( {
dimension : 'month' ,
startTime : ` ${ year } -01-01T00:00:00 ` ,
endTime : ` ${ year } -12-31T23:59:59 ` ,
statsScope : scopeParam . value ,
} )
const map : Record < string , Summary > = { }
for ( const item of res . data || [ ] ) {
map [ item . key ] = {
income : Number ( item . income ? ? 0 ) ,
expense : Number ( item . expense ? ? 0 ) ,
count : Number ( item . count ? ? 0 ) ,
}
}
yearMap . value = map
} catch ( e : any ) {
message . error ( e ? . message || t ( 'calendar.loadFailed' ) )
} finally {
yearLoading . value = false
}
}
/** 程序化切换月视图面板(下拉/箭头/今天/从年视图进入):更新年月并重建日历,再加载数据 */
function setPanel ( year : number , month : number ) {
const d = new Date ( year , month - 1 , 1 )
panelYear . value = d . getFullYear ( )
panelMonth . value = d . getMonth ( ) + 1
reloadKey . value ++
loadMonth ( panelYear . value , panelMonth . value )
}
/** 月视图:年份/月份下拉选择 */
function onPickPanelYear ( year : number ) {
if ( year === panelYear . value ) return
setPanel ( year , panelMonth . value )
}
function onPickPanelMonth ( month : number ) {
if ( month === panelMonth . value ) return
setPanel ( panelYear . value , month )
}
/** 上一月 / 下一月 */
function prevMonth ( ) {
setPanel ( panelYear . value , panelMonth . value - 1 )
}
function nextMonth ( ) {
setPanel ( panelYear . value , panelMonth . value + 1 )
}
/** 上一月 / 下一月(年视图) */
function prevYear ( ) {
yearViewYear . value -= 1
loadYear ( yearViewYear . value )
}
function nextYear ( ) {
yearViewYear . value += 1
loadYear ( yearViewYear . value )
}
/** 年视图年份下拉选择 */
function handlePickYear ( year : number ) {
yearViewYear . value = year
loadYear ( year )
}
/** 回到今天:切回月视图并选中今天 */
function goToday ( ) {
const now = new Date ( )
selectedTs . value = now . setHours ( 0 , 0 , 0 , 0 )
yearViewYear . value = now . getFullYear ( )
viewMode . value = 'month'
setPanel ( now . getFullYear ( ) , now . getMonth ( ) + 1 )
}
/** 切换月/年视图 */
function switchView ( mode : string ) {
viewMode . value = mode
if ( mode === 'year' ) {
yearViewYear . value = panelYear . value
loadYear ( yearViewYear . value )
} else {
setPanel ( panelYear . value , panelMonth . value )
}
}
/** 年视图点击某月进入该年月视图 */
function openYearMonth ( year : number , month : number ) {
viewMode . value = 'month'
setPanel ( year , month )
}
/** 面板切换(点击相邻月日期)时重新加载对应月份,内部已切换面板故不重建组件 */
function handlePanelChange ( info : { year : number ; month : number } ) {
panelYear . value = info . year
panelMonth . value = info . month
loadMonth ( info . year , info . month )
}
/** 选中日期:记录选中时间并打开当日明细浮窗 */
function handleUpdateValue ( ts : number , time : { year : number ; month : number ; date : number } ) {
selectedTs . value = ts
openDetail ( time . year , time . month , time . date )
}
/** 打开当日明细浮窗并加载该日交易 */
async function openDetail ( year : number , month : number , date : number ) {
const key = dateKey ( year , month , date )
detailDate . value = key
detailShow . value = true
detailLoading . value = true
detailList . value = [ ]
detailTotal . value = 0
try {
const res : any = await getTransactionPage ( {
page : 1 ,
size : DETAIL _PAGE _SIZE ,
startTime : ` ${ key } T00:00:00 ` ,
endTime : ` ${ key } T23:59:59 ` ,
sortField : 'transactionTime' ,
sortOrder : 'asc' ,
} )
detailList . value = res . data ? . records || [ ]
detailTotal . value = Number ( res . data ? . total ? ? 0 )
} catch ( e : any ) {
message . error ( e ? . message || t ( 'calendar.detailLoadFailed' ) )
} finally {
detailLoading . value = false
}
}
/** 跳转到收支记录页并按当日筛选 */
function goToTransactions ( ) {
const [ y , m , d ] = detailDate . value . split ( '-' ) . map ( Number )
if ( ! y || ! m || ! d ) return
saveTransactionFilterPrefs ( {
filterDateRange : [
new Date ( y , m - 1 , d , 0 , 0 , 0 ) . getTime ( ) ,
new Date ( y , m - 1 , d , 23 , 59 , 59 ) . getTime ( ) ,
] ,
} )
detailShow . value = false
router . push ( '/transactions' )
}
/** 打开账单详情弹窗(复用收支记录页的详情组件,日历中只读) */
function openTransactionDetail ( item : CalendarTransaction ) {
detailTransaction . value = item
showTransactionDetail . value = true
}
/** 将当前视图与年月同步到 URL query(replace,避免堆积历史与重复导航) */
function syncUrl ( ) {
const next : Record < string , string > = { view : viewMode . value }
if ( viewMode . value === 'year' ) {
next . y = String ( yearViewYear . value )
} else {
next . y = String ( panelYear . value )
next . m = String ( panelMonth . value )
}
const curView = String ( route . query . view ? ? '' )
const curY = String ( route . query . y ? ? '' )
const curM = String ( route . query . m ? ? '' )
if ( curView !== next . view || curY !== next . y || curM !== ( next . m ? ? '' ) ) {
router . replace ( { query : next } )
}
}
// 视图/年月变化时同步到 URL,初始化时补齐 query
watch ( [ ( ) => panelYear . value , ( ) => panelMonth . value , ( ) => yearViewYear . value , viewMode ] , syncUrl , {
immediate : true ,
} )
// 切换统计口径时按当前视图重新加载数据
watch ( statsScope , ( ) => {
if ( viewMode . value === 'year' ) {
loadYear ( yearViewYear . value )
} else {
loadMonth ( panelYear . value , panelMonth . value )
}
} )
onMounted ( ( ) => {
if ( viewMode . value === 'year' ) {
yearViewYear . value = panelYear . value
loadYear ( yearViewYear . value )
} else {
loadMonth ( panelYear . value , panelMonth . value )
}
} )
< / script >
< template >
< div >
<!-- 顶部 : 标题与提示 -- >
< NSpace justify = "space-between" align = "center" style = "margin-bottom: 20px;" :wrap = "true" >
< h2 style = "margin: 0;" > { { t ( 'calendar.title' ) } } < / h2 >
< NText depth = "3" style = "font-size: 12px;" > { { t ( 'calendar.hint' ) } } < / NText >
< / NSpace >
<!-- 汇总卡片 : 月视图显示当月 、 年视图显示当年 -- >
< NGrid responsive = "screen" cols = "1 s:2 m:4" :x-gap = "16" :y-gap = "16" style = "margin-bottom: 16px;" >
< NGridItem >
< NCard >
< NStatistic :label = "t('dashboard.totalIncome')" :value = "money(summaryCards.income)" style = "color: #18a058;" / >
< / NCard >
< / NGridItem >
< NGridItem >
< NCard >
< NStatistic :label = "t('dashboard.totalExpense')" :value = "money(summaryCards.expense)" style = "color: #d03050;" / >
< / NCard >
< / NGridItem >
< NGridItem >
< NCard >
< NStatistic :label = "t('dashboard.balance')" :value = "money(summaryCards.balance)" / >
< / NCard >
< / NGridItem >
< NGridItem >
< NCard >
< NStatistic :label = "t('calendar.transactionCount')" :value = "summaryCards.count" / >
< / NCard >
< / NGridItem >
< / NGrid >
< NCard >
<!-- 自建表头 : 左右箭头 + 年月选择 + 今天 + 视图切换 -- >
< div class = "cal-toolbar" >
< NSpace align = "center" :size = "8" :wrap = "true" >
< NButton
class = "cal-toolbar__nav"
quaternary
circle
:aria-label = "t('calendar.prevPeriod')"
@click ="viewMode === 'year' ? prevYear() : prevMonth()"
>
< template # icon >
< NIcon :component = "ChevronBackOutline" / >
< / template >
< / NButton >
< template v-if = "viewMode === 'month'" >
< NSelect
class = "cal-toolbar__select"
:value = "panelYear"
:options = "yearOptions"
: style = "{ width: isMobile ? '92px' : '110px' }"
@ update : value = "(v) => onPickPanelYear(Number(v))"
/ >
< NSelect
class = "cal-toolbar__select"
:value = "panelMonth"
:options = "monthOptions"
: style = "{ width: isMobile ? '96px' : '150px' }"
@ update : value = "(v) => onPickPanelMonth(Number(v))"
/ >
< / template >
< NSelect
v-else
class = "cal-toolbar__select"
:value = "yearViewYear"
:options = "yearOptions"
: style = "{ width: isMobile ? '100px' : '130px' }"
@ update : value = "(v) => handlePickYear(Number(v))"
/ >
< NButton
class = "cal-toolbar__nav"
quaternary
circle
:aria-label = "t('calendar.nextPeriod')"
@click ="viewMode === 'year' ? nextYear() : nextMonth()"
>
< template # icon >
< NIcon :component = "ChevronForwardOutline" / >
< / template >
< / NButton >
< / NSpace >
< NSpace align = "center" :size = "8" >
< NSelect
v -model :value = "statsScope"
class = "cal-toolbar__scope"
:options = "statsScopeOptions"
: style = "{ width: isMobile ? '128px' : '150px' }"
/ >
< NButton class = "cal-toolbar__today" size = "small" @click ="goToday" > {{ t ( ' calendar.today ' ) }} < / NButton >
< NButton
class = "cal-toolbar__view"
size = "small"
: type = "viewMode === 'year' ? 'primary' : 'default'"
: secondary = "viewMode !== 'year'"
@click ="switchView(viewMode === 'year' ? 'month' : 'year')"
>
{ { viewMode === 'year' ? t ( 'calendar.viewMonth' ) : t ( 'calendar.viewYear' ) } }
< / NButton >
< / NSpace >
< / div >
<!-- 月视图 -- >
< div
v-if = "viewMode === 'month'"
class = "calendar-wrap"
: class = "{ 'calendar-wrap--mobile': isMobile }"
>
< NSpin :show = "loading" >
< NCalendar
:key = "reloadKey"
:value = "selectedTs"
:default-value = "panelTs"
@ panel -change = " handlePanelChange "
@ update :value = "handleUpdateValue"
>
< template # default = "{ year, month, date }" >
< div v-if = "year === panelYear && month === panelMonth" class="day-cell" >
< template v-for = "d in [daySummary(dateKey(year, month, date))]" :key="`cell-${date}`" >
< template v-if = "d" >
< div v-if = "isMobile" class="day-cell__row" :style="{ color: d.expense > 0 ? '#d03050' : '#18a058' }" >
{{ d.expense > 0 ? ` - ${ moneyCompact ( d . expense ) } ` : ` + ${ moneyCompact ( d . income ) } ` } }
< / div >
< template v-else >
< div v-if = "d.income > 0" class="day-cell__row day-cell__row--income" > + {{ money ( d.income ) }} < / div >
< div v-if = "d.expense > 0" class="day-cell__row day-cell__row--expense" > - {{ money ( d.expense ) }} < / div >
< div
v-if = "d.income > 0 && d.expense > 0 && d.income - d.expense !== 0"
class = "day-cell__row day-cell__row--balance"
>
{ { money ( d . income - d . expense ) } }
< / div >
< / template >
< / template >
< / template >
< / div >
< / template >
< / NCalendar >
< / NSpin >
< div v-if = "!loading && Object.keys(dayMap).length === 0" class="calendar-empty" >
{{ t ( ' calendar.monthEmpty ' ) }}
< / div >
< / div >
< ! - - 年视图 : 12 个月概览 , 点击进入该月 - - >
< div v-else class = "year-view" >
< NSpin :show = "yearLoading" >
< NEmpty
v-if = "!yearLoading && Object.keys(yearMap).length === 0"
:description = "t('calendar.yearEmpty')"
style = "padding: 40px 0;"
/ >
< NGrid v-else responsive = "screen" cols = "1 s:2 m:3 l:4" :x-gap = "12" :y-gap = "12" >
< NGridItem v-for = "m in 12" :key="m" >
< div
class = "year-cell"
: class = "{ 'year-cell--active': isCurrentMonth(m) }"
@click ="openYearMonth(yearViewYear, m)"
>
< div class = "year-cell__month" > { { monthLabel ( m ) } } < / div >
< template v-for = "s in [yearMap[monthKey(yearViewYear, m)]]" :key="`sum-${m}`" >
< template v-if = "s" >
< div class = "year-cell__row year-cell__row--income" >
< span > { { t ( 'calendar.income' ) } } < / span > + { { money ( s . income ) } }
< / div >
< div class = "year-cell__row year-cell__row--expense" >
< span > { { t ( 'calendar.expense' ) } } < / span > - { { money ( s . expense ) } }
< / div >
< div class = "year-cell__row year-cell__row--balance" >
< span > { { t ( 'calendar.balance' ) } } < / span > { { money ( s . income - s . expense ) } }
< / div >
< / template >
< div v-else class = "year-cell__empty" > { { t ( 'calendar.noData' ) } } < / div >
< / template >
< / div >
< / NGridItem >
< / NGrid >
< / NSpin >
< / div >
< / NCard >
<!-- 当日明细浮窗 -- >
< NModal
v -model :show = "detailShow"
preset = "card"
: title = "t('calendar.detailTitle', { date: detailDate })"
class = "detail-modal"
style = "max-width: 92vw;"
>
<!-- 当日合计 : 与日历同源 , 不受明细列表条数上限影响 -- >
< div class = "detail-summary" >
< span class = "detail-summary__income" > { { t ( 'calendar.income' ) } } + { { money ( detailSummary . income ) } } < / span >
< span class = "detail-summary__expense" > { { t ( 'calendar.expense' ) } } - { { money ( detailSummary . expense ) } } < / span >
< span
class = "detail-summary__balance"
: style = "{ color: detailSummary.income - detailSummary.expense < 0 ? '#d03050' : '#18a058' }"
>
{ { t ( 'calendar.balance' ) } } { { money ( detailSummary . income - detailSummary . expense ) } }
< / span >
< / div >
< NSpin :show = "detailLoading" >
< NEmpty v-if = "!detailLoading && detailList.length === 0" :description="t('calendar.detailEmpty')" / >
< div v-else class = "detail-list" >
< div
v-for = "item in detailList"
:key = "item.id"
class = "detail-item detail-item--clickable"
@click ="openTransactionDetail(item)"
>
< div class = "detail-item__main" >
< span class = "detail-item__time" > { { timeOf ( item . transactionTime ) } } < / span >
< span class = "detail-item__category" >
< span v-if = "item.categoryIcon" class="detail-item__icon" > {{ item.categoryIcon }} < / span >
{{ item.categoryName | | t ( ' transaction.uncategorized ' ) }}
< / span >
< span v-if = "item.channel" class="detail-item__channel" > {{ getChannelLabel ( item.channel ) }} < / span >
< span
class = "detail-item__amount"
: style = "{ color: item.type === 'INCOME' ? '#18a058' : '#d03050' }"
>
{ { item . type === 'INCOME' ? '+' : '-' } } { { money ( item . amount ) } }
< / span >
< / div >
< div v-if = "item.remark && !item.remarkHidden" class="detail-item__remark" > {{ item.remark }} < / div >
< / div >
< / div >
< / NSpin >
< template # footer >
< NSpace justify = "space-between" align = "center" :wrap = "true" >
< NText v-if = "detailTotal > DETAIL_PAGE_SIZE" depth="3" style="font-size: 12px;" >
{{ t ( ' calendar.detailMore ' , { shown : DETAIL_PAGE_SIZE , total : detailTotal } ) }}
< / NText >
< span v-else / >
< NButton type = "primary" @click ="goToTransactions" > {{ t ( ' calendar.viewInTransactions ' ) }} < / NButton >
< / NSpace >
< / template >
< / NModal >
< ! - - 账单详情弹窗 ( 只读 , 复用收支记录页详情组件 ) - - >
< TransactionDetailModal
v -model :show = "showTransactionDetail"
:transaction = "detailTransaction"
:editable = "false"
/ >
< / div >
< / template >
< style scoped >
/* 自建表头:左右两组,小屏自动换行 */
. cal - toolbar {
display : flex ;
flex - wrap : wrap ;
align - items : center ;
justify - content : space - between ;
gap : 8 px 12 px ;
margin - bottom : 16 px ;
}
/* 年月选择器作为醒目标题:放大字号 */
. cal - toolbar _ _select : deep ( . n - base - selection - label ) ,
. cal - toolbar _ _select : deep ( . n - base - selection - input ) ,
. cal - toolbar _ _select : deep ( . n - base - selection - placeholder ) {
font - size : 16 px ;
font - weight : 600 ;
}
/* 隐藏 NCalendar 自带表头(改用自建表头) */
. calendar - wrap : deep ( . n - calendar - header ) {
display : none ;
}
/* 月历容器:解除 NCalendar 固定高度,改为按单元格行高铺满 */
. calendar - wrap : deep ( . n - calendar ) {
height : auto ;
}
. calendar - wrap : deep ( . n - calendar - dates ) {
grid - auto - rows : 108 px ;
}
/* 单元格内每日汇总:紧凑排版,超宽时裁剪避免撑破单元格 */
. day - cell {
margin - top : 4 px ;
display : flex ;
flex - direction : column ;
gap : 2 px ;
font - size : 12 px ;
line - height : 1.3 ;
overflow : hidden ;
}
. day - cell _ _row {
white - space : nowrap ;
}
. day - cell _ _row -- income {
color : # 18 a058 ;
}
. day - cell _ _row -- expense {
color : # d03050 ;
}
. day - cell _ _row -- balance {
color : # 666 ;
}
/* 小屏:降低行高与字号,仅保留一个金额 */
. calendar - wrap -- mobile : deep ( . n - calendar - dates ) {
grid - auto - rows : 72 px ;
}
. calendar - wrap -- mobile . day - cell {
margin - top : 2 px ;
gap : 0 ;
font - size : 10 px ;
}
/* 小屏:隐藏单元格内的星期几,避免窄列内文字竖排换行 */
. calendar - wrap -- mobile : deep ( . n - calendar - date _ _day ) {
display : none ;
}
/* 小屏:收窄单元格内边距,为金额留出更多横向空间 */
. calendar - wrap -- mobile : deep ( . n - calendar - cell ) {
padding : 4 px ;
}
. calendar - wrap : deep ( . n - calendar - date ) {
flex - wrap : nowrap ;
}
/* 年视图:每月卡片 */
. year - view {
min - height : 200 px ;
}
. year - cell {
height : 100 % ;
padding : 12 px 14 px ;
border : 1 px solid # efeff5 ;
border - radius : 8 px ;
background : # fafafc ;
cursor : pointer ;
transition : border - color 0.2 s , background - color 0.2 s ;
}
. year - cell : hover {
border - color : # 18 a058 ;
background : # f2fbf6 ;
}
. year - cell -- active {
border - color : # 18 a058 ;
}
. year - cell _ _month {
font - size : 15 px ;
font - weight : 600 ;
color : # 333 ;
margin - bottom : 8 px ;
}
. year - cell _ _row {
display : flex ;
justify - content : space - between ;
gap : 8 px ;
font - size : 12 px ;
line - height : 1.6 ;
white - space : nowrap ;
}
. year - cell _ _row span {
color : # 999 ;
}
. year - cell _ _row -- income {
color : # 18 a058 ;
}
. year - cell _ _row -- expense {
color : # d03050 ;
}
. year - cell _ _row -- balance {
color : # 666 ;
}
. year - cell _ _empty {
color : # bbb ;
font - size : 12 px ;
line - height : 1.6 ;
}
/* 当日合计 */
. detail - summary {
display : flex ;
flex - wrap : wrap ;
gap : 6 px 16 px ;
margin - bottom : 12 px ;
font - size : 13 px ;
}
. detail - summary _ _income {
color : # 18 a058 ;
}
. detail - summary _ _expense {
color : # d03050 ;
}
. detail - summary _ _balance {
font - weight : 600 ;
}
/* 明细列表 */
. detail - modal {
width : 560 px ;
}
. detail - list {
max - height : 54 vh ;
overflow - y : auto ;
}
. detail - item {
display : flex ;
flex - wrap : wrap ;
align - items : center ;
gap : 4 px 12 px ;
padding : 10 px 4 px ;
border - bottom : 1 px solid # f0f0f0 ;
}
. detail - item _ _main {
display : flex ;
flex - wrap : wrap ;
align - items : center ;
gap : 4 px 10 px ;
min - width : 0 ;
flex : 1 ;
}
. detail - item _ _time {
color : # 999 ;
font - size : 12 px ;
flex - shrink : 0 ;
}
. detail - item _ _category {
color : # 333 ;
font - size : 13 px ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. detail - item _ _icon {
margin - right : 2 px ;
}
. detail - item _ _channel {
color : # 999 ;
font - size : 12 px ;
flex - shrink : 0 ;
}
. detail - item _ _amount {
margin - left : 4 px ;
font - weight : 600 ;
font - size : 14 px ;
flex - shrink : 0 ;
}
. detail - item _ _remark {
flex - basis : 100 % ;
color : # 999 ;
font - size : 12 px ;
padding - left : 44 px ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
/* 明细行可点击查看详情 */
. detail - item -- clickable {
cursor : pointer ;
border - radius : 6 px ;
}
. detail - item -- clickable : hover {
background : # fafafc ;
}
/* 当月无数据的提示 */
. calendar - empty {
margin - top : 12 px ;
text - align : center ;
color : # 999 ;
font - size : 13 px ;
}
< / style >