Files
daywork/intern-log/src/views/StatsView.vue
T

207 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>