feat: 计划模式(时间段+打卡+计时时钟) / 工作详情范围导出 / 工作与记账分类自定义(含历史迁移)

This commit is contained in:
root
2026-10-26 11:48:14 +08:00
parent a1f2a45623
commit 284f8ebd46
24 changed files with 768 additions and 81 deletions
+14 -5
View File
@@ -7,7 +7,7 @@ import {
addTransaction, deleteTransaction, listTransactionsBetween,
setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
import { getLedgerCategories } from '../db/categories'
import { addMonths, todayStr } from '../lib/date'
import { showToast } from '../lib/toast'
@@ -20,7 +20,7 @@ const loading = ref(true)
// ---- 新增表单 ----
const fType = ref<TxType>('expense')
const fAmount = ref('')
const fCategory = ref(EXPENSE_CATEGORIES[0])
const fCategory = ref('')
const fDate = ref(todayStr())
const fNote = ref('')
const fReimb = ref(false)
@@ -30,7 +30,7 @@ const submitting = ref(false)
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const dialogInitial = ref({ type: 'expense', category: EXPENSE_CATEGORIES[0], 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 monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
@@ -56,8 +56,17 @@ async function load() {
onMounted(load)
watch(monthRef, load)
const expenseCats = ref<string[]>([])
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' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
fCategory.value = t === 'expense' ? (expenseCats.value[0] || '') : (incomeCats.value[0] || '')
})
const sorted = computed(() =>
@@ -249,7 +258,7 @@ async function confirmDelete() {
<div class="field">
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in (fType === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES)" :key="c" :value="c">{{ c }}</option>
<option v-for="c in (fType === 'expense' ? expenseCats : incomeCats)" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field" v-if="fType === 'expense'">
+301
View File
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import {
addPlan, deletePlan, listPlansByDate, setPlanStatus,
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { showToast } from '../lib/toast'
const date = ref(todayStr())
const items = ref<PlanItem[]>([])
const loading = ref(true)
const now = ref(Date.now())
let tick: number | null = null
const title = computed(() => formatDateCN(date.value))
const isToday = computed(() => date.value === todayStr())
async function load() {
loading.value = true
items.value = await listPlansByDate(date.value)
loading.value = false
}
watch(date, load)
watch(attendanceChanged, load)
onMounted(() => {
load()
tick = window.setInterval(() => { now.value = Date.now() }, 30000)
})
onUnmounted(() => { if (tick) window.clearInterval(tick) })
function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() }
// ---- 汇总 ----
const summary = computed(() => {
const total = items.value.length
const done = items.value.filter(p => p.status === 'done').length
const plannedMin = items.value.reduce((acc, p) => acc + spanMinutes(p), 0)
const actualMin = items.value.reduce((acc, p) => acc + (p.actual_minutes || 0), 0)
const runningId = running.value
if (runningId) {
const rp = items.value.find(p => p.id === runningId)
if (rp) actualMin + elapsedMinutes(rp)
}
return { total, done, plannedMin, actualMin }
})
function spanMinutes(p: PlanItem): number {
const [sh, sm] = p.start_time.split(':').map(Number)
const [eh, em] = p.end_time.split(':').map(Number)
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm))
}
function fmtMin(n: number): string {
if (n < 60) return n + ' 分钟'
return Math.floor(n / 60) + ' 小时 ' + (n % 60) + ' 分'
}
// ---- 计时时钟 ----
const running = computed(() => {
const r = items.value.find(p => !!p.timer_started_at)
return r ? (r.id as number) : null
})
function liveElapsed(p: PlanItem): number {
if (!p.timer_started_at) return p.actual_minutes || 0
const extra = Math.max(0, Math.round((now.value - new Date(p.timer_started_at).getTime()) / 60000))
return (p.actual_minutes || 0) + extra
}
async function toggleTimer(p: PlanItem) {
if (p.timer_started_at) {
await stopPlanTimer(p.id as number)
showToast('已停止计时:' + p.title)
} else {
await startPlanTimer(p.id as number)
showToast('开始计时:' + p.title)
}
await load()
}
// ---- 打卡 ----
async function mark(p: PlanItem, status: 'todo' | 'done' | 'skip') {
await setPlanStatus(p.id as number, status)
await load()
}
// ---- 新增 / 编辑 ----
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const fTitle = ref('')
const fKind = ref('学习')
const fStart = ref('09:00')
const fEnd = ref('10:00')
const fNote = ref('')
const fError = ref('')
const KINDS = ['学习', '健身', '自律', '工作', '阅读', '其他']
function openCreate() {
editingId.value = null
fTitle.value = ''
fKind.value = '学习'
fStart.value = '09:00'
fEnd.value = '10:00'
fNote.value = ''
fError.value = ''
dialogOpen.value = true
}
function openEdit(p: PlanItem) {
editingId.value = p.id as number
fTitle.value = p.title
fKind.value = p.kind
fStart.value = p.start_time
fEnd.value = p.end_time
fNote.value = p.note || ''
fError.value = ''
dialogOpen.value = true
}
async function submitPlan() {
if (!fTitle.value.trim()) { fError.value = '请填写计划内容'; return }
if (fEnd.value <= fStart.value) { fError.value = '结束时间需晚于开始时间'; return }
if (editingId.value) {
await updatePlan(editingId.value, {
title: fTitle.value.trim(), kind: fKind.value, start_time: fStart.value,
end_time: fEnd.value, note: fNote.value
})
showToast('计划已更新')
} else {
await addPlan({ date: date.value, start_time: fStart.value, end_time: fEnd.value, title: fTitle.value, kind: fKind.value, note: fNote.value })
showToast('计划已添加')
}
dialogOpen.value = false
await load()
}
async function remove(p: PlanItem) {
await deletePlan(p.id as number)
showToast('已删除')
await load()
}
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function weekday(s: string): string {
return wdNames[parseDateStr(s).getDay()]
}
</script>
<template>
<div class="plan-page">
<div class="card sec plan-head">
<div class="nav-row">
<button class="icon-btn" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<div class="date-box">
<span class="date-main num">{{ date.slice(5) }} {{ weekday(date) }}</span>
<span class="muted date-sub num">{{ date }}</span>
</div>
<button class="icon-btn" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
<span class="flex1"></span>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
</div>
<div class="muted sum-line">
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
<template v-if="running"> · <span class="timer-live">计时中</span></template>
</div>
</div>
<div class="plan-list">
<div v-if="!loading && !items.length" class="card sec empty muted">
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
</div>
<div
v-for="p in items" :key="p.id"
class="card sec plan-item"
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
>
<div class="pi-time num">
{{ p.start_time }}<span class="muted"> - {{ p.end_time }}</span>
</div>
<div class="pi-main">
<div class="pi-title">
<span class="tag">{{ p.kind }}</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>
</div>
<div v-if="p.note" class="muted pi-note">{{ p.note }}</div>
<div class="muted pi-meta num">
<template v-if="p.timer_started_at">
<span class="timer-live">计时中 · 已 {{ liveElapsed(p) }} 分钟</span>
</template>
<template v-else-if="p.actual_minutes > 0">实际投入 {{ p.actual_minutes }} 分钟</template>
</div>
</div>
<div class="pi-ops">
<button
v-if="p.status !== 'done'" class="btn btn-xs"
:class="{ 'timer-on': !!p.timer_started_at }"
@click="toggleTimer(p)"
>{{ p.timer_started_at ? '停止计时' : '计时' }}</button>
<button v-if="p.status !== 'done'" class="btn btn-xs" @click="mark(p, 'done')">完成</button>
<button v-if="p.status === 'todo'" class="btn btn-xs" @click="mark(p, 'skip')">跳过</button>
<button v-if="p.status !== 'todo'" class="btn btn-xs" @click="mark(p, 'todo')">恢复</button>
<button class="icon-btn" title="编辑" @click="openEdit(p)"><Icon name="edit" :size="14" /></button>
<button class="icon-btn" title="删除" @click="remove(p)"><Icon name="close" :size="14" /></button>
</div>
</div>
</div>
<teleport to="body">
<div v-if="dialogOpen" class="fixed-mask" @click.self="dialogOpen = false">
<div class="dialog" role="dialog" aria-label="计划">
<div class="dialog-head">
<span class="dialog-title">{{ editingId ? '编辑计划' : '添加计划' }}</span>
<button class="icon-btn" @click="dialogOpen = false"><Icon name="close" :size="16" /></button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>计划内容(学什么、做什么)</label>
<input class="input" v-model="fTitle" placeholder="如:产品文档第三章 / 晨跑 5 公里" @keydown.enter="submitPlan" />
</div>
<div class="form-row">
<label>类别</label>
<select class="select" v-model="fKind">
<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="time" v-model="fStart" />
</div>
<div>
<label>结束时间</label>
<input class="input" type="time" v-model="fEnd" />
</div>
</div>
<div class="form-row">
<label>备注(选填)</label>
<input class="input" v-model="fNote" placeholder="目标、资料、提醒等" @keydown.enter="submitPlan" />
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="dialogOpen = false">取消</button>
<button class="btn btn-primary btn-sm" @click="submitPlan">{{ editingId ? '保存' : '添加' }}</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
<style scoped>
.plan-page { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
.plan-head { padding: 12px 16px; }
.nav-row { display: flex; align-items: center; gap: 8px; }
.date-box { min-width: 150px; text-align: center; }
.date-main { font-weight: 600; font-size: 14px; }
.date-sub { font-size: 11px; margin-left: 6px; }
.sum-line { font-size: 12px; margin-top: 8px; }
.timer-live { color: var(--accent); font-weight: 600; }
.flex1 { flex: 1; }
.plan-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; }
.plan-item.done { opacity: 0.62; }
.plan-item.skip { opacity: 0.5; }
.plan-item.running { border-left: 3px solid var(--accent); }
.pi-time { font-size: 13px; font-weight: 600; min-width: 92px; padding-top: 2px; }
.pi-main { flex: 1; min-width: 0; }
.pi-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pi-text { font-size: 14px; font-weight: 500; word-break: break-all; }
.pi-text.strike { text-decoration: line-through; }
.state-done { color: var(--ok, #2e9e5b); font-size: 12px; }
.state-skip { color: var(--text-3); font-size: 12px; }
.pi-note { font-size: 12px; margin-top: 3px; }
.pi-meta { font-size: 12px; margin-top: 3px; }
.pi-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.timer-on { color: var(--accent); border-color: var(--accent); }
.empty { text-align: center; padding: 26px 16px; }
.fixed-mask {
position: fixed; inset: 0; z-index: 80;
background: rgba(29, 33, 41, 0.35);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
.dialog-title { font-weight: 600; font-size: 14px; }
.dialog-body { padding: 6px 16px 4px; }
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
.form-row label { font-size: 12px; color: var(--text-2); }
.form-row.two { flex-direction: row; gap: 10px; }
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
@media (max-width: 720px) {
.plan-item { flex-wrap: wrap; }
.pi-ops { width: 100%; justify-content: flex-start; }
}
</style>
+156 -2
View File
@@ -7,10 +7,11 @@ import {
listAttendanceBetween, listLogItemsBetween, listProjects,
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
} from '../db/repo'
import { categoryLabel, type Project } from '../db/schema'
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 { hhmm, parseDateStr, todayStr } from '../lib/date'
import { addDays, hhmm, parseDateStr, todayStr, weekStart } 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'
@@ -27,6 +28,85 @@ const aiKey = ref('')
const aiModel = ref('')
const editingId = ref<number | null>(null)
const editName = ref('')
// ---- 分类管理 ----
const logCats = ref<string[]>([])
const expenseCats = ref<string[]>([])
const incomeCats = ref<string[]>([])
const editingCat = ref<{ kind: 'log' | 'expense' | 'income'; index: number; value: string } | null>(null)
async function loadCategories() {
logCats.value = await getLogCategories()
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
}
async function addCat(kind: 'log' | 'expense' | 'income') {
const name = window.prompt('新分类名称')
if (!name || !name.trim()) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const clean = name.trim()
if (list.includes(clean)) { showToast('分类已存在'); return }
const next = [...list, clean]
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) {
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) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已更新(历史记录自动跟随显示)')
}
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
if (list.length <= 1) { showToast('至少保留一个分类'); return }
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
const next = list.filter((_, i) => i !== index)
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('分类已删除')
}
// ---- 工作详情范围导出 ----
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('')
@@ -109,6 +189,7 @@ async function load() {
aiKey.value = await getSetting('ai_api_key', '')
aiModel.value = await getSetting('ai_model', '')
await loadSyncInfo()
await loadCategories()
}
async function saveAi() {
@@ -375,6 +456,64 @@ async function doImport() {
</div>
</div>
<div class="card section">
<div class="section-title">工作分类</div>
<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-chips">
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
</span>
</div>
</div>
<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-chips">
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
</span>
</div>
</div>
<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-chips">
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
</span>
</div>
</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">
@@ -434,6 +573,21 @@ async function doImport() {
.loc-note { font-size: 12px; }
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
.backup-row { display: flex; gap: 8px; }
.cat-block { margin-top: 10px; }
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
.cat-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.cat-chip {
display: inline-flex; align-items: center; gap: 4px;
border: 1px solid var(--border); border-radius: 999px;
padding: 2px 8px; font-size: 12px;
}
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
.chip-btn:hover { color: var(--accent); }
.exp-dates { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.exp-dates .input { width: 150px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); 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: 5px 12px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.backup-note { font-size: 12px; margin-top: 10px; }
</style>
+10 -5
View File
@@ -2,7 +2,7 @@
import { onMounted, ref, watch } from 'vue'
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { CATEGORIES } from '../db/schema'
import { getLogCategories } from '../db/categories'
import { addDays, todayStr, weekStart } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
@@ -57,10 +57,15 @@ async function loadAll(): Promise<void> {
.sort((a, b) => b.count - a.count)
const total = items.length || 1
catStats.value = CATEGORIES
.map(c => {
const count = items.filter(i => i.category === c.key).length
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
const logCats = await getLogCategories()
const catCount = new Map<string, number>()
for (const it of items) catCount.set(it.category, (catCount.get(it.category) || 0) + 1)
const catSet = new Set(logCats)
for (const k of catCount.keys()) if (!catSet.has(k)) logCats.push(k)
catStats.value = logCats
.map(label => {
const count = catCount.get(label) || 0
return { key: label, label, count, percent: Math.round((count / total) * 100) }
})
.filter(c => c.count > 0)
.sort((a, b) => b.count - a.count)