151 lines
6.8 KiB
Vue
151 lines
6.8 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import Icon from '../components/Icon.vue'
|
||
import DayPanel from '../components/log/DayPanel.vue'
|
||
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 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: date.value, end: date.value, label: date.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
|
||
}
|
||
|
||
// 浏览日期变化时,导出区间锚点跟随(周/月/自定义默认围绕当前指定日)
|
||
watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.value = d })
|
||
</script>
|
||
<template>
|
||
<div class="detail-page">
|
||
<div class="card toolbar">
|
||
<div class="nav-row">
|
||
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||
<span class="date-title num" :class="{ 'is-today': isToday }">{{ formatDateCN(date) }}</span>
|
||
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
|
||
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
|
||
</div>
|
||
<div class="tool-sep"></div>
|
||
<div class="exp-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>
|
||
</div>
|
||
<DayPanel :date="date" />
|
||
</div>
|
||
</template>
|
||
<style scoped>
|
||
.detail-page { display: flex; flex-direction: column; }
|
||
.toolbar {
|
||
padding: 10px 16px;
|
||
display: flex; align-items: center; gap: 14px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
.nav-row { display: flex; align-items: center; gap: 6px; }
|
||
.date-title { font-size: 14px; font-weight: 600; min-width: 170px; text-align: center; }
|
||
.date-title.is-today { color: var(--accent); }
|
||
.w-date { width: 150px; flex: none; }
|
||
.tool-sep { width: 1px; align-self: stretch; background: var(--border); }
|
||
.exp-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.exp-label { font-size: 12px; }
|
||
.w-exp { width: 140px; flex: none; }
|
||
|
||
@media (max-width: 720px) {
|
||
.tool-sep { display: none; }
|
||
.toolbar { padding: 10px 12px; gap: 10px; }
|
||
.nav-row { flex: 1 1 100%; }
|
||
.date-title { min-width: 0; flex: 1; text-align: left; }
|
||
.w-date { flex: 1; min-width: 120px; width: auto; }
|
||
.exp-row { width: 100%; }
|
||
.exp-row .seg { flex: 1; display: flex; }
|
||
.exp-row .seg-btn { flex: 1; padding: 0 6px; }
|
||
.w-exp { flex: 1; min-width: 0; width: auto; }
|
||
}
|
||
</style>
|