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
+25
View File
@@ -0,0 +1,25 @@
# v0.5.0 发布记录
发布日期:2026-10-16
## 计划模式增强:长期计划
- 长期计划(目标)跨越多天:设置起止日期后自动**物化为每一天的任务**,落实到每天打卡与计时
- 目标进度条:已完成天数 / 总天数一目了然;编辑目标自动级联更新未完成的每日任务
- 每日任务带「长期」徽标,可与当日临时计划混排;删除目标时二次确认
- 数据模型新增 goal 表(v6),goal ↔ plan 外键随多端同步(引擎级冒烟验证外键水合正确)
## 界面原则:内容优先
- 记账页:移除内联新增表单,改为「记一笔」按钮弹窗填写,月度汇总与账目表前置
- 当日详情:移除内联快速记录表单,改为「添加记录」按钮弹窗填写,记录列表前置
- 工作详情导出从设置页移至详情页,并支持 **Markdown 格式**(当日 / 当周 / 当月 / 自定义时间段,含考勤与逐条记录)
## 上一版(v0.4.0)要点回顾
计划模式(时间段+打卡+计时时钟)、工作与记账分类自定义、工作详情范围导出 CSV
## 升级说明
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build
- 客户端覆盖安装;本地库自动升级至 v6
@@ -1,2 +1,2 @@
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};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-e9jKyg3_.js","./index-DLDMtYxl.js","./index-ieoBMDLY.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DLDMtYxl.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-e9jKyg3_.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
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="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-IuJhwbTi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-MT694d7P.css">
<script type="module" crossorigin src="./assets/index-DLDMtYxl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ieoBMDLY.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+38
View File
@@ -0,0 +1,38 @@
import 'fake-indexeddb/auto'
const BASE = 'http://127.0.0.1:3780'
const store = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => { store.set(k, String(v)) },
removeItem: (k: string) => { store.delete(k) }
}
store.set('dsh_cloud_creds', JSON.stringify({ username: 'goal_smoke', password: 'goal123' }))
store.set('dsh_sync_base', BASE)
async function main() {
const reg = await fetch(BASE + '/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'goal_smoke', password: 'goal123' })
}).catch(() => null)
const schema = await import('../src/db/schema')
const engine = await import('../src/sync/engine')
schema.bindDatabase(new schema.WorklogDB('gs_a'))
const a = schema.db
// 设备 A:建 goal + plan(goal_id 指向本地 goal)
const gid = await a.goal.add({ title: '产品文档学习', kind: '学习', start_date: '2026-10-16', end_date: '2026-10-18', note: null, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
await a.plan.add({ date: '2026-10-16', start_time: '08:00', end_time: '09:00', title: '产品文档学习', kind: '学习', note: null, status: 'todo', actual_minutes: 0, timer_started_at: null, goal_id: gid, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
const rA = await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
console.log('A push:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// 设备 B:空库拉取
schema.bindDatabase(new schema.WorklogDB('gs_b'))
await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
const b = schema.db
const goals = await b.goal.toArray()
const plans = await b.plan.toArray()
const okGoal = goals.length === 1
const okFk = plans.length === 1 && plans[0].goal_id === goals[0].id && plans[0].title === '产品文档学习'
console.log((okGoal ? 'PASS' : 'FAIL') + ' B 拉到长期计划')
console.log((okFk ? 'PASS' : 'FAIL') + ' plan.goal_id 正确水合到本地 goal(' + JSON.stringify({ goalId: plans[0] && plans[0].goal_id, localGoalId: goals[0] && goals[0].id }) + ')')
process.exit(okGoal && okFk ? 0 : 1)
}
main().catch(e => { console.error('FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
+20 -108
View File
@@ -161,61 +161,15 @@ async function saveMakeupForm() {
showToast('补卡已保存')
}
// ---- 新增工作记录 ----
const content = ref('')
const contentError = ref('')
watch(content, () => {
if (contentError.value) contentError.value = ''
})
const projectId = ref<number | null>(null)
const category = ref('dev')
const minutesVal = ref<number | null>(null)
const takeawayVal = ref('')
const remarkVal = ref('')
const submitting = ref(false)
const formDirty = computed(() => !!(content.value.trim() || minutesVal.value || takeawayVal.value.trim() || remarkVal.value.trim()))
onMounted(async () => {
const lp = await getLastPick()
projectId.value = lp.project_id
category.value = lp.category
})
function resetForm() {
content.value = ''
minutesVal.value = null
takeawayVal.value = ''
remarkVal.value = ''
contentError.value = ''
}
async function submit() {
const text = content.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (submitting.value) return
submitting.value = true
await addLogItem(props.date, {
content: text,
project_id: projectId.value,
category: category.value,
minutes: minutesVal.value && minutesVal.value > 0 ? minutesVal.value : null,
takeaway: takeawayVal.value.trim() || null,
remark: remarkVal.value.trim() || null
})
resetForm()
await load()
showToast('记录已添加')
submitting.value = false
}
// ---- 条目操作 ----
const editTarget = ref<LogItem | null>(null)
function openEdit(it: LogItem) {
editTarget.value = it
}
function openAdd() {
editTarget.value = null
}
async function onEditSave(payload: {
id: number
content: string
@@ -227,21 +181,22 @@ async function onEditSave(payload: {
is_milestone: number
needs_followup: number
}) {
const id = payload.id
const patch = {
content: payload.content,
project_id: payload.project_id,
category: payload.category,
minutes: payload.minutes,
takeaway: payload.takeaway,
remark: payload.remark,
is_milestone: payload.is_milestone,
needs_followup: payload.needs_followup
if (payload.id) {
await updateLogItem(payload.id, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark,
is_milestone: payload.is_milestone, needs_followup: payload.needs_followup
})
showToast('记录已更新')
} else {
await addLogItem(props.date, {
content: payload.content, project_id: payload.project_id, category: payload.category,
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark
})
showToast('记录已添加')
}
await updateLogItem(id, patch)
editTarget.value = null
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
@@ -381,54 +336,11 @@ async function confirmDelete() {
</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>
<input
class="input" :class="{ invalid: contentError }"
v-model="content" placeholder="做了什么,一句话一条" @keydown.enter="submit"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="projectId">
<option :value="null">无项目</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="minutesVal" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="takeawayVal" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="remarkVal" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
</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="list-sec">
<div class="sec-title">工作记录({{ items.length }})</div>
<div class="sec-head">
<span class="sec-title">工作记录({{ items.length }})</span>
<button class="btn btn-primary btn-sm" @click="openAdd"><Icon name="plus" :size="14" /> 添加记录</button>
</div>
<div v-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item">
<div class="item-main">
@@ -89,7 +89,7 @@ function save() {
return
}
if (!props.item || !props.item.id) {
emit('cancel')
emit('save', { id: 0, content: text, project_id: formProjectId.value, category: formCategory.value, minutes: formMinutes.value && formMinutes.value > 0 ? formMinutes.value : null, takeaway: formTakeaway.value.trim() || null, remark: formRemark.value.trim() || null, is_milestone: formMilestone.value, needs_followup: formFollowup.value })
return
}
emit('save', {
+111 -5
View File
@@ -1,7 +1,7 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
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 { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Goal, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine'
@@ -438,6 +438,108 @@ export async function stopPlanTimer(id: number): Promise<void> {
await commit()
}
// ---------- 长期计划(目标) ----------
export interface GoalInput {
title: string
kind: string
start_date: string
end_date: string
note: string
}
const MAX_MATERIALIZE_DAYS = 366
function eachDate(start: string, end: string): string[] {
const out: string[] = []
let cur = start
let guard = 0
while (cur <= end && guard < MAX_MATERIALIZE_DAYS) {
out.push(cur)
cur = addDays(cur, 1)
guard++
}
return out
}
export async function getGoalProgress(): Promise<{ goalId: number; done: number; total: number }[]> {
const goals = await db.goal.toArray()
const out: { goalId: number; done: number; total: number }[] = []
for (const g of goals) {
const items = await db.plan.where('goal_id').equals(g.id as number).toArray()
out.push({ goalId: g.id as number, done: items.filter(i => i.status === 'done').length, total: items.length })
}
return out
}
export async function listGoals(): Promise<Goal[]> {
const rows = await db.goal.toArray()
return rows.sort((a, b) => (a.start_date < b.start_date ? -1 : 1))
}
// 新建长期计划并物化每一天的任务(落实每日打卡与计时)
export async function addGoal(input: GoalInput): Promise<void> {
const now = nowLocalISO()
const gid = await db.goal.add({
title: input.title.trim(),
kind: input.kind.trim() || '其他',
start_date: input.start_date,
end_date: input.end_date,
note: input.note || null,
uuid: '',
created_at: now,
updated_at: now
} as never)
const goal = await db.goal.get(gid)
const dates = eachDate(input.start_date, input.end_date)
for (const d of dates) {
await db.plan.add({
date: d,
start_time: '08:00',
end_time: '09:00',
title: input.title.trim(),
kind: input.kind.trim() || '其他',
note: input.note || null,
status: 'todo',
actual_minutes: 0,
timer_started_at: null,
goal_id: gid,
uuid: '',
created_at: now,
updated_at: now
} as never)
}
void goal
await commit()
}
// 编辑目标:标题/类别级联到其每日任务(已完成项不改动)
export async function updateGoal(id: number, patch: Partial<GoalInput>): Promise<void> {
const goal = await db.goal.get(id)
if (!goal) return
const next = { ...patch, updated_at: nowLocalISO() }
await db.goal.update(id, next)
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) {
if (it.status === 'done') continue
await db.plan.update(it.id as number, {
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
...(patch.note !== undefined ? { note: patch.note || null } : {}),
updated_at: nowLocalISO()
})
}
await commit()
}
// 删除目标:连同其每日任务一起删除(前端二次确认)
export async function deleteGoal(id: number): Promise<void> {
const items = await db.plan.where('goal_id').equals(id).toArray()
for (const it of items) await db.plan.delete(it.id as number)
await db.goal.delete(id)
await commit()
}
// ---------- 备忘录 ----------
export async function listMemos(): Promise<Memo[]> {
@@ -536,7 +638,8 @@ export async function exportAll(): Promise<string> {
db.memo.toArray(),
db.credential.toArray(),
db.table('transaction').toArray(),
db.plan.toArray()
db.plan.toArray(),
db.goal.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
@@ -551,7 +654,8 @@ export async function exportAll(): Promise<string> {
memo: tables[6],
credential: tables[7],
transaction: tables[8],
plan: tables[9]
plan: tables[9],
goal: tables[10]
}
}, null, 2)
}
@@ -570,11 +674,12 @@ export async function importAll(jsonText: string): Promise<void> {
const dl = db.table('log_item')
const dt = db.table('transaction')
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 () => {
const dg = db.goal
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dg, 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(), dp.clear()
db.memo.clear(), db.credential.clear(), dt.clear(), dg.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[])
@@ -585,6 +690,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.goal && d.goal.length) await dg.bulkAdd(d.goal as never[])
if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[])
})
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
+19 -1
View File
@@ -48,6 +48,18 @@ export interface WeeklyNote {
created_at: string
}
export interface Goal {
id?: number
title: string // 长期计划,如:学完产品文档 / 本月健身 20 次
kind: string
start_date: string // YYYY-MM-DD
end_date: string
note: string | null
uuid: string
created_at: string
updated_at: string
}
export interface PlanItem {
id?: number
date: string // YYYY-MM-DD
@@ -59,6 +71,7 @@ export interface PlanItem {
status: string // todo | done | skip
actual_minutes: number // 计时时钟累计的实际投入分钟
timer_started_at: string | null
goal_id: number | null // 来源长期计划(本地 id)
uuid: string
created_at: string
updated_at: string
@@ -124,6 +137,7 @@ export class WorklogDB extends Dexie {
sync_mirror!: Table<SyncMirror, number>
tombstone!: Table<SyncTombstone, number>
plan!: Table<PlanItem, number>
goal!: Table<Goal, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
@@ -163,6 +177,10 @@ export class WorklogDB extends Dexie {
if (item.category && map[item.category]) item.category = map[item.category]
})
})
this.version(6).stores({
plan: '++id, date, status, goal_id',
goal: '++id, start_date'
})
}
}
@@ -178,7 +196,7 @@ export function unbindDatabase(): void {
db = undefined
}
export const SCHEMA_VERSION = 5
export const SCHEMA_VERSION = 6
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
+21 -2
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', 'plan']
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
@@ -175,6 +175,7 @@ export async function flushAfterWrite(): Promise<void> {
interface RefMaps {
workLog: Map<number, AnyRow>
project: Map<number, AnyRow>
goal: Map<number, AnyRow>
}
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
@@ -191,15 +192,22 @@ function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
const pj = row.project_id ? refs.project.get(row.project_id) : null
if (pj && pj.uuid) clean.puuid = pj.uuid
}
if (t === 'plan') {
delete clean.goal_id
const g = row.goal_id ? refs.goal.get(row.goal_id) : null
if (g && g.uuid) clean.guuid = g.uuid
}
return clean
}
async function buildRefMaps(): Promise<RefMaps> {
const wlRows = await db.work_log.toArray()
const pjRows = await db.project.toArray()
const gRows = await db.goal.toArray()
return {
workLog: new Map(wlRows.map(r => [r.id as number, r])),
project: new Map(pjRows.map(p => [p.id as number, p]))
project: new Map(pjRows.map(p => [p.id as number, p])),
goal: new Map(gRows.map(r => [r.id as number, r]))
}
}
@@ -398,6 +406,7 @@ async function applySnapshot(
let removed = 0
const wlIds: { [uuid: string]: number } = {}
const pjIds: { [uuid: string]: number } = {}
const goalIds: { [uuid: string]: number } = {}
for (const t of TABLES) {
const table = db.table(t)
@@ -431,6 +440,15 @@ async function applySnapshot(
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
delete srow.puuid
}
if (t === 'plan') {
if (srow.guuid) {
const gid = goalIds[srow.guuid]
srow.goal_id = gid ?? null
delete srow.guuid
} else {
srow.goal_id = null
}
}
let id: any = pkOf(t, srow0)
if (t === 'setting') id = srow0.key
else {
@@ -445,6 +463,7 @@ async function applySnapshot(
hydrated.push(rowOut)
if (t === 'work_log') wlIds[srow0.uuid] = id
if (t === 'project') pjIds[srow0.uuid] = id
if (t === 'goal') goalIds[srow0.uuid] = id
}
void importance
const outRows = hydrated.concat(inflight)
+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; }
+1 -1
View File
@@ -12,7 +12,7 @@ const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction', 'plan']
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
const NATURAL_KEYS = {