新增签到日历筛选

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-16 20:49:21 +08:00
co-authored by Claude
parent d36455ce71
commit 8690265fa0
6 changed files with 275 additions and 5 deletions
+5
View File
@@ -40,6 +40,7 @@ import type {
PositionRecord,
PublicMapTileSourceResponse,
PublicMapTileSourcesResponse,
SignDayCount,
SignRecord,
SignRecordPayload,
TelemetryRecord,
@@ -173,6 +174,10 @@ export function getSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string
return getJSON<ListResponse<SignRecord>>(listPath('/api/signs', limit, offset, nodeIdOrOptions))
}
export function getSignDailyCounts(nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignDayCount>> {
return getJSON<ListResponse<SignDayCount>>(listPath('/api/signs/daily', 500, 0, nodeIdOrOptions))
}
export function getAdminSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignRecord>> {
return getJSON<ListResponse<SignRecord>>(listPath('/api/admin/signs', limit, offset, nodeIdOrOptions))
}
+126 -4
View File
@@ -1,19 +1,61 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getSignRecords } from '../api'
import { computed, onMounted, ref, watch } from 'vue'
import { getSignDailyCounts, getSignRecords } from '../api'
import type { SignRecord } from '../types'
const pageSize = 25
const records = ref<SignRecord[]>([])
const loading = ref(false)
const calendarLoading = ref(false)
const error = ref('')
const page = ref(1)
const total = ref(0)
const calendarMonth = ref(new Date())
const dailyCounts = ref<Record<string, number>>({})
const selectedDate = ref('')
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const calendarDays = computed(() => monthDays(calendarMonth.value))
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function formatMonth(value: Date): string {
return `${value.getFullYear()}${value.getMonth() + 1}`
}
function formatSelectedDate(value: string): string {
const [year, month, day] = value.split('-')
return `${year}${Number(month)}${Number(day)}`
}
function formatDateKey(value: Date): string {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function monthDays(value: Date): Array<string | null> {
const year = value.getFullYear()
const month = value.getMonth()
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const days: Array<string | null> = Array(firstDay).fill(null)
for (let day = 1; day <= daysInMonth; day += 1) {
days.push(formatDateKey(new Date(year, month, day)))
}
return days
}
function dateRangeForDay(value: string): { since: string; until: string } {
const [year, month, day] = value.split('-').map(Number)
const start = new Date(year, month - 1, day)
const end = new Date(year, month - 1, day, 23, 59, 59, 999)
return { since: start.toISOString(), until: end.toISOString() }
}
function canPrev(): boolean {
return page.value > 1
}
@@ -22,12 +64,31 @@ function canNext(): boolean {
return page.value * pageSize < total.value || records.value.length === pageSize
}
async function loadCalendar() {
calendarLoading.value = true
error.value = ''
try {
const year = calendarMonth.value.getFullYear()
const month = calendarMonth.value.getMonth()
const since = new Date(year, month, 1).toISOString()
const until = new Date(year, month + 1, 1).toISOString()
const response = await getSignDailyCounts({ since, until })
dailyCounts.value = Object.fromEntries(response.items.map((item) => [item.date, item.count]))
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
calendarLoading.value = false
}
}
async function loadRecords(nextPage = page.value) {
loading.value = true
error.value = ''
try {
const safePage = Math.max(1, nextPage)
const response = await getSignRecords(pageSize, (safePage - 1) * pageSize)
const response = selectedDate.value
? await getSignRecords(pageSize, (safePage - 1) * pageSize, dateRangeForDay(selectedDate.value))
: await getSignRecords(pageSize, (safePage - 1) * pageSize)
records.value = response.items
total.value = response.total ?? response.offset + response.items.length
page.value = safePage
@@ -38,11 +99,72 @@ async function loadRecords(nextPage = page.value) {
}
}
onMounted(() => loadRecords())
function previousMonth() {
calendarMonth.value = new Date(calendarMonth.value.getFullYear(), calendarMonth.value.getMonth() - 1, 1)
}
function nextMonth() {
calendarMonth.value = new Date(calendarMonth.value.getFullYear(), calendarMonth.value.getMonth() + 1, 1)
}
function selectDate(value: string) {
selectedDate.value = selectedDate.value === value ? '' : value
loadRecords(1)
}
function clearSelectedDate() {
selectedDate.value = ''
loadRecords(1)
}
watch(calendarMonth, () => loadCalendar())
onMounted(() => {
loadCalendar()
loadRecords()
})
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel signed-calendar-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Calendar</p>
<h2>签到日历</h2>
</div>
<span class="counter">{{ calendarLoading ? '加载中...' : formatMonth(calendarMonth) }}</span>
</div>
<div class="signed-calendar-toolbar">
<button class="admin-button ghost" :disabled="calendarLoading" @click="previousMonth">上一月</button>
<strong>{{ formatMonth(calendarMonth) }}</strong>
<button class="admin-button ghost" :disabled="calendarLoading" @click="nextMonth">下一月</button>
</div>
<div class="signed-calendar-grid">
<div v-for="weekday in weekdays" :key="weekday" class="signed-calendar-weekday">{{ weekday }}</div>
<template v-for="(day, index) in calendarDays" :key="day || `empty-${index}`">
<div v-if="!day" class="signed-calendar-day empty" aria-hidden="true"></div>
<button
v-else
type="button"
class="signed-calendar-day"
:class="{ selected: selectedDate === day, 'has-signs': dailyCounts[day] }"
@click="selectDate(day)"
>
<span class="signed-calendar-date">{{ Number(day.slice(8, 10)) }}</span>
<span v-if="dailyCounts[day]" class="signed-calendar-count">{{ dailyCounts[day] }} </span>
</button>
</template>
</div>
<div v-if="selectedDate" class="signed-filter-info">
<span>当前筛选{{ formatSelectedDate(selectedDate) }}</span>
<button class="admin-button ghost" :disabled="loading" @click="clearSelectedDate">清除筛选</button>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
+109 -1
View File
@@ -1232,6 +1232,98 @@ h3 {
transform: translateY(-1px);
}
.signed-calendar-panel {
overflow: visible;
}
.signed-calendar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.signed-calendar-toolbar strong {
color: var(--color-heading);
font-size: 18px;
}
.signed-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
padding: 0 16px 16px;
}
.signed-calendar-weekday {
padding: 8px 4px;
color: var(--color-muted);
text-align: center;
font-size: 12px;
font-weight: 700;
}
.signed-calendar-day {
min-height: 74px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 8px;
color: var(--color-text);
background: var(--color-surface-soft);
text-align: left;
transition: background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
button.signed-calendar-day:not(:disabled):hover {
border-color: var(--color-primary);
background: var(--color-primary-soft);
transform: translateY(-1px);
}
.signed-calendar-day.empty {
border-color: transparent;
background: transparent;
}
.signed-calendar-day.has-signs {
border-color: color-mix(in srgb, var(--color-primary) 42%, white);
background: var(--color-primary-soft);
}
.signed-calendar-day.selected {
border-color: var(--color-primary);
color: #fff;
background: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.signed-calendar-date,
.signed-calendar-count {
display: block;
}
.signed-calendar-date {
color: inherit;
font-weight: 800;
}
.signed-calendar-count {
margin-top: 8px;
font-size: 12px;
font-weight: 700;
}
.signed-filter-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--color-border);
color: var(--color-muted);
}
.pagination {
display: flex;
align-items: center;
@@ -1427,11 +1519,27 @@ dd {
.pagination,
.confirm-modal-actions,
.admin-session-card {
.admin-session-card,
.signed-filter-info {
align-items: stretch;
flex-direction: column;
}
.signed-calendar-toolbar {
align-items: stretch;
flex-direction: column;
text-align: center;
}
.signed-calendar-grid {
gap: 4px;
}
.signed-calendar-day {
min-height: 56px;
padding: 6px;
}
.map-container {
min-height: 360px;
}
+5
View File
@@ -158,6 +158,11 @@ export interface SignRecord {
sign_time: string
}
export interface SignDayCount {
date: string
count: number
}
export interface SignRecordPayload {
node_id: string
long_name: string