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.*` 三语文案
This commit is contained in:
2026-09-30 17:50:21 +08:00
parent 75e75ad190
commit 007e6727a6
8 changed files with 954 additions and 8 deletions
@@ -33,10 +33,14 @@ interface ImageItem {
loading: boolean
}
const props = defineProps<{
const props = withDefaults(defineProps<{
show: boolean
transaction: DetailTransaction | null
}>()
/** 是否显示「编辑」按钮;日历等只读场景传 false */
editable?: boolean
}>(), {
editable: true,
})
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
@@ -239,7 +243,7 @@ function clearImages() {
<template #footer>
<NSpace justify="end">
<NButton @click="emit('edit')">{{ t('common.edit') }}</NButton>
<NButton v-if="editable" @click="emit('edit')">{{ t('common.edit') }}</NButton>
<NButton @click="onShowChange(false)">{{ t('common.cancel') }}</NButton>
</NSpace>
</template>
+25
View File
@@ -88,6 +88,7 @@ export default {
appTitle: { zh: '汇账', en: 'Ledgerly', ja: 'Ledgerly' },
appSubtitle: { zh: '个人财务管理', en: 'Personal Finance', ja: '個人財務管理' },
dashboard: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' },
calendar: { zh: '日历', en: 'Calendar', ja: 'カレンダー' },
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
@@ -414,6 +415,30 @@ export default {
chartActionView: { zh: '查看明细', en: 'View Details', ja: '明細を表示' },
},
// 日历
calendar: {
title: { zh: '日历', en: 'Calendar', ja: 'カレンダー' },
hint: { zh: '点击日期查看当天交易明细', en: "Click a date to view that day's transactions", ja: '日付をクリックするとその日の明細を表示します' },
loadFailed: { zh: '加载日历数据失败', en: 'Failed to load calendar data', ja: 'カレンダーデータの読み込みに失敗しました' },
transactionCount: { zh: '交易笔数', en: 'Transactions', ja: '取引件数' },
today: { zh: '今天', en: 'Today', ja: '今日' },
viewMonth: { zh: '月视图', en: 'Month', ja: '月' },
viewYear: { zh: '年视图', en: 'Year', ja: '年' },
prevPeriod: { zh: '上一个', en: 'Previous', ja: '前へ' },
nextPeriod: { zh: '下一个', en: 'Next', ja: '次へ' },
income: { zh: '收入', en: 'Income', ja: '収入' },
expense: { zh: '支出', en: 'Expense', ja: '支出' },
balance: { zh: '结余', en: 'Balance', ja: '収支' },
noData: { zh: '无数据', en: 'No data', ja: 'データなし' },
monthEmpty: { zh: '本月暂无交易数据', en: 'No transactions this month', ja: '今月の取引はありません' },
yearEmpty: { zh: '本年暂无交易数据', en: 'No transactions this year', ja: '今年の取引はありません' },
detailTitle: { zh: '{date} 交易明细', en: 'Transactions on {date}', ja: '{date} の取引明細' },
detailEmpty: { zh: '当天没有交易记录', en: 'No transactions on this day', ja: 'この日の取引はありません' },
detailLoadFailed: { zh: '加载明细失败', en: 'Failed to load details', ja: '明細の読み込みに失敗しました' },
detailMore: { zh: '仅显示前 {shown} 条,共 {total} 条', en: 'Showing first {shown} of {total}', ja: '先頭 {shown} 件を表示(全 {total} 件)' },
viewInTransactions: { zh: '在收支记录中查看', en: 'View in Transactions', ja: '収支記録で表示' },
},
// 收支记录
transaction: {
title: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
+8 -3
View File
@@ -34,13 +34,18 @@ const router = createRouter({
{
path: '/',
component: () => import('../views/Layout.vue'), // 主布局(带侧边栏)
redirect: '/dashboard', // 默认跳转到统计面板
redirect: '/calendar', // 默认跳转到日历
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'), // 统计面板
},
{
path: 'calendar',
name: 'Calendar',
component: () => import('../views/Calendar.vue'), // 日历(每日收支概览)
},
{
path: 'smart-query',
name: 'SqlQuery',
@@ -139,7 +144,7 @@ router.beforeEach(async (to, _from, next) => {
// 已配置数据库:配置引导页不再需要,跳转到登录或首页
if (to.path === '/database-setup') {
const userStore = useUserStore()
next(userStore.token ? '/dashboard' : '/login')
next(userStore.token ? '/calendar' : '/login')
return
}
@@ -151,7 +156,7 @@ router.beforeEach(async (to, _from, next) => {
const target =
typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: '/dashboard'
: '/calendar'
next(target)
return
}
+2 -1
View File
@@ -1,7 +1,7 @@
// 侧边导航清单(单一数据源):定义各导航项的标识、文案、图标与默认显隐/顺序,供主布局与设置页共用
import { markRaw, type Component } from 'vue'
import {
BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, SparklesOutline,
BarChartOutline, CalendarOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, SparklesOutline,
} from '@vicons/ionicons5'
/** 导航项定义 */
@@ -20,6 +20,7 @@ export interface NavItemDef {
/** 默认导航项清单,数组顺序即默认展示顺序;图标用 markRaw 避免被响应式包装 */
export const NAV_ITEMS: NavItemDef[] = [
{ key: 'calendar', labelKey: 'layout.calendar', icon: markRaw(CalendarOutline), aiDependent: false, defaultVisible: true },
{ key: 'dashboard', labelKey: 'layout.dashboard', icon: markRaw(BarChartOutline), aiDependent: false, defaultVisible: true },
{ key: 'smart-query', labelKey: 'layout.sqlQuery', icon: markRaw(SparklesOutline), aiDependent: true, defaultVisible: true },
{ key: 'transactions', labelKey: 'layout.transactions', icon: markRaw(ListOutline), aiDependent: false, defaultVisible: true },
+4
View File
@@ -13,6 +13,10 @@ export const STORAGE_KEYS = {
billImportBillType: 'ledgerly.billImport.billType',
// 统计面板偏好
dashboardPrefs: 'ledgerly.dashboard.prefs',
// 日历视图模式(month-月视图 / year-年视图)
calendarView: 'ledgerly.calendar.view',
// 日历统计口径(INCLUDED-仅计入 / ALL-全部 / EXCLUDED-仅不计入)
calendarStatsScope: 'ledgerly.calendar.statsScope',
// 统计面板 AI 查询历史
dashboardAiSearchHistory: 'ledgerly.dashboard.aiSearchHistory',
// 统计面板 AI 查询浮窗位置尺寸
+906
View File
@@ -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: 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>
+1
View File
@@ -32,6 +32,7 @@ const showDrawer = ref(false)
const activeKey = computed(() => {
const map: Record<string, string> = {
'/dashboard': 'dashboard',
'/calendar': 'calendar',
'/smart-query': 'smart-query',
'/transactions': 'transactions',
'/categories': 'categories',
+1 -1
View File
@@ -39,7 +39,7 @@ function resolveRedirect(): string {
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
return target
}
return '/dashboard'
return '/calendar'
}
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */