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
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
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-Tgz3gT_A.js","./index-D8vVtKDY.js","./index-DxgZcHRE.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-D8vVtKDY.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-Tgz3gT_A.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Eue2aiSv.js","./index-IuJhwbTi.js","./index-MT694d7P.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-IuJhwbTi.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-Eue2aiSv.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
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-D8vVtKDY.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DxgZcHRE.css">
<script type="module" crossorigin src="./assets/index-IuJhwbTi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-MT694d7P.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.3.1",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+4 -1
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import Icon from './components/Icon.vue'
import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import PlanView from './views/PlanView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import LedgerView from './views/LedgerView.vue'
@@ -112,6 +113,7 @@ function onGlobalKey(e: KeyboardEvent) {
const VIEWS = [
{ key: 'calendar', name: '日历', icon: 'calendar', group: '记录' },
{ key: 'today', name: '详情', icon: 'edit', group: '记录' },
{ key: 'plan', name: '计划', icon: 'plan', group: '记录' },
{ key: 'ledger', name: '记账', icon: 'wallet', group: '工具' },
{ key: 'memo', name: '备忘录', icon: 'memo', group: '工具' },
{ key: 'stats', name: '统计', icon: 'chart', group: '分析' },
@@ -145,7 +147,7 @@ watch([isAdminUser, route], () => {
}, { immediate: true })
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
const TAB_KEYS = ['calendar', 'today', 'plan', 'ledger']
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
const moreOpen = ref(false)
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
@@ -291,6 +293,7 @@ onMounted(async () => {
<div class="content-inner">
<CalendarView v-if="route === 'calendar'" />
<TodayView v-else-if="route === 'today'" />
<PlanView v-else-if="route === 'plan'" />
<StatsView v-else-if="route === 'stats'" />
<ReportView v-else-if="route === 'report'" />
<LedgerView v-else-if="route === 'ledger'" />
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
const props = defineProps<{
open: boolean
@@ -15,7 +15,7 @@ const emit = defineEmits<{
}>()
const fType = ref<'expense' | 'income'>('expense')
const fCategory = ref('餐饮')
const fCategory = ref('')
const fAmount = ref('')
const fDate = ref('')
const fNote = ref('')
@@ -30,6 +30,7 @@ function onKey(e: KeyboardEvent) {
}
watch(() => props.open, async v => {
if (v) {
await loadCats()
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
fCategory.value = props.initial.category
fAmount.value = props.initial.amountYuan
@@ -51,7 +52,20 @@ watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
})
const categoryOptions = computed(() => (fType.value === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES))
const categoryOptions = ref<string[]>([])
async function loadCats() {
categoryOptions.value = await getLedgerCategories(fType.value)
}
const newCat = ref('')
async function addCategory() {
const name = newCat.value.trim()
if (!name) return
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
categoryOptions.value.push(name)
await setLedgerCategories(fType.value, categoryOptions.value)
fCategory.value = name
newCat.value = ''
}
function computedCategory() {
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
}
@@ -109,10 +123,14 @@ function save() {
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<label>分类(可直接新增)</label>
<select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
<div class="add-cat">
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
</div>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销状态</label>
@@ -164,6 +182,8 @@ function save() {
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
+2 -1
View File
@@ -24,5 +24,6 @@ export const ICONS: Record<string, string> = {
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
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"/>'),
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"/>'),
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"/>')
}
+5 -2
View File
@@ -8,13 +8,16 @@ import {
getLocationPresets, getWorkRule, listLogItemsByDate, listProjects, saveMakeup,
setAttendanceLocation, setAttendanceTime, setLeave, updateLogItem
} from '../../db/repo'
import { CATEGORIES, categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
import { formatDateCN, hhmm, todayStr } from '../../lib/date'
import { computeDay, type DayAttendanceInfo } from '../../domain/attendance'
import type { WorkRule } from '../../db/repo'
import { showToast } from '../../lib/toast'
import { attendanceChanged, notifyAttendanceChanged } from '../../lib/bus'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{ date: string }>()
const loading = ref(true)
@@ -402,7 +405,7 @@ async function confirmDelete() {
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field">
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { CATEGORIES, type LogItem, type Project } from '../../db/schema'
import { type LogItem, type Project } from '../../db/schema'
import { getLogCategories } from '../../db/categories'
const logCats = ref<string[]>([])
getLogCategories().then(l => { logCats.value = l })
const props = defineProps<{
open: boolean
item: LogItem | null
@@ -137,7 +140,7 @@ function save() {
<div class="field">
<label>工作分类</label>
<select class="select" v-model="formCategory">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field">
+39
View File
@@ -0,0 +1,39 @@
// 分类管理:工作分类与记账分类均为用户数据(存于服务器随账号同步)
// 未自定义时返回默认值;分类值即显示文本(历史遗留 key 由 categoryLabel 直通转换)
import { getSetting, setSetting } from './repo'
export const DEFAULT_LOG_CATEGORIES: string[] = ['制度学习', '产品学习', '打杂', '项目实操', '售后服务']
export const DEFAULT_EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const DEFAULT_INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
const LOG_KEY = 'log_categories'
const EXPENSE_KEY = 'ledger_expense_categories'
const INCOME_KEY = 'ledger_income_categories'
function parse(raw: string, fallback: string[]): string[] {
if (!raw) return fallback.slice()
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) {
const clean = arr.filter((x: unknown) => typeof x === 'string' && x.trim()).map((x: string) => x.trim())
if (clean.length) return Array.from(new Set(clean))
}
} catch { /* 损坏数据回退默认 */ }
return fallback.slice()
}
export async function getLogCategories(): Promise<string[]> {
return parse(await getSetting(LOG_KEY, ''), DEFAULT_LOG_CATEGORIES)
}
export async function setLogCategories(list: string[]): Promise<void> {
await setSetting(LOG_KEY, JSON.stringify(list))
}
export async function getLedgerCategories(type: 'expense' | 'income'): Promise<string[]> {
return parse(await getSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, ''), type === 'expense' ? DEFAULT_EXPENSE_CATEGORIES : DEFAULT_INCOME_CATEGORIES)
}
export async function setLedgerCategories(type: 'expense' | 'income', list: string[]): Promise<void> {
await setSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, JSON.stringify(list))
}
+88 -6
View File
@@ -1,7 +1,7 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
@@ -360,6 +360,84 @@ export async function listWeeklyNotesBetween(start: string, end: string): Promis
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
}
// ---------- 计划模式 ----------
export interface PlanInput {
date: string
start_time: string
end_time: string
title: string
kind: string
note: string
}
export async function listPlansByDate(date: string): Promise<PlanItem[]> {
const rows = await db.plan.where('date').equals(date).toArray()
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
}
export async function addPlan(input: PlanInput): Promise<void> {
const now = nowLocalISO()
await db.plan.add({
date: input.date,
start_time: input.start_time,
end_time: input.end_time,
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
uuid: '',
created_at: now,
updated_at: now
} as never)
await commit()
}
export async function updatePlan(id: number, patch: Partial<PlanItem>): Promise<void> {
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
await commit()
}
export async function deletePlan(id: number): Promise<void> {
await db.plan.delete(id)
await commit()
}
// 打卡:完成 / 跳过 / 恢复
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
const row = await db.plan.get(id)
if (!row) return
if (status !== 'todo' && row.timer_started_at) {
await stopPlanTimer(id)
}
await db.plan.update(id, { status, updated_at: nowLocalISO() })
await commit()
}
// 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个
export async function startPlanTimer(id: number): Promise<void> {
const running = await db.plan.filter(p => !!p.timer_started_at).toArray()
for (const p of running) {
if (p.id !== id) await stopPlanTimer(p.id as number)
}
await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() })
await commit()
}
export async function stopPlanTimer(id: number): Promise<void> {
const row = await db.plan.get(id)
if (!row || !row.timer_started_at) return
const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000))
await db.plan.update(id, {
actual_minutes: (row.actual_minutes || 0) + elapsed,
timer_started_at: null,
updated_at: nowLocalISO()
})
await commit()
}
// ---------- 备忘录 ----------
export async function listMemos(): Promise<Memo[]> {
@@ -457,7 +535,8 @@ export async function exportAll(): Promise<string> {
db.setting.toArray(),
db.memo.toArray(),
db.credential.toArray(),
db.table('transaction').toArray()
db.table('transaction').toArray(),
db.plan.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
@@ -471,7 +550,8 @@ export async function exportAll(): Promise<string> {
setting: tables[5],
memo: tables[6],
credential: tables[7],
transaction: tables[8]
transaction: tables[8],
plan: tables[9]
}
}, null, 2)
}
@@ -483,17 +563,18 @@ export async function importAll(jsonText: string): Promise<void> {
} catch {
throw new Error('不是有效的 JSON 文件')
}
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3) || !parsed.data) {
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3 && parsed.schema_version !== 4 && parsed.schema_version !== 5) || !parsed.data) {
throw new Error('文件格式或 schema 版本不匹配')
}
const d = parsed.data
const dl = db.table('log_item')
const dt = db.table('transaction')
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt], async () => {
const dp = db.plan
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dp], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), dl.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear(), dt.clear()
db.memo.clear(), db.credential.clear(), dt.clear(), dp.clear()
])
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
@@ -504,6 +585,7 @@ export async function importAll(jsonText: string): Promise<void> {
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
if (d.transaction && d.transaction.length) await dt.bulkAdd(d.transaction as never[])
if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[])
})
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
await commit()
+47 -12
View File
@@ -48,6 +48,22 @@ export interface WeeklyNote {
created_at: string
}
export interface PlanItem {
id?: number
date: string // YYYY-MM-DD
start_time: string // HH:mm
end_time: string // HH:mm
title: string // 计划内容,如:产品学习 / 健身
kind: string // 类别:学习 / 健身 / 自律 / 其他自定义
note: string | null
status: string // todo | done | skip
actual_minutes: number // 计时时钟累计的实际投入分钟
timer_started_at: string | null
uuid: string
created_at: string
updated_at: string
}
export interface Memo {
id?: number
title: string
@@ -107,6 +123,7 @@ export class WorklogDB extends Dexie {
// 因为 db.transaction 会与 Dexie 内置的 transaction() 方法冲突
sync_mirror!: Table<SyncMirror, number>
tombstone!: Table<SyncTombstone, number>
plan!: Table<PlanItem, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
@@ -129,6 +146,23 @@ export class WorklogDB extends Dexie {
sync_mirror: '++id, table, uuid',
tombstone: '++id, table, uuid, deleted_at'
})
this.version(5).stores({
plan: '++id, date, status'
}).upgrade(async tx => {
// 旧固定分类 key 迁移为用户自定义分类标签(v0.4.0 起分类为用户数据)
const map: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
await tx.table('log_item').toCollection().modify((item: { category?: string }) => {
if (item.category && map[item.category]) item.category = map[item.category]
})
})
}
}
@@ -144,24 +178,25 @@ export function unbindDatabase(): void {
db = undefined
}
export const SCHEMA_VERSION = 4
export const SCHEMA_VERSION = 5
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
export const CATEGORIES: { key: string; label: string }[] = [
{ key: 'dev', label: '开发' },
{ key: 'bugfix', label: 'BUG修复' },
{ key: 'test', label: '测试' },
{ key: 'learning', label: '学习成长' },
{ key: 'meeting', label: '会议沟通' },
{ key: 'doc', label: '文档输出' },
{ key: 'other', label: '其他' }
]
// 旧版固定分类 key → 默认标签的映射(仅用于历史数据迁移与展示兜底)
export const LEGACY_CATEGORY_LABELS: Record<string, string> = {
dev: '项目实操',
test: '项目实操',
bugfix: '售后服务',
learning: '产品学习',
meeting: '打杂',
doc: '打杂',
other: '打杂'
}
// v0.4.0 起分类即用户自定义标签本身;历史 key 在此直通转换
export function categoryLabel(key: string): string {
const found = CATEGORIES.find(c => c.key === key)
return found ? found.label : '其他'
return LEGACY_CATEGORY_LABELS[key] || key
}
+13 -6
View File
@@ -1,5 +1,6 @@
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects, listWeeklyNotesBetween } from '../db/repo'
import { CATEGORIES, type LogItem } from '../db/schema'
import { type LogItem } from '../db/schema'
import { getLogCategories } from '../db/categories'
import { todayStr } from '../lib/date'
import { computeDay } from './attendance'
@@ -44,13 +45,15 @@ export async function buildReport(start: string, end: string, label: string): Pr
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true),
listWeeklyNotesBetween(start, end)
listWeeklyNotesBetween(start, end),
getLogCategories()
])
const rule = results[0]
const atts = results[1]
const items = results[2]
const projects = results[3]
const notes = results[4]
const logCats: string[] = results[5] as string[]
let attendDays = 0
let workedMin = 0
@@ -73,10 +76,14 @@ export async function buildReport(start: string, end: string, label: string): Pr
.sort((a, b) => b.count - a.count)
const total = items.length || 1
const categoryStats: CategoryStat[] = CATEGORIES
.map(c => {
const count = items.filter((i: LogItem) => i.category === c.key).length
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
const catCount2 = new Map<string, number>()
for (const it of items) catCount2.set(it.category, (catCount2.get(it.category) || 0) + 1)
const catSet2 = new Set(logCats)
for (const k of catCount2.keys()) if (!catSet2.has(k)) logCats.push(k)
const categoryStats: CategoryStat[] = logCats
.map(label => {
const count = catCount2.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)
+1 -1
View File
@@ -13,7 +13,7 @@ import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBa
// 最近一次自动同步的错误(设置页展示用)
export const syncLastError = ref('')
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction', 'plan']
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
+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)