1 Commits
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]); 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-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}; 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="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" /> <link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title> <title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-IuJhwbTi.js"></script> <script type="module" crossorigin src="./assets/index-DLDMtYxl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-MT694d7P.css"> <link rel="stylesheet" crossorigin href="./assets/index-ieoBMDLY.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "intern-log", "name": "intern-log",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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('补卡已保存') 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) const editTarget = ref<LogItem | null>(null)
function openEdit(it: LogItem) { function openEdit(it: LogItem) {
editTarget.value = it editTarget.value = it
} }
function openAdd() {
editTarget.value = null
}
async function onEditSave(payload: { async function onEditSave(payload: {
id: number id: number
content: string content: string
@@ -227,21 +181,22 @@ async function onEditSave(payload: {
is_milestone: number is_milestone: number
needs_followup: number needs_followup: number
}) { }) {
const id = payload.id if (payload.id) {
const patch = { await updateLogItem(payload.id, {
content: payload.content, content: payload.content, project_id: payload.project_id, category: payload.category,
project_id: payload.project_id, minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark,
category: payload.category, is_milestone: payload.is_milestone, needs_followup: payload.needs_followup
minutes: payload.minutes, })
takeaway: payload.takeaway, showToast('记录已更新')
remark: payload.remark, } else {
is_milestone: payload.is_milestone, await addLogItem(props.date, {
needs_followup: payload.needs_followup 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 editTarget.value = null
await load() await load()
showToast('修改已保存')
} }
async function toggleMilestone(it: LogItem) { async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 }) await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
@@ -381,54 +336,11 @@ async function confirmDelete() {
</div> </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>
<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="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-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item"> <div v-for="it in items" :key="it.id" class="item">
<div class="item-main"> <div class="item-main">
@@ -89,7 +89,7 @@ function save() {
return return
} }
if (!props.item || !props.item.id) { 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 return
} }
emit('save', { emit('save', {
+111 -5
View File
@@ -1,7 +1,7 @@
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。 // 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器, // 约定:每个写函数修改本地缓存后立即调用 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 { addDays, nowLocalISO, toDateStr } from '../lib/date'
import { flushAfterWrite } from '../sync/engine' import { flushAfterWrite } from '../sync/engine'
@@ -438,6 +438,108 @@ export async function stopPlanTimer(id: number): Promise<void> {
await commit() 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[]> { export async function listMemos(): Promise<Memo[]> {
@@ -536,7 +638,8 @@ export async function exportAll(): Promise<string> {
db.memo.toArray(), db.memo.toArray(),
db.credential.toArray(), db.credential.toArray(),
db.table('transaction').toArray(), db.table('transaction').toArray(),
db.plan.toArray() db.plan.toArray(),
db.goal.toArray()
]) ])
return JSON.stringify({ return JSON.stringify({
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
@@ -551,7 +654,8 @@ export async function exportAll(): Promise<string> {
memo: tables[6], memo: tables[6],
credential: tables[7], credential: tables[7],
transaction: tables[8], transaction: tables[8],
plan: tables[9] plan: tables[9],
goal: tables[10]
} }
}, null, 2) }, null, 2)
} }
@@ -570,11 +674,12 @@ export async function importAll(jsonText: string): Promise<void> {
const dl = db.table('log_item') const dl = db.table('log_item')
const dt = db.table('transaction') const dt = db.table('transaction')
const dp = db.plan 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([ await Promise.all([
db.project.clear(), db.work_log.clear(), dl.clear(), db.project.clear(), db.work_log.clear(), dl.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.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.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[]) 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.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.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.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[]) 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 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 { export interface PlanItem {
id?: number id?: number
date: string // YYYY-MM-DD date: string // YYYY-MM-DD
@@ -59,6 +71,7 @@ export interface PlanItem {
status: string // todo | done | skip status: string // todo | done | skip
actual_minutes: number // 计时时钟累计的实际投入分钟 actual_minutes: number // 计时时钟累计的实际投入分钟
timer_started_at: string | null timer_started_at: string | null
goal_id: number | null // 来源长期计划(本地 id)
uuid: string uuid: string
created_at: string created_at: string
updated_at: string updated_at: string
@@ -124,6 +137,7 @@ export class WorklogDB extends Dexie {
sync_mirror!: Table<SyncMirror, number> sync_mirror!: Table<SyncMirror, number>
tombstone!: Table<SyncTombstone, number> tombstone!: Table<SyncTombstone, number>
plan!: Table<PlanItem, number> plan!: Table<PlanItem, number>
goal!: Table<Goal, number>
constructor(dbName = 'intern_worklog') { constructor(dbName = 'intern_worklog') {
super(dbName) super(dbName)
@@ -163,6 +177,10 @@ export class WorklogDB extends Dexie {
if (item.category && map[item.category]) item.category = map[item.category] 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 db = undefined
} }
export const SCHEMA_VERSION = 5 export const SCHEMA_VERSION = 6
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他'] export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_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('') 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']) 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 { interface RefMaps {
workLog: Map<number, AnyRow> workLog: Map<number, AnyRow>
project: Map<number, AnyRow> project: Map<number, AnyRow>
goal: Map<number, AnyRow>
} }
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): 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 const pj = row.project_id ? refs.project.get(row.project_id) : null
if (pj && pj.uuid) clean.puuid = pj.uuid 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 return clean
} }
async function buildRefMaps(): Promise<RefMaps> { async function buildRefMaps(): Promise<RefMaps> {
const wlRows = await db.work_log.toArray() const wlRows = await db.work_log.toArray()
const pjRows = await db.project.toArray() const pjRows = await db.project.toArray()
const gRows = await db.goal.toArray()
return { return {
workLog: new Map(wlRows.map(r => [r.id as number, r])), 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 let removed = 0
const wlIds: { [uuid: string]: number } = {} const wlIds: { [uuid: string]: number } = {}
const pjIds: { [uuid: string]: number } = {} const pjIds: { [uuid: string]: number } = {}
const goalIds: { [uuid: string]: number } = {}
for (const t of TABLES) { for (const t of TABLES) {
const table = db.table(t) const table = db.table(t)
@@ -431,6 +440,15 @@ async function applySnapshot(
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
delete srow.puuid 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) let id: any = pkOf(t, srow0)
if (t === 'setting') id = srow0.key if (t === 'setting') id = srow0.key
else { else {
@@ -445,6 +463,7 @@ async function applySnapshot(
hydrated.push(rowOut) hydrated.push(rowOut)
if (t === 'work_log') wlIds[srow0.uuid] = id if (t === 'work_log') wlIds[srow0.uuid] = id
if (t === 'project') pjIds[srow0.uuid] = id if (t === 'project') pjIds[srow0.uuid] = id
if (t === 'goal') goalIds[srow0.uuid] = id
} }
void importance void importance
const outRows = hydrated.concat(inflight) const outRows = hydrated.concat(inflight)
+18 -54
View File
@@ -61,13 +61,9 @@ const incomeCats = ref<string[]>([])
async function loadCats() { async function loadCats() {
expenseCats.value = await getLedgerCategories('expense') expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income') 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() loadCats()
watch(fType, t => {
fCategory.value = t === 'expense' ? (expenseCats.value[0] || '') : (incomeCats.value[0] || '')
})
const sorted = computed(() => const sorted = computed(() =>
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1))) rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
@@ -192,12 +188,24 @@ function openEdit(row: Transaction) {
} }
dialogOpen.value = true 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 }) { async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
const id = editingId.value as number if (editingId.value) {
await updateTransaction(id, payload) 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 dialogOpen.value = false
await load() await load()
showToast('账目已更新') showToast('已记一笔')
} }
async function confirmDelete() { async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id) if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id)
@@ -225,6 +233,7 @@ async function confirmDelete() {
<option value="pending">待报销</option> <option value="pending">待报销</option>
<option value="done">已报销</option> <option value="done">已报销</option>
</select> </select>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 记一笔</button>
</div> </div>
<div class="cards"> <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 class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></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="card sec" v-if="categoryStats.length">
<div class="sec-title">本月支出分类</div> <div class="sec-title">本月支出分类</div>
<div v-for="c in categoryStats" :key="c.name" class="bar-row"> <div v-for="c in categoryStats" :key="c.name" class="bar-row">
@@ -333,7 +297,7 @@ async function confirmDelete() {
<TransactionDialog <TransactionDialog
:open="dialogOpen" :open="dialogOpen"
:title="editingId ? '编辑账目' : '编辑账目'" :title="editingId ? '编辑账目' : '记一笔'"
:initial="dialogInitial" :initial="dialogInitial"
@cancel="dialogOpen = false" @cancel="dialogOpen = false"
@save="onDialogSave" @save="onDialogSave"
+149 -2
View File
@@ -1,14 +1,72 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { import {
addPlan, deletePlan, listPlansByDate, setPlanStatus, addPlan, deletePlan, listPlansByDate, setPlanStatus,
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo' } from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date' import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus' import { attendanceChanged } from '../lib/bus'
import { showToast } from '../lib/toast' 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 date = ref(todayStr())
const items = ref<PlanItem[]>([]) const items = ref<PlanItem[]>([])
const loading = ref(true) const loading = ref(true)
@@ -20,7 +78,16 @@ const isToday = computed(() => date.value === todayStr())
async function load() { async function load() {
loading.value = true 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 loading.value = false
} }
watch(date, load) watch(date, load)
@@ -165,6 +232,34 @@ function weekday(s: string): string {
</div> </div>
</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 class="plan-list">
<div v-if="!loading && !items.length" class="card sec empty muted"> <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-main">
<div class="pi-title"> <div class="pi-title">
<span class="tag">{{ p.kind }}</span> <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 class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
<span v-if="p.status === 'done'" class="state-done">已完成</span> <span v-if="p.status === 'done'" class="state-done">已完成</span>
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span> <span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
@@ -248,6 +344,57 @@ function weekday(s: string): string {
</div> </div>
</div> </div>
</teleport> </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> </div>
</template> </template>
+1 -60
View File
@@ -11,7 +11,7 @@ import { type Project } from '../db/schema'
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories' import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
import { downloadText, } from '../lib/download' import { downloadText, } from '../lib/download'
import { downloadCsv } from '../lib/csv' 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 { computeDay } from '../domain/attendance'
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth' import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine' 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('分类已删除') 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 fileInput = ref<HTMLInputElement | null>(null)
const pendingImport = ref<{ name: string; text: string } | null>(null) const pendingImport = ref<{ name: string; text: string } | null>(null)
const importError = ref('') const importError = ref('')
@@ -490,30 +455,6 @@ async function doImport() {
</div> </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="card section">
<div class="section-title">数据备份</div> <div class="section-title">数据备份</div>
<div class="backup-row"> <div class="backup-row">
+97 -1
View File
@@ -2,7 +2,12 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.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()) const date = ref(todayStr())
@@ -11,6 +16,73 @@ const isToday = computed(() => date.value === todayStr())
function prevDay() { date.value = addDays(date.value, -1) } function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) } function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() } 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) { function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value const v = (e.target as HTMLInputElement).value
if (v) date.value = v if (v) date.value = v
@@ -26,6 +98,18 @@ function onDateInput(e: Event) {
<input type="date" class="input w-date" :value="date" @change="onDateInput" /> <input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button> <button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
</div> </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" /> <DayPanel :date="date" />
</div> </div>
</template> </template>
@@ -42,6 +126,18 @@ function onDateInput(e: Event) {
.date-title.is-today { color: var(--accent); } .date-title.is-today { color: var(--accent); }
.w-date { width: 150px; } .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) { @media (max-width: 720px) {
.detail-toolbar { gap: 8px; } .detail-toolbar { gap: 8px; }
.date-title { min-width: 0; flex: 1; text-align: left; } .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 TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000 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 = { const NATURAL_KEYS = {