初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects } from '../db/repo'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { CATEGORIES } from '../db/schema'
|
||||
import { addDays, todayStr, weekStart } from '../lib/date'
|
||||
import { attendanceChanged } from '../lib/bus'
|
||||
|
||||
const cards = ref({ attendDays: 0, totalHours: '0', itemCount: 0, streak: 0 })
|
||||
const heat = ref<{ date: string; count: number }[][]>([])
|
||||
const catStats = ref<{ key: string; label: string; count: number; percent: number }[]>([])
|
||||
const projStats = ref<{ name: string; count: number; percent: number }[]>([])
|
||||
const locStats = ref<{ name: string; count: number; percent: number }[]>([])
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
const t = todayStr()
|
||||
const start = '2000-01-01'
|
||||
const results = await Promise.all([
|
||||
getWorkRule(),
|
||||
listAttendanceBetween(start, t),
|
||||
listLogItemsBetween(start, t),
|
||||
listProjects(true)
|
||||
])
|
||||
const rule = results[0]
|
||||
const atts = results[1]
|
||||
const items = results[2]
|
||||
const projects = results[3]
|
||||
|
||||
const valid = atts.filter(a => a.clock_in_at || a.kind === 'makeup')
|
||||
let worked = 0
|
||||
for (const a of valid) {
|
||||
const info = computeDay(a, a.date, rule, a.date === t)
|
||||
worked += info.workedMinutes ?? 0
|
||||
}
|
||||
const dateSet = new Set(items.map(i => i.date))
|
||||
let streak = 0
|
||||
let cursor = t
|
||||
if (!dateSet.has(cursor)) cursor = addDays(t, -1)
|
||||
while (dateSet.has(cursor)) {
|
||||
streak++
|
||||
cursor = addDays(cursor, -1)
|
||||
}
|
||||
cards.value = {
|
||||
attendDays: valid.length,
|
||||
totalHours: (worked / 60).toFixed(1),
|
||||
itemCount: items.length,
|
||||
streak
|
||||
}
|
||||
|
||||
const lcount = new Map<string, number>()
|
||||
for (const a of valid) {
|
||||
const name = a.location || '未记录'
|
||||
lcount.set(name, (lcount.get(name) || 0) + 1)
|
||||
}
|
||||
locStats.value = Array.from(lcount.entries())
|
||||
.map(pair => ({ name: pair[0], count: pair[1], percent: Math.round((pair[1] / (valid.length || 1)) * 100) }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const total = items.length || 1
|
||||
catStats.value = CATEGORIES
|
||||
.map(c => {
|
||||
const count = items.filter(i => i.category === c.key).length
|
||||
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
|
||||
})
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
|
||||
const pcount = new Map<string, number>()
|
||||
for (const it of items) {
|
||||
const name = it.project_id ? (pname.get(it.project_id) || '已删除项目') : '无项目'
|
||||
pcount.set(name, (pcount.get(name) || 0) + 1)
|
||||
}
|
||||
projStats.value = Array.from(pcount.entries())
|
||||
.map(pair => ({ name: pair[0], count: pair[1], percent: Math.round((pair[1] / total) * 100) }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
const countMap = new Map<string, number>()
|
||||
for (const it of items) countMap.set(it.date, (countMap.get(it.date) || 0) + 1)
|
||||
const thisWeek = weekStart(t)
|
||||
const weeks: { date: string; count: number }[][] = []
|
||||
for (let w = 11; w >= 0; w--) {
|
||||
const ws = addDays(thisWeek, -7 * w)
|
||||
const col: { date: string; count: number }[] = []
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const date = addDays(ws, d)
|
||||
col.push({ date, count: countMap.get(date) || 0 })
|
||||
}
|
||||
weeks.push(col)
|
||||
}
|
||||
heat.value = weeks
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
})
|
||||
// 打卡、休假等考勤变更后实时刷新统计
|
||||
watch(attendanceChanged, () => { loadAll() })
|
||||
|
||||
function level(count: number): number {
|
||||
if (count === 0) return 0
|
||||
if (count === 1) return 1
|
||||
if (count <= 3) return 2
|
||||
if (count <= 5) return 3
|
||||
return 4
|
||||
}
|
||||
function heatTitle(date: string, count: number): string {
|
||||
return date + ' · ' + (count ? count + ' 条' : '无记录')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
<div class="cards">
|
||||
<div class="card stat-card"><div class="muted">出勤天数</div><div class="stat-num num">{{ cards.attendDays }}</div></div>
|
||||
<div class="card stat-card"><div class="muted">累计工时(小时)</div><div class="stat-num num">{{ cards.totalHours }}</div></div>
|
||||
<div class="card stat-card"><div class="muted">记录条数</div><div class="stat-num num">{{ cards.itemCount }}</div></div>
|
||||
<div class="card stat-card"><div class="muted">连续记录(天)</div><div class="stat-num num">{{ cards.streak }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">记录热力图(近 12 周)</div>
|
||||
<div class="heat">
|
||||
<div v-for="(col, ci) in heat" :key="ci" class="heat-col">
|
||||
<span
|
||||
v-for="cell in col" :key="cell.date"
|
||||
class="heat-cell" :class="'lv' + level(cell.count)"
|
||||
:title="heatTitle(cell.date, cell.count)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heat-legend muted">
|
||||
<span>少</span>
|
||||
<span class="heat-cell lv0"></span>
|
||||
<span class="heat-cell lv1"></span>
|
||||
<span class="heat-cell lv2"></span>
|
||||
<span class="heat-cell lv3"></span>
|
||||
<span class="heat-cell lv4"></span>
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<div class="card section">
|
||||
<div class="section-title">分类分布</div>
|
||||
<div v-for="c in catStats" :key="c.key" class="bar-row">
|
||||
<span class="bar-label">{{ c.label }}</span>
|
||||
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
|
||||
<span class="muted num bar-num">{{ c.count }} 条 · {{ c.percent }}%</span>
|
||||
</div>
|
||||
<div v-if="!catStats.length" class="muted">暂无数据</div>
|
||||
</div>
|
||||
<div class="card section">
|
||||
<div class="section-title">项目投入</div>
|
||||
<div v-for="p in projStats" :key="p.name" class="bar-row">
|
||||
<span class="bar-label">{{ p.name }}</span>
|
||||
<span class="bar-track"><span class="bar-fill" :style="{ width: p.percent + '%' }"></span></span>
|
||||
<span class="muted num bar-num">{{ p.count }} 条 · {{ p.percent }}%</span>
|
||||
</div>
|
||||
<div v-if="!projStats.length" class="muted">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">地点分布(按出勤天)</div>
|
||||
<div v-for="l in locStats" :key="l.name" class="bar-row">
|
||||
<span class="bar-label loc-label">{{ l.name }}</span>
|
||||
<span class="bar-track"><span class="bar-fill" :style="{ width: l.percent + '%' }"></span></span>
|
||||
<span class="muted num bar-num">{{ l.count }} 天 · {{ l.percent }}%</span>
|
||||
</div>
|
||||
<div v-if="!locStats.length" class="muted">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.stat-card { padding: 14px 16px; }
|
||||
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
|
||||
|
||||
.section { padding: 14px 16px; margin-top: 12px; }
|
||||
.section-title { font-weight: 600; margin-bottom: 10px; font-size: 13px; }
|
||||
|
||||
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
|
||||
.heat-col { display: flex; flex-direction: column; gap: 3px; }
|
||||
.heat-cell { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
|
||||
.lv0 { background: var(--bg-subtle); border: 1px solid var(--border); }
|
||||
.lv1 { background: #D8ECDF; }
|
||||
.lv2 { background: #A9D8BC; }
|
||||
.lv3 { background: #6FBF92; }
|
||||
.lv4 { background: var(--ok); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 3px; margin-top: 8px; font-size: 12px; }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
|
||||
.bar-label { width: 72px; flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
|
||||
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
|
||||
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
|
||||
.bar-num { font-size: 12px; width: 92px; flex: none; }
|
||||
.loc-label { width: 110px; text-align: left; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user