5 Commits
30 changed files with 852 additions and 174 deletions
+27
View File
@@ -0,0 +1,27 @@
# v0.6.7 发布说明(2026-10-26)
## 全组件 UI 审核打磨(19 个界面/组件逐一过审)
**交互反馈**
- 按钮增加按下态(轻微下沉 + 主按钮阴影收起),主按钮带品牌色微阴影
- 全局细滚动条(8px 圆角,滚动区域观感统一)
- 修复侧栏 tooltip:仅折叠态(平板)显示,宽侧栏不再多余弹泡
**语义色与信息层级**
- 记账统计卡增加语义色条:收入绿 / 支出灰 / 结余蓝 / 待报销橙
- 长期计划进度条 100% 完成时变绿
- 危险确认弹窗标题红色警示
- 详情页出勤时间数字加大加粗;工作记录内容字重提升;总结页摘要数字加粗
- 密码库密码文本改等宽字体
- 记账表格金额列右对齐
**体验升级**
- 记录弹窗「耗时」新增快捷选择 chips(15/30/60/90/120 分)
- 登录页背景渐变质感、输入框加高
- 日历:今日格子淡蓝高亮、非本月格灰底、周标题字重
- 移动端「更多」弹层加拖拽指示条
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.7 安装包
+13
View File
@@ -0,0 +1,13 @@
# v0.6.8 发布说明(2026-10-26)
## 确认弹窗重做
- 修复内容贴边(上一轮组件基座统一时确认框未获得内边距,标题/正文/按钮挤作一团)
- 重做为标准企业级危险确认:红色警告徽标 + 标题/正文分区 + 22px 内边距 + 右对齐操作区
- 危险操作:红色三角警告徽标 + 红色标题 + 红色实心确认按钮;普通确认保持蓝白风格
- 移动端按钮等宽 40px;Esc/点击遮罩关闭行为不变(全部 10+ 处调用点零改动)
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.8 安装包
+13
View File
@@ -0,0 +1,13 @@
# v0.6.9 发布说明(2026-10-26)
## 分类管理去原生弹窗,全部自绘
- **添加 / 修改分类**:不再使用浏览器原生 prompt,改为自绘「输入弹窗」——自动聚焦全选、回车提交、**内联校验**(空值 / 重名直接在弹窗内红字提示,不再 toast 打断)
- **删除分类**:不再使用原生 confirm,改为统一的红色警告确认框(含保留提示:历史记录不受影响)
- 新增通用 InputDialog 组件(输入类弹窗基座),后续项目改名等场景可复用
- 全应用已无任何浏览器原生弹窗
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.9 安装包
+12
View File
@@ -0,0 +1,12 @@
# v0.7.0 发布说明(2026-10-26)
## 记账:分类筛选
- 工具栏新增「分类」下拉:选项为本月**实际出现的分类**(自动去重排序),支持「全部分类」
- 与类型筛选(支出/收入)、报销筛选可叠加使用;底部「当前筛选 N 条」实时联动
- 切换月份自动重置分类筛选,避免跨月无效选项
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.7.0 安装包
+19
View File
@@ -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 变更、无服务端改动;部署后刷新网页即生效。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-B4MkFHMk.js","./index-DP7vL7H5.js","./index-BuZbSc5_.css"])))=>i.map(i=>d[i]); const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-9h8A4HQx.js","./index-pe6VFzmc.js","./index-BiI44Vix.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DP7vL7H5.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-B4MkFHMk.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-pe6VFzmc.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-9h8A4HQx.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DP7vL7H5.js"></script> <script type="module" crossorigin src="./assets/index-pe6VFzmc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BuZbSc5_.css"> <link rel="stylesheet" crossorigin href="./assets/index-BiI44Vix.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "intern-log", "name": "intern-log",
"private": true, "private": true,
"version": "0.6.6", "version": "0.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+12 -1
View File
@@ -333,6 +333,7 @@ onMounted(async () => {
<!-- 手机端「更多」底部弹层 --> <!-- 手机端「更多」底部弹层 -->
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false"> <div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
<div class="more-sheet" @click.stop> <div class="more-sheet" @click.stop>
<div class="sheet-handle"></div>
<div class="sheet-user num">{{ session?.username }}</div> <div class="sheet-user num">{{ session?.username }}</div>
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)"> <button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
<Icon :name="v.icon" :size="18" /> <Icon :name="v.icon" :size="18" />
@@ -452,6 +453,7 @@ onMounted(async () => {
background: var(--accent); background: var(--accent);
} }
.nav-label { white-space: nowrap; } .nav-label { white-space: nowrap; }
/* tooltip 仅在折叠态(图标-only)显示;宽侧栏文字可见,不需要 */
.nav-btn::after { .nav-btn::after {
content: attr(data-tip); content: attr(data-tip);
position: absolute; position: absolute;
@@ -647,10 +649,18 @@ onMounted(async () => {
bottom: 0; bottom: 0;
background: var(--bg-page); background: var(--bg-page);
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
padding: 10px 14px calc(14px + env(safe-area-inset-bottom)); padding: 6px 14px calc(14px + env(safe-area-inset-bottom));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border-strong);
margin: 4px auto 8px;
flex: none;
}
.sheet-user { .sheet-user {
font-size: 12px; font-size: 12px;
color: var(--text-3); color: var(--text-3);
@@ -721,6 +731,7 @@ onMounted(async () => {
.brand-name, .nav-label, .nav-group { display: none; } .brand-name, .nav-label, .nav-group { display: none; }
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; } .nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
.nav-btn.active::before { display: none; } .nav-btn.active::before { display: none; }
.nav-btn:hover::after { opacity: 1; }
.side-foot { flex-direction: column; gap: 6px; } .side-foot { flex-direction: column; gap: 6px; }
.foot-name { display: none; } .foot-name { display: none; }
} }
+44 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted, watch } from 'vue' import { onUnmounted, watch } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
open: boolean open: boolean
@@ -27,12 +28,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<teleport to="body"> <teleport to="body">
<transition name="modal"> <transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')"> <div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title"> <div class="dialog confirm-dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div> <div class="confirm-body">
<div class="dialog-msg">{{ message }}</div> <div v-if="danger" class="confirm-badge">
<div class="dialog-btns"> <Icon name="warning" :size="22" />
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button> </div>
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button> <div class="confirm-text">
<div class="confirm-title" :class="{ 'is-danger': danger }">{{ title }}</div>
<div class="confirm-msg">{{ message }}</div>
</div>
</div>
<div class="confirm-btns">
<button class="btn" @click="emit('cancel')">{{ cancelText }}</button>
<button class="btn" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +49,34 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
</template> </template>
<style scoped> <style scoped>
.dialog { width: 380px; } .confirm-dialog {
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; } width: 400px;
.dialog-btns { margin-top: 20px; } padding: 22px 22px 18px;
}
.confirm-body { display: flex; gap: 14px; align-items: flex-start; }
.confirm-badge {
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: #FDEEEE;
color: var(--danger);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-text { flex: 1; min-width: 0; }
.confirm-title { font-size: 15px; font-weight: 600; color: var(--text-1); line-height: 1.4; }
.confirm-title.is-danger { color: var(--danger); }
.confirm-msg { margin-top: 6px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.confirm-btns { margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
@media (max-width: 720px) {
.confirm-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
.confirm-btns .btn { flex: 1; height: 40px; }
.confirm-btns { margin-top: 16px; }
}
</style> </style>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(defineProps<{
open: boolean
title: string
label?: string
placeholder?: string
initial?: string
confirmText?: string
validate?: (v: string) => string | null
}>(), { label: '', placeholder: '', initial: '', confirmText: '确定' })
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'confirm', value: string): void }>()
const value = ref('')
const err = ref('')
const input = ref<HTMLInputElement | null>(null)
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
value.value = props.initial
err.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
input.value?.focus()
input.value?.select()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
function confirm() {
const v = value.value.trim()
if (!v) { err.value = '请输入内容'; return }
const e = props.validate ? props.validate(v) : null
if (e) { err.value = e; return }
emit('confirm', v)
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog input-dialog" role="dialog" :aria-label="title">
<div class="dialog-head">
<span class="dialog-title">{{ title }}</span>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="field">
<label v-if="label">{{ label }}</label>
<input
ref="input" class="input" :class="{ invalid: err }"
v-model="value" :placeholder="placeholder" maxlength="20"
@keydown.enter="confirm"
/>
<div v-if="err" class="form-error">{{ err }}</div>
</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.input-dialog { width: 400px; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
@media (max-width: 720px) {
.input-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
.dialog-btns .btn { flex: 1; height: 40px; }
}
</style>
+207
View File
@@ -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>
+1
View File
@@ -25,5 +25,6 @@ export const ICONS: Record<string, string> = {
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'), wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'), more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'), shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
warning: svg('<path d="M12 4.2L21 19.2H3L12 4.2z"/><path d="M12 10.2v3.6M12 16.4h.01"/>'),
plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>') plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>')
} }
+2 -1
View File
@@ -420,6 +420,7 @@ async function confirmDelete() {
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; } .att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.att-cell label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; } .att-cell label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; } .att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
.att-val > .num { font-size: 14px; font-weight: 600; letter-spacing: 0.2px; }
.val-empty { color: var(--text-3); } .val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; } .time-input { width: 104px; }
@@ -444,7 +445,7 @@ async function confirmDelete() {
} }
.item:hover { border-color: var(--border-strong); background: var(--bg-page); } .item:hover { border-color: var(--border-strong); background: var(--bg-page); }
.item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; } .item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; }
.item-content { cursor: text; word-break: break-all; } .item-content { cursor: text; word-break: break-all; font-weight: 500; }
.item-meta { font-size: 12px; } .item-meta { font-size: 12px; }
.item-sub { .item-sub {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
@@ -146,6 +146,13 @@ function save() {
<div class="field"> <div class="field">
<label>耗时(分钟)</label> <label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" /> <input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
<div class="min-chips">
<button
v-for="m in [15, 30, 60, 90, 120]" :key="m" type="button"
class="min-chip" :class="{ on: formMinutes === m }"
@click="formMinutes = formMinutes === m ? null : m"
>{{ m }}分</button>
</div>
</div> </div>
<div class="field"> <div class="field">
<label>主要收获</label> <label>主要收获</label>
@@ -183,4 +190,18 @@ function save() {
<style scoped> <style scoped>
.dialog { width: 560px; } .dialog { width: 560px; }
.min-chips { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.min-chip {
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: 999px;
height: 24px;
padding: 0 10px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
transition: all 0.12s;
}
.min-chip:hover { background: var(--bg-hover); }
.min-chip.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
</style> </style>
+13
View File
@@ -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> {
+41
View File
@@ -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)
}
+13 -1
View File
@@ -78,9 +78,11 @@ button, input, select, textarea { font: inherit; color: inherit; }
transition: background 0.12s, border-color 0.12s, color 0.12s; transition: background 0.12s, border-color 0.12s, color 0.12s;
} }
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); } .btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; } .btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; box-shadow: 0 1px 2px rgba(51, 112, 255, 0.28); }
.btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); } .btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
.btn-primary:active { box-shadow: none; }
.btn-danger { color: var(--danger); border-color: var(--danger); background: var(--bg-page); } .btn-danger { color: var(--danger); border-color: var(--danger); background: var(--bg-page); }
.btn-danger:hover { background: #FDF0F0; } .btn-danger:hover { background: #FDF0F0; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; } .btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
@@ -279,6 +281,16 @@ button, input, select, textarea { font: inherit; color: inherit; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; } .bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; flex: none; color: var(--text-3); } .bar-num { font-size: 12px; flex: none; color: var(--text-3); }
/* ---------- 滚动条(桌面细滚动条,移动端系统默认) ---------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #D3D7DE; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #B9BEC7; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-corner { background: transparent; }
/* 记账表格:金额列右对齐(数字位对齐更利扫读) */
.tx-table th:nth-child(3), .tx-table td:nth-child(3) { text-align: right; }
/* ---------- 图标 ---------- */ /* ---------- 图标 ---------- */
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; } .icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
.icon svg { width: 100%; height: 100%; display: block; } .icon svg { width: 100%; height: 100%; display: block; }
+4 -2
View File
@@ -104,7 +104,7 @@ watch(focusDate, d => {
<button <button
v-for="(c, idx) in cells" :key="idx" v-for="(c, idx) in cells" :key="idx"
class="cal-cell" class="cal-cell"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date }" :class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date, 'is-today': c.date === t }"
:title="c.inMonth ? cellTitle(c.date) : undefined" :title="c.inMonth ? cellTitle(c.date) : undefined"
@click="selectDay(c.date, c.inMonth)" @click="selectDay(c.date, c.inMonth)"
> >
@@ -149,6 +149,7 @@ watch(focusDate, d => {
text-align: center; text-align: center;
padding: 7px 0; padding: 7px 0;
font-size: 12px; font-size: 12px;
font-weight: 500;
color: var(--text-3); color: var(--text-3);
} }
.cal-cell { .cal-cell {
@@ -163,7 +164,8 @@ watch(focusDate, d => {
transition: background 0.12s; transition: background 0.12s;
} }
.cal-cell:hover { background: var(--bg-subtle); } .cal-cell:hover { background: var(--bg-subtle); }
.cal-cell.blank { cursor: default; } .cal-cell.blank { cursor: default; background: var(--bg-subtle); }
.cal-cell.is-today { background: #F5F9FF; }
.cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); } .cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
.daynum.today { color: var(--accent); font-weight: 600; } .daynum.today { color: var(--accent); font-weight: 600; }
.cal-cell.weekend .daynum { color: var(--text-3); } .cal-cell.weekend .daynum { color: var(--text-3); }
+208 -90
View File
@@ -3,49 +3,130 @@ 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, 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 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))
}
// 今天所在的期数(未设起点时按 1 处理)
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 +134,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], () => {
catFilter.value = 'all' // 切换周期时重置分类筛选(分类集合可能不同)
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)))
) )
@@ -86,9 +211,14 @@ const summary = computed(() => {
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')
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 (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
@@ -116,59 +246,6 @@ function amountText(r: Transaction): string {
return (r.type === 'income' ? '+' : '-') + yuan(r.amount) return (r.type === 'income' ? '+' : '-') + yuan(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) {
await setTransactionReimbursed(row.id as number, true) await setTransactionReimbursed(row.id as number, true)
await load() await load()
@@ -214,19 +291,31 @@ 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>
@@ -236,14 +325,14 @@ async function confirmDelete() {
</div> </div>
<div class="cards"> <div class="cards">
<div class="card stat-card"><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"><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"><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"><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" v-if="categoryStats.length">
<div class="sec-title">本月支出分类</div> <div class="sec-title">本期支出分类</div>
<div v-for="c in categoryStats" :key="c.name" class="bar-row"> <div v-for="c in categoryStats" :key="c.name" class="bar-row">
<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 + '%' }"></span></span>
@@ -286,13 +375,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"
@@ -302,6 +391,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="删除确认"
@@ -315,10 +420,23 @@ 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-income { border-left-color: var(--ok); }
.stat-expense { border-left-color: var(--text-3); }
.stat-balance { border-left-color: var(--accent); }
.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; } .bar-label { width: 56px; }
@@ -335,7 +453,7 @@ 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; }
} }
+2 -1
View File
@@ -78,12 +78,13 @@ async function submit() {
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
background: var(--bg-app); background: linear-gradient(180deg, #EBF0F8 0%, var(--bg-app) 46%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20px; padding: 20px;
} }
.login-card .input { height: 38px; }
.login-card { .login-card {
width: 400px; width: 400px;
max-width: 100%; max-width: 100%;
+4 -1
View File
@@ -236,7 +236,10 @@ function weekday(s: string): string {
<div class="goal-bar"> <div class="goal-bar">
<span <span
class="goal-bar-fill" class="goal-bar-fill"
:style="{ width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%' }" :style="{
width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%',
background: goalProg[String(g.id)] && goalProg[String(g.id)].done >= goalProg[String(g.id)].total && goalProg[String(g.id)].total > 0 ? 'var(--ok)' : 'var(--accent)'
}"
></span> ></span>
</div> </div>
<div class="muted num goal-meta"> <div class="muted num goal-meta">
+1
View File
@@ -221,6 +221,7 @@ function undoPolish() {
.md-editor { min-height: 420px; line-height: 1.7; } .md-editor { min-height: 420px; line-height: 1.7; }
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; } .sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
.sum-row span:last-child { font-weight: 500; }
.section-sub { font-weight: 600; margin: 12px 0 4px; font-size: 12px; color: var(--text-2); } .section-sub { font-weight: 600; margin: 12px 0 4px; font-size: 12px; color: var(--text-2); }
.sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; } .sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
.sum-empty { font-size: 12px; } .sum-empty { font-size: 12px; }
+70 -32
View File
@@ -2,6 +2,7 @@
import { onMounted, ref, watch } from 'vue' import { 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 InputDialog from '../components/InputDialog.vue'
import { import {
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll, addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
listAttendanceBetween, listLogItemsBetween, listProjects, listAttendanceBetween, listLogItemsBetween, listProjects,
@@ -39,37 +40,53 @@ async function loadCategories() {
expenseCats.value = await getLedgerCategories('expense') expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income') incomeCats.value = await getLedgerCategories('income')
} }
async function addCat(kind: 'log' | 'expense' | 'income') { // ---- 分类编辑:自绘输入弹窗 + 危险确认框(替代浏览器原生 prompt/confirm)----
const name = window.prompt('新分类名称') type CatKind = 'log' | 'expense' | 'income'
if (!name || !name.trim()) return const catDialog = ref<{ open: boolean; kind: CatKind; index: number; initial: string }>({ open: false, kind: 'log', index: -1, initial: '' })
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value const delCat = ref<{ kind: CatKind; index: number; name: string } | null>(null)
const clean = name.trim()
if (list.includes(clean)) { showToast('分类已存在'); return } function catListOf(kind: CatKind): string[] {
const next = [...list, clean] return kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已添加')
} }
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) { async function writeCats(kind: CatKind, next: string[]) {
const name = window.prompt('修改分类名称', oldName)
if (!name || !name.trim() || name.trim() === oldName) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const next = list.map((x, i) => (i === index ? name.trim() : x))
if (kind === 'log') { logCats.value = next; await setLogCategories(next) } if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) } else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) } else { incomeCats.value = next; await setLedgerCategories('income', next) }
}
function openCatAdd(kind: CatKind) {
catDialog.value = { open: true, kind, index: -1, initial: '' }
}
function openCatRename(kind: CatKind, index: number, oldName: string) {
catDialog.value = { open: true, kind, index, initial: oldName }
}
function validateCat(v: string): string | null {
const { kind, index } = catDialog.value
const list = catListOf(kind)
if (list.includes(v) && !(index >= 0 && list[index] === v)) return '该分类已存在'
return null
}
async function onCatDialogConfirm(v: string) {
const { kind, index } = catDialog.value
if (index >= 0) {
await writeCats(kind, catListOf(kind).map((x, i) => (i === index ? v : x)))
showToast('分类已更新(历史记录自动跟随显示)') showToast('分类已更新(历史记录自动跟随显示)')
} else {
await writeCats(kind, [...catListOf(kind), v])
showToast('分类已添加')
}
catDialog.value.open = false
} }
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) { function askRemoveCat(kind: CatKind, index: number) {
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value const list = catListOf(kind)
if (list.length <= 1) { showToast('至少保留一个分类'); return } if (list.length <= 1) { showToast('至少保留一个分类'); return }
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return delCat.value = { kind, index, name: list[index] }
const next = list.filter((_, i) => i !== index) }
if (kind === 'log') { logCats.value = next; await setLogCategories(next) } async function confirmRemoveCat() {
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) } if (!delCat.value) return
else { incomeCats.value = next; await setLedgerCategories('income', next) } const { kind, index } = delCat.value
await writeCats(kind, catListOf(kind).filter((_, i) => i !== index))
showToast('分类已删除') showToast('分类已删除')
delCat.value = null
} }
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
@@ -424,32 +441,32 @@ async function doImport() {
<div class="card section"> <div class="card section">
<div class="section-title">工作分类</div> <div class="section-title">工作分类</div>
<div class="cat-block"> <div class="cat-block">
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="addCat('log')">添加</button></div> <div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="openCatAdd('log')">添加</button></div>
<div class="cat-chips"> <div class="cat-chips">
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip"> <span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
{{ c }} {{ c }}
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button> <button class="chip-btn" title="修改" @click="openCatRename('log', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button> <button class="chip-btn" title="删除" @click="askRemoveCat('log', i)">删</button>
</span> </span>
</div> </div>
</div> </div>
<div class="cat-block"> <div class="cat-block">
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="addCat('expense')">添加</button></div> <div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="openCatAdd('expense')">添加</button></div>
<div class="cat-chips"> <div class="cat-chips">
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip"> <span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
{{ c }} {{ c }}
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button> <button class="chip-btn" title="修改" @click="openCatRename('expense', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button> <button class="chip-btn" title="删除" @click="askRemoveCat('expense', i)">删</button>
</span> </span>
</div> </div>
</div> </div>
<div class="cat-block"> <div class="cat-block">
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="addCat('income')">添加</button></div> <div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="openCatAdd('income')">添加</button></div>
<div class="cat-chips"> <div class="cat-chips">
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip"> <span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
{{ c }} {{ c }}
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button> <button class="chip-btn" title="修改" @click="openCatRename('income', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button> <button class="chip-btn" title="删除" @click="askRemoveCat('income', i)">删</button>
</span> </span>
</div> </div>
</div> </div>
@@ -471,6 +488,27 @@ async function doImport() {
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div> <div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
</div> </div>
<InputDialog
:open="catDialog.open"
:title="catDialog.index >= 0 ? '修改分类名称' : '添加分类'"
label="分类名称"
placeholder="如:制度学习 / 差旅交通"
:initial="catDialog.initial"
:validate="validateCat"
confirm-text="保存"
@cancel="catDialog.open = false"
@confirm="onCatDialogConfirm"
/>
<ConfirmDialog
:open="!!delCat"
title="删除分类"
:message="delCat ? '将删除分类「' + delCat.name + '」。历史记录不受影响,仍按原分类展示。' : ''"
confirm-text="删除" danger
@cancel="delCat = null"
@confirm="confirmRemoveCat"
/>
<ConfirmDialog <ConfirmDialog
:open="!!pendingImport" :open="!!pendingImport"
title="导入确认" title="导入确认"
+1 -1
View File
@@ -375,7 +375,7 @@ async function doChangePwd() {
.td-system { font-weight: 500; } .td-system { font-weight: 500; }
.th-ops { width: 150px; } .th-ops { width: 150px; }
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } .pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.pwd-text { letter-spacing: 1px; } .pwd-text { letter-spacing: 1px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.copyable { cursor: pointer; } .copyable { cursor: pointer; }
.copyable:hover { color: var(--accent); } .copyable:hover { color: var(--accent); }
.ops { display: flex; gap: 4px; flex-wrap: wrap; } .ops { display: flex; gap: 4px; flex-wrap: wrap; }