Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f268a495e |
@@ -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
|
||||||
+2
-2
@@ -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};
|
||||||
+27
File diff suppressed because one or more lines are too long
-26
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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) })
|
||||||
@@ -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
@@ -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[])
|
||||||
})
|
})
|
||||||
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
|
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
|
||||||
|
|||||||
@@ -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[] = ['工资', '报销', '其他']
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user