209 lines
7.4 KiB
Vue
209 lines
7.4 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 { getWorkRule, listAttendanceBetween, listLogItemsBetween } from '../db/repo'
|
|
import { computeDay } from '../domain/attendance'
|
|
import { addMonths, toDateStr, todayStr } from '../lib/date'
|
|
import { attendanceChanged, focusDate } from '../lib/bus'
|
|
|
|
const monthRef = ref(todayStr())
|
|
const year = computed(() => Number(monthRef.value.slice(0, 4)))
|
|
const month0 = computed(() => Number(monthRef.value.slice(5, 7)) - 1)
|
|
const title = computed(() => year.value + ' 年 ' + (month0.value + 1) + ' 月')
|
|
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
|
|
|
const cells = computed(() => {
|
|
const first = new Date(year.value, month0.value, 1)
|
|
const lead = (first.getDay() + 6) % 7
|
|
const total = new Date(year.value, month0.value + 1, 0).getDate()
|
|
const arr: { date: string; inMonth: boolean; weekend: boolean }[] = []
|
|
for (let i = 0; i < lead; i++) arr.push({ date: '', inMonth: false, weekend: false })
|
|
for (let d = 1; d <= total; d++) {
|
|
const dt = new Date(year.value, month0.value, d)
|
|
const wd = dt.getDay()
|
|
arr.push({ date: toDateStr(dt), inMonth: true, weekend: wd === 0 || wd === 6 })
|
|
}
|
|
return arr
|
|
})
|
|
|
|
interface CellInfo { status: string; label: string; location: string; hasLog: boolean; hasMilestone: boolean }
|
|
const infoMap = ref(new Map<string, CellInfo>())
|
|
const t = todayStr()
|
|
|
|
async function loadMonth() {
|
|
const start = toDateStr(new Date(year.value, month0.value, 1))
|
|
const end = toDateStr(new Date(year.value, month0.value + 1, 0))
|
|
const results = await Promise.all([
|
|
getWorkRule(),
|
|
listAttendanceBetween(start, end),
|
|
listLogItemsBetween(start, end)
|
|
])
|
|
const rule = results[0]
|
|
const attMap = new Map(results[1].map(a => [a.date, a]))
|
|
const items = results[2]
|
|
const m = new Map<string, CellInfo>()
|
|
for (const c of cells.value) {
|
|
if (!c.inMonth) continue
|
|
const di = computeDay(attMap.get(c.date), c.date, rule, c.date === t)
|
|
const dayItems = items.filter(i => i.date === c.date)
|
|
m.set(c.date, {
|
|
status: di.status,
|
|
label: di.label,
|
|
location: (attMap.get(c.date)?.location) || '',
|
|
hasLog: dayItems.length > 0,
|
|
hasMilestone: dayItems.some(i => i.is_milestone === 1)
|
|
})
|
|
}
|
|
infoMap.value = m
|
|
}
|
|
watch([year, month0], loadMonth, { immediate: true })
|
|
// 打卡、补卡、休假等变更后实时刷新格子状态
|
|
watch(attendanceChanged, () => { loadMonth() })
|
|
|
|
function statusOf(date: string): string { return infoMap.value.get(date)?.status ?? '' }
|
|
function cellTitle(date: string): string {
|
|
const info = infoMap.value.get(date)
|
|
if (!info) return date
|
|
return date + ' · ' + info.label + (info.location ? ' · ' + info.location : '')
|
|
}
|
|
function hasLogOf(date: string): boolean { return !!infoMap.value.get(date)?.hasLog }
|
|
function hasStarOf(date: string): boolean { return !!infoMap.value.get(date)?.hasMilestone }
|
|
|
|
function prevMonth() { monthRef.value = addMonths(monthRef.value, -1) }
|
|
function nextMonth() { monthRef.value = addMonths(monthRef.value, 1) }
|
|
function goToday() { monthRef.value = todayStr() }
|
|
|
|
const selected = ref<string | null>(null)
|
|
function selectDay(date: string, inMonth: boolean) {
|
|
if (inMonth) selected.value = date
|
|
}
|
|
|
|
// 全局搜索命中工作记录后跳转并打开当日抽屉
|
|
watch(focusDate, d => {
|
|
if (!d) return
|
|
monthRef.value = d
|
|
selected.value = d
|
|
focusDate.value = null
|
|
}, { immediate: true })
|
|
</script>
|
|
<template>
|
|
<div class="cal-page">
|
|
<div class="cal-toolbar">
|
|
<button class="icon-btn" title="上一月" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
|
|
<span class="cal-title num">{{ title }}</span>
|
|
<button class="icon-btn" title="下一月" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
|
|
<button class="btn btn-sm" :disabled="monthRef === t" @click="goToday">回到本月</button>
|
|
</div>
|
|
|
|
<div class="card cal-card">
|
|
<div class="cal-head">
|
|
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week">{{ w }}</span>
|
|
</div>
|
|
<div class="cal-grid">
|
|
<button
|
|
v-for="(c, idx) in cells" :key="idx"
|
|
class="cal-cell"
|
|
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date, 'is-today': c.date === t }"
|
|
:title="c.inMonth ? cellTitle(c.date) : undefined"
|
|
@click="selectDay(c.date, c.inMonth)"
|
|
>
|
|
<template v-if="c.inMonth">
|
|
<span class="daynum num" :class="{ today: c.date === t }">{{ Number(c.date.slice(8, 10)) }}</span>
|
|
<span class="cal-marks">
|
|
<span v-if="statusOf(c.date)" class="dot" :class="statusOf(c.date)"></span>
|
|
<Icon v-if="hasLogOf(c.date)" name="check-circle" :size="12" class="mark-log" />
|
|
<Icon v-if="hasStarOf(c.date)" name="star" :size="12" class="mark-star" />
|
|
</span>
|
|
</template>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<transition name="drawer">
|
|
<aside v-if="selected" class="drawer">
|
|
<div class="drawer-head">
|
|
<span class="drawer-title">当日详情</span>
|
|
<button class="icon-btn" @click="selected = null"><Icon name="close" :size="16" /></button>
|
|
</div>
|
|
<div class="drawer-body">
|
|
<DayPanel :date="selected" />
|
|
</div>
|
|
</aside>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
<style scoped>
|
|
.cal-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
|
.cal-title { font-size: 14px; font-weight: 600; min-width: 96px; text-align: center; }
|
|
|
|
.cal-card { padding: 0; overflow: hidden; }
|
|
.cal-head, .cal-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 1px;
|
|
background: var(--border);
|
|
}
|
|
.cal-week {
|
|
background: var(--bg-subtle);
|
|
text-align: center;
|
|
padding: 7px 0;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--text-3);
|
|
}
|
|
.cal-cell {
|
|
min-height: 92px;
|
|
padding: 6px 8px;
|
|
text-align: left;
|
|
background: var(--bg-page);
|
|
border: none;
|
|
cursor: pointer;
|
|
display: block;
|
|
font-size: 13px;
|
|
transition: background 0.12s;
|
|
}
|
|
.cal-cell:hover { background: var(--bg-subtle); }
|
|
.cal-cell.blank { cursor: default; background: var(--bg-subtle); }
|
|
.cal-cell.is-today { background: #F5F9FF; }
|
|
.cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
|
|
.daynum.today { color: var(--accent); font-weight: 600; }
|
|
.cal-cell.weekend .daynum { color: var(--text-3); }
|
|
.cal-marks { display: flex; align-items: center; gap: 4px; margin-top: 8px; height: 14px; }
|
|
.mark-log { color: var(--text-3); }
|
|
.mark-star { color: var(--accent); }
|
|
|
|
.drawer {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: min(440px, 100%);
|
|
background: var(--bg-page);
|
|
border-left: 1px solid var(--border);
|
|
box-shadow: var(--shadow-float);
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.drawer-head {
|
|
height: 48px;
|
|
flex: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.drawer-title { font-weight: 600; }
|
|
.drawer-body { flex: 1; overflow: auto; padding: 16px; }
|
|
|
|
.drawer-enter-active, .drawer-leave-active { transition: opacity 0.15s ease; }
|
|
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
|
|
|
|
@media (max-width: 720px) {
|
|
.cal-title { min-width: 76px; font-size: 13px; }
|
|
.drawer { width: 100%; }
|
|
}
|
|
</style>
|