Files
ledgerly/frontend/src/views/Calendar.vue
T
rosercode 007e6727a6 feat: 新增日历页面并设为默认首页
新增日历页以月视图展示每日收支汇总、以年视图展示每月汇总,支持月份切换、统计口径过滤与当日明细查看,并将日历置于侧边导航首位、作为访问 / 的默认落地页。

变更内容:
- 新增 `views/Calendar.vue`:月视图按日展示收入/支出/结余与当月合计,年视图以 12 月网格展示每月汇总并可点击进入该月,表头支持上一/下一期、年与月下拉跳转、「今天」及月/年视图切换
- 日历支持统计口径过滤(仅计入统计/全部记录/仅不计入,持久化于 `ledgerly.calendar.statsScope`),仅作用于汇总、不影响当日明细
- 点击日期弹出当日明细浮窗(顶部显示当日收入/支出/结余合计),明细行在条目主行展示金额,可点击打开完整账单详情(只读)并跳转到收支记录按当日筛选
- 视图模式持久化于 `ledgerly.calendar.view`,当前视图与年月同步到 URL(`?view=month|year&y=YYYY&m=MM`),月/年无数据时显示空状态
- `TransactionDetailModal` 新增 `editable` 属性(默认 true)以支持只读场景;`utils/navItems.ts` 将日历置于导航首位;`router` 与 `Login` 默认跳转由统计面板改为日历;补充导航、存储键与 `layout.calendar`、`calendar.*` 三语文案
2026-09-30 17:50:21 +08:00

907 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: 8px 12px;
margin-bottom: 16px;
}
/* 年月选择器作为醒目标题:放大字号 */
.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: 16px;
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: 108px;
}
/* 单元格内每日汇总:紧凑排版,超宽时裁剪避免撑破单元格 */
.day-cell {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
}
.day-cell__row {
white-space: nowrap;
}
.day-cell__row--income {
color: #18a058;
}
.day-cell__row--expense {
color: #d03050;
}
.day-cell__row--balance {
color: #666;
}
/* 小屏:降低行高与字号,仅保留一个金额 */
.calendar-wrap--mobile :deep(.n-calendar-dates) {
grid-auto-rows: 72px;
}
.calendar-wrap--mobile .day-cell {
margin-top: 2px;
gap: 0;
font-size: 10px;
}
/* 小屏:隐藏单元格内的星期几,避免窄列内文字竖排换行 */
.calendar-wrap--mobile :deep(.n-calendar-date__day) {
display: none;
}
/* 小屏:收窄单元格内边距,为金额留出更多横向空间 */
.calendar-wrap--mobile :deep(.n-calendar-cell) {
padding: 4px;
}
.calendar-wrap :deep(.n-calendar-date) {
flex-wrap: nowrap;
}
/* 年视图:每月卡片 */
.year-view {
min-height: 200px;
}
.year-cell {
height: 100%;
padding: 12px 14px;
border: 1px solid #efeff5;
border-radius: 8px;
background: #fafafc;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.year-cell:hover {
border-color: #18a058;
background: #f2fbf6;
}
.year-cell--active {
border-color: #18a058;
}
.year-cell__month {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.year-cell__row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
line-height: 1.6;
white-space: nowrap;
}
.year-cell__row span {
color: #999;
}
.year-cell__row--income {
color: #18a058;
}
.year-cell__row--expense {
color: #d03050;
}
.year-cell__row--balance {
color: #666;
}
.year-cell__empty {
color: #bbb;
font-size: 12px;
line-height: 1.6;
}
/* 当日合计 */
.detail-summary {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin-bottom: 12px;
font-size: 13px;
}
.detail-summary__income {
color: #18a058;
}
.detail-summary__expense {
color: #d03050;
}
.detail-summary__balance {
font-weight: 600;
}
/* 明细列表 */
.detail-modal {
width: 560px;
}
.detail-list {
max-height: 54vh;
overflow-y: auto;
}
.detail-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
padding: 10px 4px;
border-bottom: 1px solid #f0f0f0;
}
.detail-item__main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
min-width: 0;
flex: 1;
}
.detail-item__time {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
.detail-item__category {
color: #333;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-item__icon {
margin-right: 2px;
}
.detail-item__channel {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
.detail-item__amount {
margin-left: 4px;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.detail-item__remark {
flex-basis: 100%;
color: #999;
font-size: 12px;
padding-left: 44px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 明细行可点击查看详情 */
.detail-item--clickable {
cursor: pointer;
border-radius: 6px;
}
.detail-item--clickable:hover {
background: #fafafc;
}
/* 当月无数据的提示 */
.calendar-empty {
margin-top: 12px;
text-align: center;
color: #999;
font-size: 13px;
}
</style>