@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user