feat: 长期计划(目标→每日任务物化+进度条) / 详情页Markdown范围导出 / 记账与详情页改内容优先弹窗新增

This commit is contained in:
root
2026-10-26 12:16:14 +08:00
parent 284f8ebd46
commit 0f268a495e
20 changed files with 535 additions and 268 deletions
+97 -1
View File
@@ -2,7 +2,12 @@
import { computed, ref } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { addDays, formatDateCN, todayStr } from '../lib/date'
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { addDays, formatDateCN, hhmm, todayStr, weekStart } from '../lib/date'
import { downloadText } from '../lib/download'
import { showToast } from '../lib/toast'
import { categoryLabel } from '../db/schema'
// 详情视图:自由切换日期,对任意一天进行详细查看与修改
const date = ref(todayStr())
@@ -11,6 +16,73 @@ const isToday = computed(() => date.value === todayStr())
function prevDay() { date.value = addDays(date.value, -1) }
function nextDay() { date.value = addDays(date.value, 1) }
function goToday() { date.value = todayStr() }
// ---- 工作详情导出(Markdown) ----
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
const expStart = ref(todayStr())
const expEnd = ref(todayStr())
const exporting = ref(false)
function rangeStartEnd(): { start: string; end: string; label: string } {
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
if (expRange.value === 'week') {
const ws = weekStart(expStart.value)
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
}
if (expRange.value === 'month') {
const y = expStart.value.slice(0, 4)
const m = expStart.value.slice(5, 7)
const last = new Date(Number(y), Number(m), 0).getDate()
return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m }
}
return { start: expStart.value, end: expEnd.value, label: expStart.value + '_' + expEnd.value }
}
async function doExportMd() {
if (exporting.value) return
exporting.value = true
try {
const { start, end, label } = rangeStartEnd()
const [rule, atts, items, projects] = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true)
])
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
const lines: string[] = ['# 工作详情 ' + label, '']
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
let cur = start
let guard = 0
while (cur <= end && guard < 400) {
const dayItems = items.filter(i => i.date === cur)
const att = atts.find(a => a.date === cur)
if (att || dayItems.length) {
lines.push('## ' + cur + ' ' + wd[new Date(cur + 'T00:00:00').getDay()], '')
if (att && (att.clock_in_at || att.kind === 'makeup')) {
const info = computeDay(att, cur, rule, cur === todayStr())
const seg = [hhmm(att.clock_in_at), '–', hhmm(att.clock_out_at), '·', info.label]
if (att.location) seg.push('· 地点:' + att.location)
lines.push('考勤:' + seg.join(' '), '')
}
for (const it of dayItems) {
const proj = it.project_id ? (pname.get(it.project_id) || '') : ''
const head = '- 【' + categoryLabel(it.category) + '】' + it.content + (it.minutes ? '(' + it.minutes + ' 分钟)' : '') + (proj ? ' @' + proj : '')
lines.push(head)
if (it.takeaway) lines.push(' - 收获:' + it.takeaway)
if (it.needs_followup === 1 && it.remark) lines.push(' - 待跟进:' + it.remark)
else if (it.remark) lines.push(' - 备注:' + it.remark)
}
lines.push('')
}
cur = addDays(cur, 1)
guard++
}
await downloadText('工作详情_' + label + '.md', lines.join('\n'))
showToast('已导出 Markdown')
} finally {
exporting.value = false
}
}
function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (v) date.value = v
@@ -26,6 +98,18 @@ function onDateInput(e: Event) {
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
</div>
<div class="export-row">
<span class="muted exp-label">导出工作详情</span>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
</div>
<input v-if="expRange !== 'day'" class="input w-exp" type="date" v-model="expStart" />
<input v-if="expRange === 'custom'" class="input w-exp" type="date" v-model="expEnd" />
<button class="btn btn-sm" :disabled="exporting" @click="doExportMd">导出 Markdown</button>
</div>
<DayPanel :date="date" />
</div>
</template>
@@ -42,6 +126,18 @@ function onDateInput(e: Event) {
.date-title.is-today { color: var(--accent); }
.w-date { width: 150px; }
.export-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: var(--radius-md);
background: var(--bg-page);
}
.exp-label { font-size: 12px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 4px 10px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-exp { width: 140px; }
@media (max-width: 720px) {
.detail-toolbar { gap: 8px; }
.date-title { min-width: 0; flex: 1; text-align: left; }