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:
@@ -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>
|
||||
Reference in New Issue
Block a user