diff --git a/src/web/pages/CheckinLog.tsx b/src/web/pages/CheckinLog.tsx index b335c4b..4d43f8e 100644 --- a/src/web/pages/CheckinLog.tsx +++ b/src/web/pages/CheckinLog.tsx @@ -1,13 +1,16 @@ -import { useEffect, useState } from 'react'; -import { api } from '../api.js'; -import { MobileCard, MobileField } from '../components/MobileCard.js'; -import { MobileDrawer } from '../components/MobileDrawer.js'; -import { useToast } from '../components/Toast.js'; -import { useIsMobile } from '../components/useIsMobile.js'; -import { formatCheckinLogTime } from './helpers/checkinLogTime.js'; -import { tr } from '../i18n.js'; +import { useEffect, useMemo, useState } from "react"; +import { api } from "../api.js"; +import { MobileCard, MobileField } from "../components/MobileCard.js"; +import { MobileDrawer } from "../components/MobileDrawer.js"; +import { useToast } from "../components/Toast.js"; +import { useIsMobile } from "../components/useIsMobile.js"; +import { + formatCheckinLogTime, + parseServerUtcDateTime, +} from "./helpers/checkinLogTime.js"; +import { tr } from "../i18n.js"; -type LogFilter = 'all' | 'success' | 'failed' | 'skipped'; +type LogFilter = "all" | "success" | "failed" | "skipped"; type FailureReason = { code: string; @@ -17,23 +20,131 @@ type FailureReason = { detailHint: string; }; +function pad2(value: number) { + return String(value).padStart(2, "0"); +} + +function formatDateTimeInputValue(value: Date) { + return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}T${pad2(value.getHours())}:${pad2(value.getMinutes())}`; +} + +function getTodayTimeRangeInput(now = new Date()): { + from: string; + to: string; +} { + const start = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 0, + 0, + 0, + 0, + ); + const end = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 23, + 59, + 0, + 0, + ); + return { + from: formatDateTimeInputValue(start), + to: formatDateTimeInputValue(end), + }; +} + +function parseLocalDateTimeInput(value: string): Date | null { + const text = value.trim(); + if (!text) return null; + const parsed = new Date(text); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + export default function CheckinLog() { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [triggering, setTriggering] = useState(false); - const [filter, setFilter] = useState('all'); + const [filter, setFilter] = useState("all"); const [expandedLogId, setExpandedLogId] = useState(null); const [showFilters, setShowFilters] = useState(false); const isMobile = useIsMobile(768); const toast = useToast(); + function getStatus(log: any): "success" | "failed" | "skipped" { + const raw = (log.checkin_logs?.status || log.status || "failed") as string; + if (raw === "success" || raw === "skipped") return raw; + return "failed"; + } + + function getCreatedAtDate(log: any): Date | null { + const createdAt = log.checkin_logs?.createdAt || log.createdAt; + return parseServerUtcDateTime(createdAt); + } + + const initialTimeRange = useMemo(() => getTodayTimeRangeInput(), []); + const [fromInput, setFromInput] = useState(initialTimeRange.from); + const [toInput, setToInput] = useState(initialTimeRange.to); + + const fromRaw = useMemo( + () => parseLocalDateTimeInput(fromInput), + [fromInput], + ); + const toRaw = useMemo(() => parseLocalDateTimeInput(toInput), [toInput]); + const fromMs = fromRaw?.getTime() ?? null; + const toMs = toRaw?.getTime() ?? null; + const toExclusiveMs = toMs === null ? null : toMs + 60_000; + + const hasInvalidTimeRange = Boolean( + fromMs !== null && toMs !== null && fromMs >= toMs, + ); + + const timeFilteredLogs = useMemo(() => { + if (hasInvalidTimeRange) return []; + + return logs.filter((log) => { + const createdAtDate = getCreatedAtDate(log); + if (!createdAtDate) return false; + + const createdAtMs = createdAtDate.getTime(); + if (fromMs !== null && createdAtMs < fromMs) return false; + if (toExclusiveMs !== null && createdAtMs >= toExclusiveMs) return false; + return true; + }); + }, [fromMs, hasInvalidTimeRange, logs, toExclusiveMs]); + + const statusFilteredLogs = useMemo( + () => + filter === "all" + ? timeFilteredLogs + : timeFilteredLogs.filter((log) => getStatus(log) === filter), + [filter, timeFilteredLogs], + ); + + // 兼容旧命名:后面渲染统一用 filtered + const filtered = statusFilteredLogs; + + const countBy = useMemo( + () => (target: Exclude) => + timeFilteredLogs.filter((log) => getStatus(log) === target).length, + [timeFilteredLogs], + ); + + const clearTimeRange = () => { + setFromInput(""); + setToInput(""); + }; + const load = async () => { setLoading(true); try { - const data = await api.getCheckinLogs('limit=100'); + const data = await api.getCheckinLogs("limit=100"); setLogs(Array.isArray(data) ? data : []); } catch (e: any) { - toast.error(e.message || '加载签到记录失败'); + toast.error(e.message || "加载签到记录失败"); } finally { setLoading(false); } @@ -48,38 +159,28 @@ export default function CheckinLog() { try { const res = await api.triggerCheckinAll(); if (res?.queued) { - toast.info(res.message || '已开始签到,请稍后查看签到记录'); + toast.info(res.message || "已开始签到,请稍后查看签到记录"); } else { - toast.success(res?.message || '签到已执行'); + toast.success(res?.message || "签到已执行"); } await load(); } catch (e: any) { - toast.error(e.message || '触发签到失败'); + toast.error(e.message || "触发签到失败"); } finally { setTriggering(false); } }; - const getStatus = (log: any): 'success' | 'failed' | 'skipped' => { - const raw = (log.checkin_logs?.status || log.status || 'failed') as string; - if (raw === 'success' || raw === 'skipped') return raw; - return 'failed'; + const statusLabel = (status: "success" | "failed" | "skipped") => { + if (status === "success") return "成功"; + if (status === "skipped") return "跳过"; + return "失败"; }; - const filtered = filter === 'all' ? logs : logs.filter((log) => getStatus(log) === filter); - - const countBy = (target: Exclude) => logs.filter((log) => getStatus(log) === target).length; - - const statusLabel = (status: 'success' | 'failed' | 'skipped') => { - if (status === 'success') return '成功'; - if (status === 'skipped') return '跳过'; - return '失败'; - }; - - const statusClass = (status: 'success' | 'failed' | 'skipped') => { - if (status === 'success') return 'badge-success'; - if (status === 'skipped') return 'badge-muted'; - return 'badge-error'; + const statusClass = (status: "success" | "failed" | "skipped") => { + if (status === "success") return "badge-success"; + if (status === "skipped") return "badge-muted"; + return "badge-error"; }; const getFailureReason = (log: any): FailureReason | null => { @@ -88,14 +189,60 @@ export default function CheckinLog() { return reason; }; + const timeRangeControls = ( +
+ + + +
+ ); + const filterTabs = ( -
- {(['all', 'success', 'failed', 'skipped'] as const).map((tab) => ( - ))}
@@ -104,7 +251,7 @@ export default function CheckinLog() { return (
-

{tr('签到记录')}

+

{tr("签到记录")}

@@ -127,27 +274,67 @@ export default function CheckinLog() {
- setShowFilters(false)}> -
+ setShowFilters(false)} + > +
+ {timeRangeControls} + {hasInvalidTimeRange && ( +
+ 结束时间必须晚于开始时间 +
+ )} {filterTabs}
) : ( - filterTabs +
+
{filterTabs}
+
+ {timeRangeControls} +
+ {hasInvalidTimeRange && ( +
+ 结束时间必须晚于开始时间 +
+ )} +
)} -
+
{loading ? ( -
+
{[...Array(5)].map((_, i) => ( -
+
@@ -162,49 +349,74 @@ export default function CheckinLog() { {filtered.map((log: any) => { const status = getStatus(log); const reason = getFailureReason(log); - const isExpanded = expandedLogId === (log.checkin_logs?.id || log.id); + const isExpanded = + expandedLogId === (log.checkin_logs?.id || log.id); const logId = log.checkin_logs?.id || log.id; return ( + title={log.accounts?.username || "未知"} + actions={ + {statusLabel(status)} - )} + } > - + - - {log.sites?.name || '-'} + value={ + log.sites?.url ? ( + + + {log.sites?.name || "-"} + + + ) : ( + + {log.sites?.name || "-"} - - ) : ( - - {log.sites?.name || '-'} - - )} + ) + } /> - {reason.title} - - ) : ( - - - )} + value={ + reason ? ( + + {reason.title} + + ) : ( + - + ) + } + /> + - {isExpanded ? (
) : null} @@ -221,9 +433,11 @@ export default function CheckinLog() {
@@ -250,11 +464,18 @@ export default function CheckinLog() { const reason = getFailureReason(log); return ( - - {formatCheckinLogTime(log.checkin_logs?.createdAt || log.createdAt)} + + {formatCheckinLogTime( + log.checkin_logs?.createdAt || log.createdAt, + )} - - {log.accounts?.username || '未知'} + + {log.accounts?.username || "未知"} {log.sites?.url ? ( @@ -264,22 +485,33 @@ export default function CheckinLog() { rel="noopener noreferrer" className="badge-link" > - - {log.sites?.name || '-'} + + {log.sites?.name || "-"} ) : ( - - {log.sites?.name || '-'} + + {log.sites?.name || "-"} )} - {statusLabel(status)} + + {statusLabel(status)} + {reason ? ( - + {reason.title} ) : ( @@ -287,26 +519,33 @@ export default function CheckinLog() { )} - + {log.checkin_logs?.message || log.message} - {reason?.actionHint || '-'} + {reason?.actionHint || "-"} - {log.checkin_logs?.reward || '-'} + {log.checkin_logs?.reward || "-"} ); })} @@ -316,8 +555,18 @@ export default function CheckinLog() { {!loading && filtered.length === 0 && (
- - + +
暂无签到记录
点击“运行所有签到”开始执行
diff --git a/src/web/pages/helpers/checkinLogTime.ts b/src/web/pages/helpers/checkinLogTime.ts index 757d276..ac15a89 100644 --- a/src/web/pages/helpers/checkinLogTime.ts +++ b/src/web/pages/helpers/checkinLogTime.ts @@ -35,6 +35,10 @@ function parseServerUtcDate(value: string | null | undefined): { date: Date | nu return { date, raw }; } +export function parseServerUtcDateTime(value: string | null | undefined): Date | null { + return parseServerUtcDate(value).date; +} + function formatWithParts( value: string | null | undefined, options: Intl.DateTimeFormatOptions,