feat: 长期计划(目标→每日任务物化+进度条) / 详情页Markdown范围导出 / 记账与详情页改内容优先弹窗新增

This commit is contained in:
root
2026-10-26 12:16:14 +08:00
parent 284f8ebd46
commit 0f268a495e
20 changed files with 535 additions and 268 deletions
+18 -54
View File
@@ -61,13 +61,9 @@ const incomeCats = ref<string[]>([])
async function loadCats() {
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
if (fType.value === 'expense' && !expenseCats.value.includes(fCategory.value)) fCategory.value = expenseCats.value[0] || ''
if (fType.value === 'income' && !incomeCats.value.includes(fCategory.value)) fCategory.value = incomeCats.value[0] || ''
}
loadCats()
watch(fType, t => {
fCategory.value = t === 'expense' ? (expenseCats.value[0] || '') : (incomeCats.value[0] || '')
})
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)))
@@ -192,12 +188,24 @@ function openEdit(row: Transaction) {
}
dialogOpen.value = true
}
function openCreate() {
editingId.value = null
dialogInitial.value = { type: 'expense', category: (expenseCats.value[0] || ''), amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 }
dialogOpen.value = true
}
async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
const id = editingId.value as number
await updateTransaction(id, payload)
if (editingId.value) {
await updateTransaction(editingId.value, payload)
dialogOpen.value = false
await load()
showToast('账目已更新')
return
}
await addTransaction({ type: payload.type, category: payload.category, amount: payload.amount, date: payload.date, note: payload.note, reimbursable: payload.reimbursable, reimbursed: payload.reimbursed })
dialogOpen.value = false
await load()
showToast('账目已更新')
showToast('已记一笔')
}
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id)
@@ -225,6 +233,7 @@ async function confirmDelete() {
<option value="pending">待报销</option>
<option value="done">已报销</option>
</select>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 记一笔</button>
</div>
<div class="cards">
@@ -234,51 +243,6 @@ async function confirmDelete() {
<div class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增一笔</span>
<span class="muted form-hint">金额必填,回车快速添加</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>类型</label>
<div class="seg">
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
</div>
</div>
<div class="field">
<label>金额(元,必填)</label>
<input class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" @keydown.enter="submit" />
</div>
<div class="field">
<label>日期</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in (fType === 'expense' ? expenseCats : incomeCats)" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销</label>
<button class="toggle-btn" :class="{ on: fReimb }" @click="fReimb = !fReimb">
{{ fReimb ? '需要报销' : '无需报销' }}
</button>
</div>
<div class="field span2">
<label>备注</label>
<input class="input" v-model="fNote" placeholder="选填,如:客户现场午餐" @keydown.enter="submit" />
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="card sec" v-if="categoryStats.length">
<div class="sec-title">本月支出分类</div>
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
@@ -333,7 +297,7 @@ async function confirmDelete() {
<TransactionDialog
:open="dialogOpen"
:title="editingId ? '编辑账目' : '编辑账目'"
:title="editingId ? '编辑账目' : '记一笔'"
:initial="dialogInitial"
@cancel="dialogOpen = false"
@save="onDialogSave"
+149 -2
View File
@@ -1,14 +1,72 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import {
addPlan, deletePlan, listPlansByDate, setPlanStatus,
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { showToast } from '../lib/toast'
// ---- 长期计划 ----
const goals = ref<Goal[]>([])
const goalProg = ref<Record<string, { done: number; total: number }>>({})
const goalOpen = ref(false)
const goalEditing = ref<Goal | null>(null)
const gTitle = ref('')
const gKind = ref('学习')
const gStart = ref(todayStr())
const gEnd = ref(addDays(todayStr(), 6))
const gNote = ref('')
const gError = ref('')
const delGoal = ref<Goal | null>(null)
function openGoalCreate() {
goalEditing.value = null
gTitle.value = ''
gKind.value = '学习'
gStart.value = todayStr()
gEnd.value = addDays(todayStr(), 6)
gNote.value = ''
gError.value = ''
goalOpen.value = true
}
function openGoalEdit(g: Goal) {
goalEditing.value = g
gTitle.value = g.title
gKind.value = g.kind
gStart.value = g.start_date
gEnd.value = g.end_date
gNote.value = g.note || ''
gError.value = ''
goalOpen.value = true
}
async function submitGoal() {
if (!gTitle.value.trim()) { gError.value = '请填写计划名称'; return }
if (gEnd.value < gStart.value) { gError.value = '结束日期不能早于开始日期'; return }
if (goalEditing.value) {
await updateGoal(goalEditing.value.id as number, {
title: gTitle.value.trim(), kind: gKind.value, note: gNote.value
})
showToast('长期计划已更新')
} else {
await addGoal({ title: gTitle.value, kind: gKind.value, start_date: gStart.value, end_date: gEnd.value, note: gNote.value })
showToast('长期计划已创建,已生成每日任务')
}
goalOpen.value = false
await load()
}
async function removeGoal() {
if (!delGoal.value) return
await deleteGoal(delGoal.value.id as number)
showToast('已删除长期计划及其每日任务')
delGoal.value = null
await load()
}
const date = ref(todayStr())
const items = ref<PlanItem[]>([])
const loading = ref(true)
@@ -20,7 +78,16 @@ const isToday = computed(() => date.value === todayStr())
async function load() {
loading.value = true
items.value = await listPlansByDate(date.value)
const [plans, gs, prog] = await Promise.all([
listPlansByDate(date.value),
listGoals(),
getGoalProgress()
])
items.value = plans
goals.value = gs
const map: Record<string, { done: number; total: number }> = {}
for (const p of prog) map[String(p.goalId)] = { done: p.done, total: p.total }
goalProg.value = map
loading.value = false
}
watch(date, load)
@@ -165,6 +232,34 @@ function weekday(s: string): string {
</div>
</div>
<div class="goals-sec">
<div class="goals-head">
<span class="goals-title">长期计划</span>
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
</div>
<div v-if="!goals.length" class="muted goals-empty">暂无长期计划。可以创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。</div>
<div v-for="g in goals" :key="g.id" class="card sec goal-card">
<div class="goal-main">
<div class="goal-line">
<span class="tag">{{ g.kind }}</span>
<span class="goal-name">{{ g.title }}</span>
<span class="muted num goal-range">{{ g.start_date.slice(5) }} ~ {{ g.end_date.slice(5) }}</span>
</div>
<div class="goal-bar">
<span 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) + '%' }"></span>
</div>
<div class="muted num goal-meta">
{{ goalProg[String(g.id)] ? goalProg[String(g.id)].done + '/' + goalProg[String(g.id)].total + ' 天已完成' : '0/' + (goalProg[String(g.id)] ? goalProg[String(g.id)].total : 0) + ' 天' }}
<template v-if="g.note"> · {{ g.note }}</template>
</div>
</div>
<div class="goal-ops">
<button class="icon-btn" title="编辑" @click="openGoalEdit(g)"><Icon name="edit" :size="14" /></button>
<button class="icon-btn" title="删除" @click="delGoal = g"><Icon name="close" :size="14" /></button>
</div>
</div>
</div>
<div class="plan-list">
<div v-if="!loading && !items.length" class="card sec empty muted">
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
@@ -180,6 +275,7 @@ function weekday(s: string): string {
<div class="pi-main">
<div class="pi-title">
<span class="tag">{{ p.kind }}</span>
<span v-if="p.goal_id" class="tag tag-goal">长期</span>
<span class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
<span v-if="p.status === 'done'" class="state-done">已完成</span>
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
@@ -248,6 +344,57 @@ function weekday(s: string): string {
</div>
</div>
</teleport>
<teleport to="body">
<div v-if="goalOpen" class="fixed-mask" @click.self="goalOpen = false">
<div class="dialog" role="dialog" aria-label="长期计划">
<div class="dialog-head">
<span class="dialog-title">{{ goalEditing ? '编辑长期计划' : '新建长期计划' }}</span>
<button class="icon-btn" @click="goalOpen = false"><Icon name="close" :size="16" /></button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>计划名称(落实为每天一条任务)</label>
<input class="input" v-model="gTitle" placeholder="如:产品文档学习 / 健身月计划" />
</div>
<div class="form-row">
<label>类别</label>
<select class="select" v-model="gKind">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="form-row two">
<div>
<label>开始日期</label>
<input class="input" type="date" v-model="gStart" />
</div>
<div>
<label>结束日期</label>
<input class="input" type="date" v-model="gEnd" />
</div>
</div>
<div class="form-row">
<label>备注(选填)</label>
<input class="input" v-model="gNote" />
</div>
<div v-if="gError" class="form-error">{{ gError }}</div>
<div class="muted goal-hint">创建后区间内每一天都会生成一条任务,可在每日安排中逐日打卡与计时。</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="goalOpen = false">取消</button>
<button class="btn btn-primary btn-sm" @click="submitGoal">{{ goalEditing ? '保存' : '创建' }}</button>
</div>
</div>
</div>
</teleport>
<ConfirmDialog
:open="!!delGoal"
title="删除长期计划"
:message="delGoal ? '将删除「' + delGoal.title + '」及其全部每日任务(含已完成记录),不可恢复。' : ''"
confirm-text="删除" danger
@cancel="delGoal = null"
@confirm="removeGoal"
/>
</div>
</template>
+1 -60
View File
@@ -11,7 +11,7 @@ import { type Project } from '../db/schema'
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
import { downloadText, } from '../lib/download'
import { downloadCsv } from '../lib/csv'
import { addDays, hhmm, parseDateStr, todayStr, weekStart } from '../lib/date'
import { hhmm, parseDateStr, todayStr } from '../lib/date'
import { computeDay } from '../domain/attendance'
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
@@ -72,41 +72,6 @@ async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
showToast('分类已删除')
}
// ---- 工作详情范围导出 ----
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
const expStart = ref(todayStr())
const expEnd = ref(todayStr())
function rangeStartEnd(): { start: string; end: string; label: string } {
const t = todayStr()
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
if (expRange.value === 'week') { const ws = weekStart(expStart.value); return { start: ws, end: addDays(ws, 6), label: ws + ' ~ ' + addDays(ws, 6) } }
if (expRange.value === 'month') { const y = expStart.value.slice(0, 4); const m = expStart.value.slice(5, 7); const last = new Date(Number(y), Number(m), 0).getDate(); return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m } }
return { start: expStart.value, end: expEnd.value, label: expStart.value + ' ~ ' + expEnd.value }
}
async function doExportRange() {
const { start, end, label } = rangeStartEnd()
const found = await Promise.all([listLogItemsBetween(start, end), listProjects(true)])
const items = found[0]
const projects = found[1]
const pmap = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
const rows: (string | number | null)[][] = [['日期', '分类', '项目', '内容', '耗时(分钟)', '成果', '待跟进', '收获', '备注']]
for (const it of items) {
rows.push([
it.date,
categoryLabel(it.category),
it.project_id ? (pmap.get(it.project_id) || '') : '',
it.content,
it.minutes == null ? '' : it.minutes,
it.is_milestone ? '是' : '',
it.needs_followup ? '是' : '',
it.takeaway || '',
it.remark || ''
])
}
const file = '工作详情_' + label.replace(/[~s]/g, '_') + '.csv'
const p = await downloadCsv(file, rows)
showToast(p ? '已导出:' + p : '已导出到系统下载目录(' + items.length + ' 条)')
}
const fileInput = ref<HTMLInputElement | null>(null)
const pendingImport = ref<{ name: string; text: string } | null>(null)
const importError = ref('')
@@ -490,30 +455,6 @@ async function doImport() {
</div>
</div>
<div class="card section">
<div class="section-title">工作详情导出</div>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
</div>
<div class="exp-dates">
<template v-if="expRange === 'custom'">
<input class="input" type="date" v-model="expStart" />
<span class="muted">至</span>
<input class="input" type="date" v-model="expEnd" />
</template>
<template v-else>
<input class="input" type="date" v-model="expStart" />
<span class="muted loc-note">{{ expRange === 'day' ? '导出该日' : expRange === 'week' ? '导出该日期所在一周' : '导出该日期所在月份' }}</span>
</template>
</div>
<div class="rule-save">
<button class="btn btn-primary btn-sm" @click="doExportRange">导出 CSV</button>
</div>
</div>
<div class="card section">
<div class="section-title">数据备份</div>
<div class="backup-row">
+97 -1
View File
@@ -2,7 +2,12 @@
import { computed, ref } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { addDays, formatDateCN, todayStr } from '../lib/date'
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { addDays, formatDateCN, hhmm, todayStr, weekStart } from '../lib/date'
import { downloadText } from '../lib/download'
import { showToast } from '../lib/toast'
import { categoryLabel } from '../db/schema'
// 详情视图:自由切换日期,对任意一天进行详细查看与修改
const date = ref(todayStr())
@@ -11,6 +16,73 @@ const isToday = computed(() => date.value === todayStr())
function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() }
// ---- 工作详情导出(Markdown) ----
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
const expStart = ref(todayStr())
const expEnd = ref(todayStr())
const exporting = ref(false)
function rangeStartEnd(): { start: string; end: string; label: string } {
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
if (expRange.value === 'week') {
const ws = weekStart(expStart.value)
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
}
if (expRange.value === 'month') {
const y = expStart.value.slice(0, 4)
const m = expStart.value.slice(5, 7)
const last = new Date(Number(y), Number(m), 0).getDate()
return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m }
}
return { start: expStart.value, end: expEnd.value, label: expStart.value + '_' + expEnd.value }
}
async function doExportMd() {
if (exporting.value) return
exporting.value = true
try {
const { start, end, label } = rangeStartEnd()
const [rule, atts, items, projects] = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true)
])
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
const lines: string[] = ['# 工作详情 ' + label, '']
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
let cur = start
let guard = 0
while (cur <= end && guard < 400) {
const dayItems = items.filter(i => i.date === cur)
const att = atts.find(a => a.date === cur)
if (att || dayItems.length) {
lines.push('## ' + cur + ' ' + wd[new Date(cur + 'T00:00:00').getDay()], '')
if (att && (att.clock_in_at || att.kind === 'makeup')) {
const info = computeDay(att, cur, rule, cur === todayStr())
const seg = [hhmm(att.clock_in_at), '–', hhmm(att.clock_out_at), '·', info.label]
if (att.location) seg.push('· 地点:' + att.location)
lines.push('考勤:' + seg.join(' '), '')
}
for (const it of dayItems) {
const proj = it.project_id ? (pname.get(it.project_id) || '') : ''
const head = '- 【' + categoryLabel(it.category) + '】' + it.content + (it.minutes ? '(' + it.minutes + ' 分钟)' : '') + (proj ? ' @' + proj : '')
lines.push(head)
if (it.takeaway) lines.push(' - 收获:' + it.takeaway)
if (it.needs_followup === 1 && it.remark) lines.push(' - 待跟进:' + it.remark)
else if (it.remark) lines.push(' - 备注:' + it.remark)
}
lines.push('')
}
cur = addDays(cur, 1)
guard++
}
await downloadText('工作详情_' + label + '.md', lines.join('\n'))
showToast('已导出 Markdown')
} finally {
exporting.value = false
}
}
function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (v) date.value = v
@@ -26,6 +98,18 @@ function onDateInput(e: Event) {
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
</div>
<div class="export-row">
<span class="muted exp-label">导出工作详情</span>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
</div>
<input v-if="expRange !== 'day'" class="input w-exp" type="date" v-model="expStart" />
<input v-if="expRange === 'custom'" class="input w-exp" type="date" v-model="expEnd" />
<button class="btn btn-sm" :disabled="exporting" @click="doExportMd">导出 Markdown</button>
</div>
<DayPanel :date="date" />
</div>
</template>
@@ -42,6 +126,18 @@ function onDateInput(e: Event) {
.date-title.is-today { color: var(--accent); }
.w-date { width: 150px; }
.export-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: var(--radius-md);
background: var(--bg-page);
}
.exp-label { font-size: 12px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 4px 10px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-exp { width: 140px; }
@media (max-width: 720px) {
.detail-toolbar { gap: 8px; }
.date-title { min-width: 0; flex: 1; text-align: left; }