Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4092b0830a | ||
|
|
971853560a | ||
|
|
d300fd9b96 |
@@ -0,0 +1,19 @@
|
|||||||
|
# v0.7.1 记账周期统计
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
- 记账支持按统计周期查看:按月 / 按期 / 自定义三种模式,统计卡、支出分类、表格与筛选全部跟随所选周期。
|
||||||
|
- 按期模式:设置「账期起点」(如实习开始日 10-14)后自动切分第 1、2、3…期,第 N 期 = 起点 + (N-1) 个月,到起点 + N 个月的前一天(如 10.14 开启,第 1 期为 10.14 – 11.13)。
|
||||||
|
- 账期起点存于设置表,随账号多端同步;设置起点后自动切换为按期视图。
|
||||||
|
- 起点之前如有账目,期列表中提供「起点前」入口,避免数据「不见」的疑惑。
|
||||||
|
- 自定义模式:任意起止日期区间(如 10.14 – 10.31)。
|
||||||
|
- 新增「统计周期」选择弹窗:月份列表(仅有账目的月份)、期列表(带日期范围)、自定义区间三个页签。
|
||||||
|
|
||||||
|
## 优化
|
||||||
|
- 工具栏月份标题改为可点击的周期选择按钮,‹ › 按当前模式逐周期移动。
|
||||||
|
- 「回到本月」按钮仅在偏离当前周期时出现,减少常驻按钮。
|
||||||
|
- 自定义模式下隐藏前后翻页箭头(无操作对象)。
|
||||||
|
- 清除页面遗留的快速记账表单死代码(约 90 行未使用的脚本)。
|
||||||
|
- 文案「本月」统一为「本期」(统计卡、分类、空态、计数)。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 纯客户端功能,无 schema 变更、无服务端改动;部署后刷新网页即生效。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# v0.7.2 顶栏精简
|
||||||
|
|
||||||
|
## 优化
|
||||||
|
- 移除右上角常驻的「上班打卡」按钮:打卡在「详情」页当日面板与日历当日抽屉中操作,顶栏不再重复。
|
||||||
|
- 移除右上角常驻的「填写本周周记」提示按钮:改为周五至周日打开应用时 toast 提示一次,不占用常驻界面;设置页「周记提醒」开关继续有效。
|
||||||
|
- 顶栏仅保留页面标题与搜索,移动端搜索按钮右对齐。
|
||||||
|
- 清除相关死代码约 80 行(打卡三态、周记提示样式)。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# v0.8.0 记账系统升级:多选分类统计 + 数据可视化
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
- **分类统计(多选)**:支出 / 收入分类以可多选的胶囊呈现,任选几类即实时计算「合计金额 · 笔数 · 占本期比例」,同时联动下方明细表过滤;分类条按所选高亮、未选淡显。
|
||||||
|
- **每日收支柱状图**:本周期内每日收入(绿)/ 支出(灰)双柱对比,悬浮显示当日金额;自定义区间超过 92 天自动按月聚合,超过 120 桶仅展示最近 120 个并提示。
|
||||||
|
- 分类条新增彩色标识(8 色低饱和配色,按分类名稳定取色)与独立占比列。
|
||||||
|
|
||||||
|
## 优化
|
||||||
|
- 原工具栏「全部分类」下拉移除,由分类统计区多选胶囊承担(可多选、可看合计,能力超集)。
|
||||||
|
- 金额全面改为千分位格式(统计卡、分类条、合计、表格)。
|
||||||
|
- 分类条布局重排:色标 + 名称 + 进度条 + 金额右对齐 + 占比,长分类名截断不再挤压。
|
||||||
|
- 记一笔弹窗:报销状态三段选择器由半栏改为整行(修复溢出),金额输入放大加粗、数字等宽,标签精简,移动端按钮加高至触控基线。
|
||||||
|
- 移动端:图表横向可滑动、柱宽自适应,分类胶囊自动换行。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 纯客户端升级,无 schema 与服务端改动;所有统计跟随统计周期(按月 / 按期 / 自定义)。
|
||||||
+27
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Cn9aGqjN.js","./index-vpp14W9X.js","./index-D9ZLjO2x.css"])))=>i.map(i=>d[i]);
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-oDK3lWem.js","./index-BI5aMIlx.js","./index-CwfY7_G6.css"])))=>i.map(i=>d[i]);
|
||||||
import{r as s,_ as i}from"./index-vpp14W9X.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-Cn9aGqjN.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
import{r as s,_ as i}from"./index-BI5aMIlx.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-oDK3lWem.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||||
-27
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
|||||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
<title>实习工作日志</title>
|
<title>实习工作日志</title>
|
||||||
<script type="module" crossorigin src="./assets/index-vpp14W9X.js"></script>
|
<script type="module" crossorigin src="./assets/index-BI5aMIlx.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-D9ZLjO2x.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CwfY7_G6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "intern-log",
|
"name": "intern-log",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+18
-97
@@ -15,11 +15,11 @@ import UserAdminView from './views/UserAdminView.vue'
|
|||||||
import LoginView from './views/LoginView.vue'
|
import LoginView from './views/LoginView.vue'
|
||||||
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
||||||
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
|
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
|
||||||
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
|
import { getWorkRule, getWeeklyNote, searchLogItems, searchMemos } from './db/repo'
|
||||||
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
|
import { todayStr, weekStart } from './lib/date'
|
||||||
import { showToast, useToasts } from './lib/toast'
|
import { showToast, useToasts } from './lib/toast'
|
||||||
import { attendanceChanged, focusDate, focusMemo, notifyAttendanceChanged } from './lib/bus'
|
import { focusDate, focusMemo } from './lib/bus'
|
||||||
import { categoryLabel, type Attendance, type LogItem, type Memo } from './db/schema'
|
import { categoryLabel, type LogItem, type Memo } from './db/schema'
|
||||||
|
|
||||||
const toasts = useToasts()
|
const toasts = useToasts()
|
||||||
|
|
||||||
@@ -30,8 +30,7 @@ async function onLogin(s: Session) {
|
|||||||
openUserDatabase(s.userId)
|
openUserDatabase(s.userId)
|
||||||
location.hash = '#/calendar'
|
location.hash = '#/calendar'
|
||||||
route.value = 'calendar'
|
route.value = 'calendar'
|
||||||
await refreshClock()
|
await checkWeeklyReminder()
|
||||||
await refreshTip()
|
|
||||||
startSyncTimer()
|
startSyncTimer()
|
||||||
try {
|
try {
|
||||||
await ensureCloudBinding()
|
await ensureCloudBinding()
|
||||||
@@ -161,72 +160,24 @@ function pickLog(item: LogItem & { date: string }) { openLog(item); mobileSearch
|
|||||||
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
|
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
|
||||||
|
|
||||||
|
|
||||||
// ---- 工具栏打卡 ----
|
// ---- 周记提醒:周五至周日打开应用时 toast 提示一次,不占用常驻界面 ----
|
||||||
const today = ref(todayStr())
|
async function checkWeeklyReminder() {
|
||||||
const att = ref<Attendance | undefined>(undefined)
|
try {
|
||||||
const yesterdayPending = ref(false)
|
const rule = await getWorkRule()
|
||||||
|
if (rule.remind_weekly === 'off') return
|
||||||
async function refreshClock() {
|
|
||||||
att.value = await getAttendance(today.value)
|
|
||||||
yesterdayPending.value = false
|
|
||||||
const now = new Date()
|
|
||||||
if (now.getHours() < 4) {
|
|
||||||
const yRec = await getAttendance(addDays(today.value, -1))
|
|
||||||
yesterdayPending.value = !!(yRec && yRec.clock_in_at && !yRec.clock_out_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActiveSession = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
if (a && a.clock_in_at && !a.clock_out_at) return true
|
|
||||||
return yesterdayPending.value && !(a && a.clock_in_at)
|
|
||||||
})
|
|
||||||
const clockDone = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
return !!(a && a.clock_in_at && a.clock_out_at)
|
|
||||||
})
|
|
||||||
const clockLabel = computed(() => {
|
|
||||||
const a = att.value
|
|
||||||
if (hasActiveSession.value) return '下班打卡'
|
|
||||||
if (clockDone.value) return '已打卡 ' + hhmm(a?.clock_in_at) + ' - ' + hhmm(a?.clock_out_at)
|
|
||||||
return '上班打卡'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function doClock() {
|
|
||||||
if (hasActiveSession.value) {
|
|
||||||
try { await smartClockOut() } catch { /* 没有进行中的打卡 */ }
|
|
||||||
} else {
|
|
||||||
await clockIn(today.value)
|
|
||||||
}
|
|
||||||
await refreshClock()
|
|
||||||
notifyAttendanceChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任何视图发生的考勤变更(含今日面板、日历抽屉)都实时同步到顶栏
|
|
||||||
watch(attendanceChanged, () => {
|
|
||||||
if (session.value) refreshClock()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- 周记提醒 ----
|
|
||||||
const weekTip = ref(false)
|
|
||||||
async function refreshTip() {
|
|
||||||
const rule: WorkRule = await getWorkRule()
|
|
||||||
if (rule.remind_weekly === 'off') { weekTip.value = false; return }
|
|
||||||
const wd = new Date().getDay()
|
const wd = new Date().getDay()
|
||||||
if (wd === 5 || wd === 6 || wd === 0) {
|
if (wd !== 5 && wd !== 6 && wd !== 0) return
|
||||||
const note = await getWeeklyNote(weekStart(today.value))
|
const note = await getWeeklyNote(weekStart(todayStr()))
|
||||||
weekTip.value = !note || (!note.highlights && !note.challenges && !note.plan_next)
|
if (note && (note.highlights || note.challenges || note.plan_next)) return
|
||||||
} else {
|
showToast('本周周记还没写,可在「总结」页填写')
|
||||||
weekTip.value = false
|
} catch { /* 提醒检查失败不影响使用 */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onGlobalKey)
|
window.addEventListener('keydown', onGlobalKey)
|
||||||
if (session.value) {
|
if (session.value) {
|
||||||
openUserDatabase(session.value.userId)
|
openUserDatabase(session.value.userId)
|
||||||
await refreshClock()
|
await checkWeeklyReminder()
|
||||||
await refreshTip()
|
|
||||||
startSyncTimer()
|
startSyncTimer()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -287,17 +238,7 @@ onMounted(async () => {
|
|||||||
<div v-else class="gs-empty muted">无匹配结果</div>
|
<div v-else class="gs-empty muted">无匹配结果</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="flex1"></span>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<button v-if="weekTip" class="week-tip" @click="nav('report')">
|
|
||||||
<span class="dot" style="background: var(--warn)"></span>
|
|
||||||
填写本周周记
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" :disabled="clockDone" @click="doClock">
|
|
||||||
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
|
|
||||||
<span class="num">{{ clockLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<div class="content-inner">
|
<div class="content-inner">
|
||||||
@@ -507,8 +448,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
|
||||||
|
|
||||||
.gsearch { position: relative; width: 320px; max-width: 38vw; }
|
.gsearch { position: relative; width: 320px; max-width: 38vw; }
|
||||||
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
||||||
.gs-input {
|
.gs-input {
|
||||||
@@ -554,21 +493,6 @@ onMounted(async () => {
|
|||||||
.gs-date { font-size: 12px; flex: none; }
|
.gs-date { font-size: 12px; flex: none; }
|
||||||
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
|
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
|
||||||
|
|
||||||
.week-tip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid var(--warn);
|
|
||||||
color: var(--warn);
|
|
||||||
background: var(--bg-page);
|
|
||||||
border-radius: 999px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.week-tip:hover { background: #FDF4EC; }
|
|
||||||
|
|
||||||
.content { flex: 1; overflow: auto; }
|
.content { flex: 1; overflow: auto; }
|
||||||
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
|
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
|
||||||
@@ -752,10 +676,7 @@ onMounted(async () => {
|
|||||||
.gsearch { display: none; }
|
.gsearch { display: none; }
|
||||||
.gsearch .gs-panel { display: none; }
|
.gsearch .gs-panel { display: none; }
|
||||||
.mob-search-btn { display: flex; }
|
.mob-search-btn { display: flex; }
|
||||||
.topbar-actions { margin-left: auto; gap: 6px; }
|
.mob-search-btn { margin-left: auto; }
|
||||||
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
|
|
||||||
.topbar-actions .btn { padding: 0 12px; height: 34px; }
|
|
||||||
.topbar-actions .btn .num { font-size: 12px; }
|
|
||||||
|
|
||||||
.tabbar { display: flex; }
|
.tabbar { display: flex; }
|
||||||
.tab-btn { height: 56px; font-size: 11px; }
|
.tab-btn { height: 56px; font-size: 11px; }
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { addDays, todayStr } from '../lib/date'
|
||||||
|
import { cycleStartOf, cycleEndOf, fmtDayRef, fmtRange } from '../lib/period'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
mode: 'month' | 'cycle' | 'custom'
|
||||||
|
anchor: string
|
||||||
|
month: string
|
||||||
|
cycleSel: number | 'before'
|
||||||
|
customStart: string
|
||||||
|
customEnd: string
|
||||||
|
cycleCount: number
|
||||||
|
hasBefore: boolean
|
||||||
|
months: string[]
|
||||||
|
}>(), { anchor: '', months: () => [] })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'apply', payload: { mode: 'month' | 'cycle' | 'custom'; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }): void
|
||||||
|
(e: 'saveAnchor', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dMode = ref(props.mode)
|
||||||
|
const dMonth = ref(props.month.slice(0, 7))
|
||||||
|
const dCycle = ref<number | 'before'>(props.cycleSel)
|
||||||
|
const dStart = ref(props.customStart)
|
||||||
|
const dEnd = ref(props.customEnd)
|
||||||
|
const dAnchor = ref(props.anchor)
|
||||||
|
const customErr = ref('')
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
watch(() => props.open, v => {
|
||||||
|
if (v) {
|
||||||
|
dMode.value = props.mode
|
||||||
|
dMonth.value = props.month.slice(0, 7)
|
||||||
|
dCycle.value = props.cycleSel
|
||||||
|
dStart.value = props.customStart
|
||||||
|
dEnd.value = props.customEnd
|
||||||
|
dAnchor.value = props.anchor
|
||||||
|
customErr.value = ''
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(() => [props.anchor, props.cycleSel] as const, () => {
|
||||||
|
dAnchor.value = props.anchor
|
||||||
|
dCycle.value = props.cycleSel
|
||||||
|
})
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||||
|
|
||||||
|
// 有账目的月份 ∪ 当前选中月 ∪ 本月,倒序
|
||||||
|
const monthOptions = computed(() => {
|
||||||
|
const set = new Set(props.months)
|
||||||
|
set.add(dMonth.value)
|
||||||
|
set.add(todayStr().slice(0, 7))
|
||||||
|
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeEndLabel = computed(() =>
|
||||||
|
props.anchor ? fmtDayRef(addDays(props.anchor, -1), props.anchor) : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
function monthLabel(m: string): string {
|
||||||
|
return m.slice(0, 4) + ' 年 ' + Number(m.slice(5, 7)) + ' 月'
|
||||||
|
}
|
||||||
|
function pickMonth(m: string) {
|
||||||
|
dMonth.value = m
|
||||||
|
emit('apply', { mode: 'month', month: m })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
function pickCycle(c: number | 'before') {
|
||||||
|
dCycle.value = c
|
||||||
|
emit('apply', { mode: 'cycle', cycle: c })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
function saveAnchor() {
|
||||||
|
if (!dAnchor.value) return
|
||||||
|
emit('saveAnchor', dAnchor.value)
|
||||||
|
}
|
||||||
|
function applyCustom() {
|
||||||
|
if (!dStart.value || !dEnd.value) { customErr.value = '请选择开始和结束日期'; return }
|
||||||
|
if (dStart.value > dEnd.value) { customErr.value = '开始日期不能晚于结束日期'; return }
|
||||||
|
customErr.value = ''
|
||||||
|
emit('apply', { mode: 'custom', customStart: dStart.value, customEnd: dEnd.value })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="modal">
|
||||||
|
<div v-if="open" class="mask" @click.self="emit('close')">
|
||||||
|
<div class="dialog period-dialog" role="dialog" aria-label="统计周期">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<span class="dialog-title">统计周期</span>
|
||||||
|
<button class="icon-btn" title="关闭" @click="emit('close')">
|
||||||
|
<Icon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="seg seg-row">
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'month' }" @click="dMode = 'month'">按月</button>
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'cycle' }" @click="dMode = 'cycle'">按期</button>
|
||||||
|
<button class="seg-btn" :class="{ active: dMode === 'custom' }" @click="dMode = 'custom'">自定义</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按月:有账目的月份列表 -->
|
||||||
|
<div v-if="dMode === 'month'" class="pane">
|
||||||
|
<button
|
||||||
|
v-for="m in monthOptions" :key="m"
|
||||||
|
class="opt" :class="{ active: m === dMonth }"
|
||||||
|
@click="pickMonth(m)"
|
||||||
|
>
|
||||||
|
<span>{{ monthLabel(m) }}</span>
|
||||||
|
<Icon v-if="m === dMonth" name="check-circle" :size="15" />
|
||||||
|
</button>
|
||||||
|
<div v-if="!monthOptions.length" class="hint">还没有账目记录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按期:账期起点 + 期列表 -->
|
||||||
|
<div v-else-if="dMode === 'cycle'" class="pane">
|
||||||
|
<div class="anchor-row">
|
||||||
|
<span class="anchor-label">账期起点</span>
|
||||||
|
<input class="input" type="date" v-model="dAnchor" />
|
||||||
|
<button class="btn btn-sm" :disabled="!dAnchor || dAnchor === anchor" @click="saveAnchor">保存</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!dAnchor" class="hint">填写实习开始日期并保存,自动切分为第 1、2、3…期(每期到下月同日前一天)</div>
|
||||||
|
<div v-else-if="dAnchor !== anchor" class="hint">起点已修改,保存后按新起点重新切分</div>
|
||||||
|
<template v-if="anchor">
|
||||||
|
<button
|
||||||
|
v-if="hasBefore" class="opt" :class="{ active: dCycle === 'before' }"
|
||||||
|
@click="pickCycle('before')"
|
||||||
|
>
|
||||||
|
<span>起点前</span>
|
||||||
|
<span class="muted num">~ {{ beforeEndLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="n in cycleCount" :key="n"
|
||||||
|
class="opt" :class="{ active: dCycle === n }"
|
||||||
|
@click="pickCycle(n)"
|
||||||
|
>
|
||||||
|
<span>第 {{ n }} 期</span>
|
||||||
|
<span class="muted num">{{ fmtRange(cycleStartOf(anchor, n), cycleEndOf(anchor, n)) }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义:任意起止区间 -->
|
||||||
|
<div v-else class="pane">
|
||||||
|
<div class="custom-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>开始日期</label>
|
||||||
|
<input class="input" type="date" v-model="dStart" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>结束日期</label>
|
||||||
|
<input class="input" type="date" v-model="dEnd" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="customErr" class="form-error">{{ customErr }}</div>
|
||||||
|
<button class="btn btn-primary apply-btn" @click="applyCustom">应用区间</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-btns">
|
||||||
|
<button class="btn btn-sm" @click="emit('close')">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.period-dialog { width: 400px; }
|
||||||
|
.seg-row { display: flex; width: 100%; }
|
||||||
|
.seg-row .seg-btn { flex: 1; }
|
||||||
|
.pane { margin-top: 12px; max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.opt {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
border: none; background: transparent; cursor: pointer;
|
||||||
|
padding: 8px 10px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; color: var(--text-1); text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.opt:hover { background: var(--bg-hover); }
|
||||||
|
.opt.active { background: rgba(51, 112, 255, 0.08); color: var(--accent); font-weight: 600; }
|
||||||
|
.opt.active .muted { color: var(--accent); opacity: 0.75; }
|
||||||
|
.anchor-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.anchor-label { font-size: 12px; color: var(--text-2); flex: none; }
|
||||||
|
.anchor-row .input { flex: 1; min-width: 0; }
|
||||||
|
.hint { font-size: 12px; color: var(--text-3); padding: 4px 2px; }
|
||||||
|
.custom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.apply-btn { width: 100%; margin-top: 6px; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||||
|
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.period-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
|
||||||
|
.dialog-btns .btn { flex: 1; height: 40px; }
|
||||||
|
.pane { max-height: 48vh; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -117,15 +117,15 @@ function save() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>金额(元,必填)</label>
|
<label>金额(元)</label>
|
||||||
<input ref="amountInput" class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" />
|
<input ref="amountInput" class="input amt-input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" inputmode="decimal" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>日期(必填)</label>
|
<label>日期</label>
|
||||||
<input class="input" type="date" v-model="fDate" />
|
<input class="input" type="date" v-model="fDate" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>分类(可直接新增)</label>
|
<label>分类</label>
|
||||||
<select class="select" v-model="fCategory">
|
<select class="select" v-model="fCategory">
|
||||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -134,7 +134,7 @@ function save() {
|
|||||||
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
|
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" v-if="fType === 'expense'">
|
<div class="field span2" v-if="fType === 'expense'">
|
||||||
<label>报销状态</label>
|
<label>报销状态</label>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
|
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
|
||||||
@@ -162,6 +162,10 @@ function save() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dialog { width: 480px; }
|
.dialog { width: 480px; }
|
||||||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||||||
|
.amt-input { height: 38px; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.dialog-btns .btn { flex: 1; height: 40px; }
|
||||||
|
}
|
||||||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.add-cat-input { height: 34px; }
|
.add-cat-input { height: 34px; }
|
||||||
|
|||||||
@@ -634,6 +634,19 @@ export async function setTransactionReimbursed(id: number, done: boolean): Promi
|
|||||||
await commit()
|
await commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有账目的月份列表(YYYY-MM,倒序),供周期选择弹窗使用
|
||||||
|
export async function listTransactionMonths(): Promise<string[]> {
|
||||||
|
const all = await db.table('transaction').toArray()
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of all) if (r && r.date) set.add(r.date.slice(0, 7))
|
||||||
|
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某日期之前的账目条数(判断「起点前」是否有数据)
|
||||||
|
export async function countTransactionsBefore(date: string): Promise<number> {
|
||||||
|
return db.table('transaction').where('date').below(date).count()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 备份 ----------
|
// ---------- 备份 ----------
|
||||||
|
|
||||||
export async function exportAll(): Promise<string> {
|
export async function exportAll(): Promise<string> {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 账期(统计周期)推算:全部基于 YYYY-MM-DD 字符串与整数运算,不涉及时区。
|
||||||
|
// 第 n 期 = [起点 + (n-1) 个月, 起点 + n 个月 - 1 天];月加法沿用 date.ts 的自然月末收敛
|
||||||
|
//(如起点 01-31,下一期从 02-28 起)。
|
||||||
|
import { addDays, addMonths } from './date'
|
||||||
|
|
||||||
|
export type CycleSel = number | 'before'
|
||||||
|
|
||||||
|
// 第 n 期的开始日期(n 从 1 开始)
|
||||||
|
export function cycleStartOf(anchor: string, n: number): string {
|
||||||
|
return addMonths(anchor, n - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 n 期的结束日期
|
||||||
|
export function cycleEndOf(anchor: string, n: number): string {
|
||||||
|
return addDays(addMonths(anchor, n), -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某日期落在第几期;早于起点返回 0(即「起点前」)
|
||||||
|
export function cycleIndexOf(anchor: string, date: string): number {
|
||||||
|
if (!anchor || date < anchor) return 0
|
||||||
|
let n = (Number(date.slice(0, 4)) - Number(anchor.slice(0, 4))) * 12
|
||||||
|
+ (Number(date.slice(5, 7)) - Number(anchor.slice(5, 7))) + 1
|
||||||
|
if (n < 1) n = 1
|
||||||
|
if (n > 1 && date < cycleStartOf(anchor, n)) n--
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10.14 形式的短日期
|
||||||
|
export function fmtDay(d: string): string {
|
||||||
|
return Number(d.slice(5, 7)) + '.' + Number(d.slice(8, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与参照日期同年则省略年份,跨年则带完整年月日
|
||||||
|
export function fmtDayRef(d: string, ref: string): string {
|
||||||
|
return d.slice(0, 4) === ref.slice(0, 4) ? fmtDay(d) : d.split('-').join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「10.14 – 11.13」,跨年时两侧均带年份
|
||||||
|
export function fmtRange(start: string, end: string): string {
|
||||||
|
return fmtDayRef(start, end) + ' – ' + fmtDayRef(end, start)
|
||||||
|
}
|
||||||
+426
-126
@@ -3,49 +3,139 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import TransactionDialog from '../components/TransactionDialog.vue'
|
import TransactionDialog from '../components/TransactionDialog.vue'
|
||||||
|
import PeriodDialog from '../components/PeriodDialog.vue'
|
||||||
import {
|
import {
|
||||||
addTransaction, deleteTransaction, listTransactionsBetween,
|
addTransaction, countTransactionsBefore, deleteTransaction, getSetting,
|
||||||
|
listTransactionMonths, listTransactionsBetween, setSetting,
|
||||||
setTransactionReimbursed, updateTransaction, type Transaction
|
setTransactionReimbursed, updateTransaction, type Transaction
|
||||||
} from '../db/repo'
|
} from '../db/repo'
|
||||||
import { getLedgerCategories } from '../db/categories'
|
import { getLedgerCategories } from '../db/categories'
|
||||||
import { addMonths, todayStr } from '../lib/date'
|
import { addDays, addMonths, parseDateStr, todayStr } from '../lib/date'
|
||||||
|
import { cycleEndOf, cycleIndexOf, cycleStartOf, fmtDayRef, fmtRange, type CycleSel } from '../lib/period'
|
||||||
import { showToast } from '../lib/toast'
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
type TxType = 'expense' | 'income'
|
type TxType = 'expense' | 'income'
|
||||||
|
type PeriodMode = 'month' | 'cycle' | 'custom'
|
||||||
|
|
||||||
|
// 周期偏好为设备本地习惯;账期起点存 setting 表,随账号多端同步
|
||||||
|
const LS_MODE = 'dsh_ledger_mode'
|
||||||
|
const LS_CYCLE = 'dsh_ledger_cycle'
|
||||||
|
const LS_CSTART = 'dsh_ledger_cstart'
|
||||||
|
const LS_CEND = 'dsh_ledger_cend'
|
||||||
|
const EARLIEST = '0000-01-01'
|
||||||
|
|
||||||
|
// 分类可视化配色(高级白低饱和,按分类名稳定取色)
|
||||||
|
const PALETTE = ['#3370FF', '#23A757', '#7A5AF8', '#0EA5A0', '#E8842C', '#D96D5A', '#5B7A99', '#B56CC6']
|
||||||
|
function catColor(name: string): string {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||||||
|
return PALETTE[h % PALETTE.length]
|
||||||
|
}
|
||||||
|
function fmtMoney(cents: number): string {
|
||||||
|
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = ref<PeriodMode>('month')
|
||||||
const monthRef = ref(todayStr())
|
const monthRef = ref(todayStr())
|
||||||
|
const cycleSel = ref<CycleSel>(1)
|
||||||
|
const customStart = ref(todayStr().slice(0, 7) + '-01')
|
||||||
|
const customEnd = ref(todayStr())
|
||||||
|
const anchor = ref('')
|
||||||
|
const hasBefore = ref(false)
|
||||||
|
const txMonths = ref<string[]>([])
|
||||||
|
|
||||||
const rows = ref<Transaction[]>([])
|
const rows = ref<Transaction[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
// ---- 新增表单 ----
|
|
||||||
const fType = ref<TxType>('expense')
|
|
||||||
const fAmount = ref('')
|
|
||||||
const fCategory = ref('')
|
|
||||||
const fDate = ref(todayStr())
|
|
||||||
const fNote = ref('')
|
|
||||||
const fReimb = ref(false)
|
|
||||||
const fError = ref('')
|
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||||
const deleteTarget = ref<Transaction | null>(null)
|
const deleteTarget = ref<Transaction | null>(null)
|
||||||
|
const periodOpen = ref(false)
|
||||||
|
|
||||||
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
|
// ---- 统计周期 ----
|
||||||
const monthEnd = computed(() => {
|
|
||||||
const y = Number(monthRef.value.slice(0, 4))
|
function monthEndOf(ym: string): string {
|
||||||
const m0 = Number(monthRef.value.slice(5, 7)) - 1
|
const y = Number(ym.slice(0, 4))
|
||||||
|
const m0 = Number(ym.slice(5, 7)) - 1
|
||||||
const last = new Date(y, m0 + 1, 0).getDate()
|
const last = new Date(y, m0 + 1, 0).getDate()
|
||||||
return monthRef.value.slice(0, 7) + '-' + (last < 10 ? '0' + last : String(last))
|
return ym + '-' + (last < 10 ? '0' + last : String(last))
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1))
|
||||||
|
|
||||||
|
const rangeStart = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01'
|
||||||
|
if (mode.value === 'custom') return customStart.value
|
||||||
|
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01'
|
||||||
|
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
|
||||||
})
|
})
|
||||||
const title = computed(() => Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月')
|
const rangeEnd = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthEndOf(monthRef.value.slice(0, 7))
|
||||||
|
if (mode.value === 'custom') return customEnd.value
|
||||||
|
if (!anchor.value) return monthEndOf(monthRef.value.slice(0, 7))
|
||||||
|
return cycleSel.value === 'before' ? addDays(anchor.value, -1) : cycleEndOf(anchor.value, cycleSel.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (mode.value === 'month') return Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月'
|
||||||
|
if (mode.value === 'custom') return fmtRange(rangeStart.value, rangeEnd.value)
|
||||||
|
if (!anchor.value) return '按期统计 · 设置起点'
|
||||||
|
if (cycleSel.value === 'before') return '起点前 · ~ ' + fmtDayRef(addDays(anchor.value, -1), anchor.value)
|
||||||
|
return '第 ' + cycleSel.value + ' 期 · ' + fmtRange(rangeStart.value, rangeEnd.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canPrev = computed(() => {
|
||||||
|
if (mode.value === 'month') return true
|
||||||
|
if (mode.value === 'cycle' && anchor.value) {
|
||||||
|
if (cycleSel.value === 'before') return false
|
||||||
|
return cycleSel.value > 1 || hasBefore.value
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (mode.value === 'month') return true
|
||||||
|
if (mode.value === 'cycle' && anchor.value) {
|
||||||
|
return cycleSel.value === 'before' ? true : cycleSel.value < currentCycle.value
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
function step(dir: -1 | 1) {
|
||||||
|
if (mode.value === 'month') {
|
||||||
|
monthRef.value = addMonths(monthRef.value, dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode.value !== 'cycle' || !anchor.value) return
|
||||||
|
if (cycleSel.value === 'before') {
|
||||||
|
if (dir === 1) cycleSel.value = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number(cycleSel.value) + dir
|
||||||
|
if (n > currentCycle.value) return
|
||||||
|
if (n < 1) {
|
||||||
|
if (hasBefore.value) cycleSel.value = 'before'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cycleSel.value = n
|
||||||
|
}
|
||||||
|
|
||||||
|
const offCurrent = computed(() => {
|
||||||
|
if (mode.value === 'month') return monthRef.value.slice(0, 7) !== todayStr().slice(0, 7)
|
||||||
|
if (mode.value === 'cycle') return !!anchor.value && cycleSel.value !== currentCycle.value
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
function goCurrent() {
|
||||||
|
if (mode.value === 'cycle') cycleSel.value = currentCycle.value
|
||||||
|
else monthRef.value = todayStr()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据加载:全部统计共用 [rangeStart, rangeEnd] ----
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
|
rows.value = await listTransactionsBetween(rangeStart.value, rangeEnd.value)
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
||||||
@@ -53,18 +143,62 @@ async function load() {
|
|||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
onMounted(load)
|
|
||||||
watch(monthRef, load)
|
onMounted(async () => {
|
||||||
|
const savedMode = localStorage.getItem(LS_MODE)
|
||||||
|
if (savedMode === 'month' || savedMode === 'cycle' || savedMode === 'custom') mode.value = savedMode
|
||||||
|
const cs = localStorage.getItem(LS_CSTART)
|
||||||
|
const ce = localStorage.getItem(LS_CEND)
|
||||||
|
if (cs && ce && cs <= ce) { customStart.value = cs; customEnd.value = ce }
|
||||||
|
const cyc = localStorage.getItem(LS_CYCLE)
|
||||||
|
if (cyc === 'before') cycleSel.value = 'before'
|
||||||
|
else if (cyc && Number(cyc) >= 1) cycleSel.value = Number(cyc)
|
||||||
|
anchor.value = await getSetting('ledger.anchor', '')
|
||||||
|
if (cycleSel.value !== 'before' && cycleSel.value > currentCycle.value) cycleSel.value = currentCycle.value
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
watch([rangeStart, rangeEnd], () => {
|
||||||
|
selectedCats.value = [] // 切换周期重置分类选择
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
watch(mode, v => localStorage.setItem(LS_MODE, v))
|
||||||
|
watch(cycleSel, v => localStorage.setItem(LS_CYCLE, String(v)))
|
||||||
|
watch([customStart, customEnd], ([s, e]) => {
|
||||||
|
localStorage.setItem(LS_CSTART, s)
|
||||||
|
localStorage.setItem(LS_CEND, e)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openPeriodDialog() {
|
||||||
|
try {
|
||||||
|
txMonths.value = await listTransactionMonths()
|
||||||
|
hasBefore.value = anchor.value ? (await countTransactionsBefore(anchor.value)) > 0 : false
|
||||||
|
} catch { /* 统计失败不阻塞打开弹窗 */ }
|
||||||
|
periodOpen.value = true
|
||||||
|
}
|
||||||
|
function onApply(p: { mode: PeriodMode; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }) {
|
||||||
|
mode.value = p.mode
|
||||||
|
if (p.month) monthRef.value = p.month + '-01'
|
||||||
|
if (p.cycle !== undefined) cycleSel.value = p.cycle
|
||||||
|
if (p.customStart) customStart.value = p.customStart
|
||||||
|
if (p.customEnd) customEnd.value = p.customEnd
|
||||||
|
}
|
||||||
|
async function onSaveAnchor(v: string) {
|
||||||
|
anchor.value = v
|
||||||
|
await setSetting('ledger.anchor', v)
|
||||||
|
mode.value = 'cycle'
|
||||||
|
cycleSel.value = Math.max(1, cycleIndexOf(v, todayStr()))
|
||||||
|
try {
|
||||||
|
hasBefore.value = (await countTransactionsBefore(v)) > 0
|
||||||
|
} catch { /* 忽略统计失败 */ }
|
||||||
|
showToast('账期起点已保存,已按新起点切分期数')
|
||||||
|
}
|
||||||
|
|
||||||
const expenseCats = ref<string[]>([])
|
const expenseCats = ref<string[]>([])
|
||||||
const incomeCats = ref<string[]>([])
|
|
||||||
async function loadCats() {
|
async function loadCats() {
|
||||||
expenseCats.value = await getLedgerCategories('expense')
|
expenseCats.value = await getLedgerCategories('expense')
|
||||||
incomeCats.value = await getLedgerCategories('income')
|
|
||||||
}
|
}
|
||||||
loadCats()
|
loadCats()
|
||||||
|
|
||||||
|
|
||||||
const sorted = computed(() =>
|
const sorted = computed(() =>
|
||||||
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
|
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
|
||||||
)
|
)
|
||||||
@@ -74,9 +208,8 @@ const summary = computed(() => {
|
|||||||
let expense = 0
|
let expense = 0
|
||||||
let pending = 0
|
let pending = 0
|
||||||
for (const r of rows.value) {
|
for (const r of rows.value) {
|
||||||
if (r.type === 'income') {
|
if (r.type === 'income') income += r.amount
|
||||||
income += r.amount
|
else {
|
||||||
} else {
|
|
||||||
expense += r.amount
|
expense += r.amount
|
||||||
if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount
|
if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount
|
||||||
}
|
}
|
||||||
@@ -84,96 +217,143 @@ const summary = computed(() => {
|
|||||||
return { income, expense, pending, balance: income - expense }
|
return { income, expense, pending, balance: income - expense }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- 每日收支(自定义长区间自动按月聚合) ----
|
||||||
|
|
||||||
|
interface Bucket { label: string; tip: string; income: number; expense: number; showLabel: boolean }
|
||||||
|
const bucketHint = ref('')
|
||||||
|
const buckets = computed<Bucket[]>(() => {
|
||||||
|
bucketHint.value = ''
|
||||||
|
const list: Bucket[] = []
|
||||||
|
const start = rangeStart.value
|
||||||
|
const end = rangeEnd.value
|
||||||
|
if (!start || !end || start > end) return list
|
||||||
|
const days = Math.round((parseDateStr(end).getTime() - parseDateStr(start).getTime()) / 86400000) + 1
|
||||||
|
// 收集:日/月两个粒度各一份,按需取用
|
||||||
|
const daily = new Map<string, { i: number; e: number }>()
|
||||||
|
const monthly = new Map<string, { i: number; e: number }>()
|
||||||
|
for (const r of rows.value) {
|
||||||
|
const d = daily.get(r.date) || { i: 0, e: 0 }
|
||||||
|
if (r.type === 'income') d.i += r.amount; else d.e += r.amount
|
||||||
|
daily.set(r.date, d)
|
||||||
|
const m = r.date.slice(0, 7)
|
||||||
|
const mo = monthly.get(m) || { i: 0, e: 0 }
|
||||||
|
if (r.type === 'income') mo.i += r.amount; else mo.e += r.amount
|
||||||
|
monthly.set(m, mo)
|
||||||
|
}
|
||||||
|
let keys: string[] = []
|
||||||
|
let gran: 'day' | 'month' = 'day'
|
||||||
|
if (days <= 92) {
|
||||||
|
let cur = start
|
||||||
|
let guard = 0
|
||||||
|
while (cur <= end && guard < 4000) { keys.push(cur); cur = addDays(cur, 1); guard++ }
|
||||||
|
} else {
|
||||||
|
gran = 'month'
|
||||||
|
let ym = start.slice(0, 7)
|
||||||
|
const endYm = end.slice(0, 7)
|
||||||
|
let guard = 0
|
||||||
|
while (ym <= endYm && guard < 600) {
|
||||||
|
keys.push(ym)
|
||||||
|
const y = Number(ym.slice(0, 4)), m0 = Number(ym.slice(5, 7)) - 1
|
||||||
|
const next = new Date(y, m0 + 1, 1)
|
||||||
|
ym = next.getFullYear() + '-' + (next.getMonth() + 1 < 10 ? '0' + (next.getMonth() + 1) : String(next.getMonth() + 1))
|
||||||
|
guard++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keys.length) return list
|
||||||
|
if (keys.length > 120) {
|
||||||
|
if (gran === 'day') bucketHint.value = '时间跨度较长,仅展示最近 120 天'
|
||||||
|
else bucketHint.value = '时间跨度较长,仅展示最近 120 个月'
|
||||||
|
keys = keys.slice(-120)
|
||||||
|
}
|
||||||
|
const src = gran === 'day' ? daily : monthly
|
||||||
|
const labelEvery = Math.max(1, Math.ceil(keys.length / 8))
|
||||||
|
keys.forEach((k, idx) => {
|
||||||
|
const v = src.get(k) || { i: 0, e: 0 }
|
||||||
|
let label = ''
|
||||||
|
let tip = ''
|
||||||
|
if (gran === 'day') {
|
||||||
|
label = Number(k.slice(5, 7)) + '.' + Number(k.slice(8, 10))
|
||||||
|
tip = k + ' · 支出 ' + fmtMoney(v.e) + ' · 收入 ' + fmtMoney(v.i)
|
||||||
|
} else {
|
||||||
|
label = k.slice(2, 4) + '.' + Number(k.slice(5, 7))
|
||||||
|
tip = Number(k.slice(0, 4)) + ' 年 ' + Number(k.slice(5, 7)) + ' 月 · 支出 ' + fmtMoney(v.e) + ' · 收入 ' + fmtMoney(v.i)
|
||||||
|
}
|
||||||
|
list.push({ label, tip, income: v.i, expense: v.e, showLabel: idx % labelEvery === 0 || idx === keys.length - 1 })
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
const bucketMax = computed(() => {
|
||||||
|
let m = 0
|
||||||
|
for (const b of buckets.value) m = Math.max(m, b.income, b.expense)
|
||||||
|
return m || 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 分类统计(多选) ----
|
||||||
|
|
||||||
|
const chartType = ref<TxType>('expense')
|
||||||
|
const selectedCats = ref<string[]>([])
|
||||||
|
watch(chartType, () => { selectedCats.value = [] })
|
||||||
|
|
||||||
|
function toggleCat(name: string) {
|
||||||
|
const i = selectedCats.value.indexOf(name)
|
||||||
|
if (i >= 0) selectedCats.value.splice(i, 1)
|
||||||
|
else selectedCats.value = [...selectedCats.value, name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCats = computed(() => {
|
||||||
|
const map = new Map<string, { name: string; amount: number; count: number }>()
|
||||||
|
for (const r of rows.value) {
|
||||||
|
if (r.type !== chartType.value) continue
|
||||||
|
const c = map.get(r.category) || { name: r.category, amount: 0, count: 0 }
|
||||||
|
c.amount += r.amount
|
||||||
|
c.count++
|
||||||
|
map.set(r.category, c)
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.amount - a.amount)
|
||||||
|
})
|
||||||
|
const activeTypeTotal = computed(() => activeCats.value.reduce((s, c) => s + c.amount, 0))
|
||||||
|
const catRows = computed(() =>
|
||||||
|
activeCats.value.map(c => ({
|
||||||
|
...c,
|
||||||
|
color: catColor(c.name),
|
||||||
|
percent: activeTypeTotal.value ? Math.round((c.amount / activeTypeTotal.value) * 100) : 0,
|
||||||
|
dim: selectedCats.value.length > 0 && !selectedCats.value.includes(c.name)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const selSummary = computed(() => {
|
||||||
|
if (!selectedCats.value.length) return null
|
||||||
|
let amount = 0
|
||||||
|
let count = 0
|
||||||
|
for (const r of rows.value) {
|
||||||
|
if (r.type !== chartType.value || !selectedCats.value.includes(r.category)) continue
|
||||||
|
amount += r.amount
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cats: selectedCats.value.length,
|
||||||
|
count,
|
||||||
|
amount,
|
||||||
|
percent: activeTypeTotal.value ? Math.round((amount / activeTypeTotal.value) * 100) : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const typeFilter = ref<'all' | TxType>('all')
|
const typeFilter = ref<'all' | TxType>('all')
|
||||||
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
|
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
|
||||||
const catFilter = ref<string>('all')
|
|
||||||
// 切换月份时重置分类筛选(新月份的分类集合可能不同)
|
|
||||||
watch(monthRef, () => { catFilter.value = 'all' })
|
|
||||||
const usedCategories = computed(() =>
|
|
||||||
Array.from(new Set(rows.value.map(r => r.category))).sort((a, b) => a.localeCompare(b, 'zh'))
|
|
||||||
)
|
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
sorted.value.filter(r => {
|
sorted.value.filter(r => {
|
||||||
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
|
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
|
||||||
if (catFilter.value !== 'all' && r.category !== catFilter.value) return false
|
if (selectedCats.value.length && !selectedCats.value.includes(r.category)) return false
|
||||||
if (reimbFilter.value === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
|
if (reimbFilter.value === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
|
||||||
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
|
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const categoryStats = computed(() => {
|
|
||||||
const map = new Map<string, number>()
|
|
||||||
let total = 0
|
|
||||||
for (const r of rows.value) {
|
|
||||||
if (r.type !== 'expense') continue
|
|
||||||
map.set(r.category, (map.get(r.category) || 0) + r.amount)
|
|
||||||
total += r.amount
|
|
||||||
}
|
|
||||||
const base = total || 1
|
|
||||||
return Array.from(map.entries())
|
|
||||||
.map(pair => ({ name: pair[0], amount: pair[1], percent: Math.round((pair[1] / base) * 100) }))
|
|
||||||
.sort((a, b) => b.amount - a.amount)
|
|
||||||
})
|
|
||||||
|
|
||||||
function yuan(cents: number): string {
|
function yuan(cents: number): string {
|
||||||
return (cents / 100).toFixed(2)
|
return fmtMoney(cents)
|
||||||
}
|
}
|
||||||
function amountText(r: Transaction): string {
|
function amountText(r: Transaction): string {
|
||||||
return (r.type === 'income' ? '+' : '-') + yuan(r.amount)
|
return (r.type === 'income' ? '+' : '-') + fmtMoney(r.amount)
|
||||||
}
|
|
||||||
|
|
||||||
function prevMonth() { monthRef.value = addMonths(monthRef.value, -1) }
|
|
||||||
function nextMonth() { monthRef.value = addMonths(monthRef.value, 1) }
|
|
||||||
function goThisMonth() { monthRef.value = todayStr() }
|
|
||||||
|
|
||||||
watch(fAmount, () => {
|
|
||||||
if (fError.value) fError.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const amountText = String(fAmount.value ?? '').trim()
|
|
||||||
const v = Number(amountText)
|
|
||||||
if (!amountText || !isFinite(v) || v <= 0) {
|
|
||||||
fError.value = '请填写大于 0 的金额'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!fDate.value) {
|
|
||||||
fError.value = '请选择日期'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (submitting.value) return
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await addTransaction({
|
|
||||||
type: fType.value,
|
|
||||||
category: fCategory.value,
|
|
||||||
amount: Math.round(v * 100),
|
|
||||||
date: fDate.value,
|
|
||||||
note: String(fNote.value ?? '').trim(),
|
|
||||||
reimbursable: fType.value === 'expense' && fReimb.value ? 1 : 0,
|
|
||||||
reimbursed: 0
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
showToast('添加失败:' + (e instanceof Error ? e.message : String(e)))
|
|
||||||
submitting.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fAmount.value = ''
|
|
||||||
fNote.value = ''
|
|
||||||
fReimb.value = false
|
|
||||||
fError.value = ''
|
|
||||||
await load()
|
|
||||||
showToast('记账已添加')
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
const formDirty = computed(() => String(fAmount.value ?? '').trim() !== '' || String(fNote.value ?? '').trim() !== '')
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
fAmount.value = ''
|
|
||||||
fNote.value = ''
|
|
||||||
fReimb.value = false
|
|
||||||
fError.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markReimbursed(row: Transaction) {
|
async function markReimbursed(row: Transaction) {
|
||||||
@@ -187,7 +367,7 @@ function openEdit(row: Transaction) {
|
|||||||
dialogInitial.value = {
|
dialogInitial.value = {
|
||||||
type: row.type,
|
type: row.type,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
amountYuan: yuan(row.amount),
|
amountYuan: yuan(row.amount).replace(/,/g, ''),
|
||||||
date: row.date,
|
date: row.date,
|
||||||
note: row.note || '',
|
note: row.note || '',
|
||||||
reimbursable: row.reimbursable,
|
reimbursable: row.reimbursable,
|
||||||
@@ -221,23 +401,27 @@ async function confirmDelete() {
|
|||||||
showToast('账目已删除')
|
showToast('账目已删除')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ledger-page">
|
<div class="ledger-page">
|
||||||
<div class="ledger-toolbar">
|
<div class="ledger-toolbar">
|
||||||
<button class="icon-btn" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
|
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canPrev" title="上一周期" @click="step(-1)">
|
||||||
<span class="month-title num">{{ title }}</span>
|
<Icon name="chevron-left" :size="16" />
|
||||||
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
|
</button>
|
||||||
<button class="btn btn-sm" @click="goThisMonth">回到本月</button>
|
<button class="period-title" title="选择统计周期" @click="openPeriodDialog">
|
||||||
|
<span class="num">{{ title }}</span>
|
||||||
|
<Icon name="chevron-down" :size="14" />
|
||||||
|
</button>
|
||||||
|
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canNext" title="下一周期" @click="step(1)">
|
||||||
|
<Icon name="chevron-right" :size="16" />
|
||||||
|
</button>
|
||||||
|
<button v-if="offCurrent" class="btn btn-sm" @click="goCurrent">{{ mode === 'cycle' ? '回到本期' : '回到本月' }}</button>
|
||||||
<span class="flex1"></span>
|
<span class="flex1"></span>
|
||||||
<select class="select w-filter" v-model="typeFilter">
|
<select class="select w-filter" v-model="typeFilter">
|
||||||
<option value="all">全部类型</option>
|
<option value="all">全部类型</option>
|
||||||
<option value="expense">仅支出</option>
|
<option value="expense">仅支出</option>
|
||||||
<option value="income">仅收入</option>
|
<option value="income">仅收入</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="select w-filter" v-model="catFilter">
|
|
||||||
<option value="all">全部分类</option>
|
|
||||||
<option v-for="c in usedCategories" :key="c" :value="c">{{ c }}</option>
|
|
||||||
</select>
|
|
||||||
<select class="select w-filter" v-model="reimbFilter">
|
<select class="select w-filter" v-model="reimbFilter">
|
||||||
<option value="all">全部报销</option>
|
<option value="all">全部报销</option>
|
||||||
<option value="pending">待报销</option>
|
<option value="pending">待报销</option>
|
||||||
@@ -247,18 +431,63 @@ async function confirmDelete() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="card stat-card stat-income"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
|
<div class="card stat-card stat-income"><div class="muted">本期收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
|
||||||
<div class="card stat-card stat-expense"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
|
<div class="card stat-card stat-expense"><div class="muted">本期支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
|
||||||
<div class="card stat-card stat-balance"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
|
<div class="card stat-card stat-balance"><div class="muted">本期结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
|
||||||
<div class="card stat-card stat-pending"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
|
<div class="card stat-card stat-pending"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card sec" v-if="categoryStats.length">
|
<div class="card sec chart-sec">
|
||||||
<div class="sec-title">本月支出分类</div>
|
<div class="sec-head">
|
||||||
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
|
<span class="sec-title">每日收支</span>
|
||||||
|
<span class="legend muted">
|
||||||
|
<i class="lg" style="background: var(--ok)"></i>收入
|
||||||
|
<i class="lg" style="background: var(--text-3)"></i>支出
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="bucketHint" class="chart-hint muted">{{ bucketHint }}</div>
|
||||||
|
<div v-if="buckets.length" class="chart-wrap">
|
||||||
|
<div class="chart">
|
||||||
|
<div v-for="(b, i) in buckets" :key="i" class="chart-col" :title="b.tip">
|
||||||
|
<div class="chart-bars">
|
||||||
|
<div class="cbar cbar-income" :style="{ height: Math.round(b.income / bucketMax * 100) + '%' }"></div>
|
||||||
|
<div class="cbar cbar-expense" :style="{ height: Math.round(b.expense / bucketMax * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-axis">
|
||||||
|
<span v-for="(b, i) in buckets" :key="i" class="chart-label num">{{ b.showLabel ? b.label : '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty muted">本周期暂无账目</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card sec" v-if="activeCats.length">
|
||||||
|
<div class="sec-head">
|
||||||
|
<span class="sec-title">分类统计</span>
|
||||||
|
<div class="seg">
|
||||||
|
<button class="seg-btn" :class="{ active: chartType === 'expense' }" @click="chartType = 'expense'">支出</button>
|
||||||
|
<button class="seg-btn" :class="{ active: chartType === 'income' }" @click="chartType = 'income'">收入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row">
|
||||||
|
<button class="chip" :class="{ active: !selectedCats.length }" @click="selectedCats = []">全部</button>
|
||||||
|
<button
|
||||||
|
v-for="c in activeCats" :key="c.name"
|
||||||
|
class="chip" :class="{ active: selectedCats.includes(c.name) }"
|
||||||
|
@click="toggleCat(c.name)"
|
||||||
|
>{{ c.name }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="selSummary" class="sel-strip">
|
||||||
|
<span>已选 {{ selSummary.cats }} 类 · {{ selSummary.count }} 笔</span>
|
||||||
|
<span class="num">{{ yuan(selSummary.amount) }} 元 · 占本期{{ chartType === 'expense' ? '支出' : '收入' }} {{ selSummary.percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="c in catRows" :key="c.name" class="bar-row" :class="{ dim: c.dim }" :title="c.count + ' 笔'">
|
||||||
|
<i class="cat-dot" :style="{ background: c.color }"></i>
|
||||||
<span class="bar-label">{{ c.name }}</span>
|
<span class="bar-label">{{ c.name }}</span>
|
||||||
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
|
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%', background: c.color }"></span></span>
|
||||||
<span class="muted num bar-num">{{ yuan(c.amount) }} 元 · {{ c.percent }}%</span>
|
<span class="bar-amt num">{{ yuan(c.amount) }}</span>
|
||||||
|
<span class="bar-pct num muted">{{ c.percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,13 +526,13 @@ async function confirmDelete() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filtered.length && !loading">
|
<tr v-if="!filtered.length && !loading">
|
||||||
<td colspan="6" class="empty muted">本月暂无账目</td>
|
<td colspan="6" class="empty muted">该周期暂无账目</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
<div class="muted table-note num">本期 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
:open="dialogOpen"
|
:open="dialogOpen"
|
||||||
@@ -313,6 +542,22 @@ async function confirmDelete() {
|
|||||||
@save="onDialogSave"
|
@save="onDialogSave"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PeriodDialog
|
||||||
|
:open="periodOpen"
|
||||||
|
:mode="mode"
|
||||||
|
:anchor="anchor"
|
||||||
|
:month="monthRef"
|
||||||
|
:cycle-sel="cycleSel"
|
||||||
|
:custom-start="customStart"
|
||||||
|
:custom-end="customEnd"
|
||||||
|
:cycle-count="currentCycle"
|
||||||
|
:has-before="hasBefore"
|
||||||
|
:months="txMonths"
|
||||||
|
@close="periodOpen = false"
|
||||||
|
@apply="onApply"
|
||||||
|
@save-anchor="onSaveAnchor"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="!!deleteTarget"
|
:open="!!deleteTarget"
|
||||||
title="删除确认"
|
title="删除确认"
|
||||||
@@ -326,8 +571,16 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ledger-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
.ledger-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
.month-title { font-size: 14px; font-weight: 600; min-width: 88px; text-align: center; }
|
.period-title {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
||||||
|
border: none; background: transparent; cursor: pointer;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-1);
|
||||||
|
padding: 5px 10px; border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.period-title:hover { background: var(--bg-hover); }
|
||||||
.w-filter { width: 110px; flex: none; }
|
.w-filter { width: 110px; flex: none; }
|
||||||
|
.icon-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||||
.stat-card { border-left: 3px solid var(--border); }
|
.stat-card { border-left: 3px solid var(--border); }
|
||||||
@@ -337,8 +590,50 @@ async function confirmDelete() {
|
|||||||
.stat-pending { border-left-color: var(--warn); }
|
.stat-pending { border-left-color: var(--warn); }
|
||||||
|
|
||||||
.card.sec, .card.section { margin-top: 12px; }
|
.card.sec, .card.section { margin-top: 12px; }
|
||||||
.bar-label { width: 56px; }
|
.sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||||
.bar-num { width: 118px; }
|
.sec-head .sec-title { margin-bottom: 0; }
|
||||||
|
.legend { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
|
||||||
|
.legend .lg { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-left: 8px; }
|
||||||
|
.legend .lg:first-child { margin-left: 0; }
|
||||||
|
|
||||||
|
/* 每日收支柱状图 */
|
||||||
|
.chart-wrap { min-width: 0; }
|
||||||
|
.chart { display: flex; align-items: stretch; gap: 3px; height: 110px; }
|
||||||
|
.chart-col { flex: 1; min-width: 0; display: flex; }
|
||||||
|
.chart-bars { flex: 1; display: flex; align-items: flex-end; justify-content: center; gap: 2px; }
|
||||||
|
.cbar { width: 6px; max-width: 12px; border-radius: 2px 2px 0 0; min-height: 0; }
|
||||||
|
.cbar-income { background: var(--ok); opacity: 0.85; }
|
||||||
|
.cbar-expense { background: var(--text-3); opacity: 0.8; }
|
||||||
|
.chart-axis { display: flex; gap: 3px; border-top: 1px solid var(--border); margin-top: 0; }
|
||||||
|
.chart-label { flex: 1; min-width: 0; text-align: center; font-size: 10px; color: var(--text-3); line-height: 18px; height: 18px; overflow: hidden; white-space: nowrap; }
|
||||||
|
.chart-hint { font-size: 12px; margin-bottom: 6px; }
|
||||||
|
.chart-col:hover .cbar-income { opacity: 1; }
|
||||||
|
.chart-col:hover .cbar-expense { opacity: 1; }
|
||||||
|
|
||||||
|
/* 分类统计 */
|
||||||
|
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.chip {
|
||||||
|
height: 24px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
|
background: var(--bg-page); color: var(--text-2);
|
||||||
|
font-size: 12px; cursor: pointer; white-space: nowrap;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.chip:hover { background: var(--bg-hover); }
|
||||||
|
.chip.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); font-weight: 500; }
|
||||||
|
.sel-strip {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;
|
||||||
|
background: var(--accent-bg); border-radius: var(--radius-sm);
|
||||||
|
padding: 7px 10px; font-size: 12px; color: var(--accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.bar-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; transition: opacity 0.12s; }
|
||||||
|
.bar-row.dim { opacity: 0.4; }
|
||||||
|
.cat-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }
|
||||||
|
.bar-label { flex: none; width: 72px; color: var(--text-2); font-size: 12px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
|
||||||
|
.bar-amt { flex: none; width: 88px; text-align: right; font-size: 12px; }
|
||||||
|
.bar-pct { flex: none; width: 38px; text-align: right; font-size: 12px; }
|
||||||
|
|
||||||
.td-note { color: var(--text-2); }
|
.td-note { color: var(--text-2); }
|
||||||
.tx-pending { color: var(--warn); font-size: 12px; }
|
.tx-pending { color: var(--warn); font-size: 12px; }
|
||||||
@@ -351,8 +646,13 @@ async function confirmDelete() {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.ledger-toolbar { gap: 8px; }
|
.ledger-toolbar { gap: 8px; }
|
||||||
.ledger-toolbar .flex1 { display: none; }
|
.ledger-toolbar .flex1 { display: none; }
|
||||||
.month-title { min-width: 0; flex: 1; text-align: left; }
|
.period-title { flex: 1; justify-content: flex-start; padding-left: 0; }
|
||||||
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
|
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
|
||||||
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
|
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
|
||||||
|
.chart { overflow-x: auto; }
|
||||||
|
.chart-col { min-width: 11px; }
|
||||||
|
.cbar { width: 4px; }
|
||||||
|
.bar-label { width: 60px; }
|
||||||
|
.bar-amt { width: 78px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user