初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)

This commit is contained in:
root
2026-10-15 23:55:27 +08:00
commit cfb984627d
50 changed files with 9223 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
<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" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
<span class="cal-title num">{{ title }}</span>
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" @click="goToday">回到今天</button>
</div>
<div class="cal-head">
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week muted">{{ 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 }"
: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>
<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-head {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.cal-week {
text-align: center;
padding: 6px 0;
font-size: 12px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.cal-cell {
min-height: 92px;
padding: 6px 8px;
text-align: left;
background: var(--bg-page);
border: none;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
cursor: pointer;
display: block;
font-size: 13px;
}
.cal-cell:hover { background: var(--bg-subtle); }
.cal-cell.blank { cursor: default; }
.cal-cell.blank:hover { background: var(--bg-page); }
.cal-cell.selected { background: var(--accent-bg); }
.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(420px, 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; }
</style>