video-shuoshan/web/src/components/DatePicker.tsx
seaislee1209 85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip
v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复
v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs)

审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等),
记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:18:44 +08:00

148 lines
5.3 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import styles from './DatePicker.module.css';
interface DatePickerProps {
value: string; // 'YYYY-MM-DD' or ''
onChange: (value: string) => void;
placeholder?: string;
}
export function DatePicker({ value, onChange, placeholder = '选择日期' }: DatePickerProps) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const today = new Date();
const selected = value ? new Date(value + 'T00:00:00') : null;
const [viewYear, setViewYear] = useState(selected?.getFullYear() ?? today.getFullYear());
const [viewMonth, setViewMonth] = useState(selected?.getMonth() ?? today.getMonth());
// Close on outside click
useEffect(() => {
if (!open) return;
const handle = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [open]);
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
else setViewMonth(viewMonth + 1);
};
// Build calendar grid
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
const cells: { day: number; month: number; year: number; isOther: boolean }[] = [];
// Previous month padding
for (let i = firstDay - 1; i >= 0; i--) {
const m = viewMonth === 0 ? 11 : viewMonth - 1;
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
cells.push({ day: daysInPrevMonth - i, month: m, year: y, isOther: true });
}
// Current month
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ day: d, month: viewMonth, year: viewYear, isOther: false });
}
// Next month padding
const remaining = 42 - cells.length;
for (let d = 1; d <= remaining; d++) {
const m = viewMonth === 11 ? 0 : viewMonth + 1;
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
cells.push({ day: d, month: m, year: y, isOther: true });
}
const fmt = (y: number, m: number, d: number) =>
`${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const todayStr = fmt(today.getFullYear(), today.getMonth(), today.getDate());
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
return (
<div className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.trigger}
onClick={() => {
if (!open && selected) {
setViewYear(selected.getFullYear());
setViewMonth(selected.getMonth());
}
setOpen(!open);
}}
>
<svg className={styles.calendarIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{value ? (
<span>{value}</span>
) : (
<span className={styles.placeholder}>{placeholder}</span>
)}
{value && (
<button
type="button"
className={styles.clearBtn}
onClick={(e) => { e.stopPropagation(); onChange(''); setOpen(false); }}
title="清除"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</button>
{open && (
<div className={styles.dropdown}>
<div className={styles.header}>
<button type="button" className={styles.navBtn} onClick={prevMonth}>&lt;</button>
<span className={styles.monthLabel}>{viewYear} {monthNames[viewMonth]}</span>
<button type="button" className={styles.navBtn} onClick={nextMonth}>&gt;</button>
</div>
<div className={styles.weekRow}>
{weekDays.map((d) => <span key={d} className={styles.weekDay}>{d}</span>)}
</div>
<div className={styles.daysGrid}>
{cells.map((c, i) => {
const dateStr = fmt(c.year, c.month, c.day);
const isToday = dateStr === todayStr;
const isSelected = dateStr === value;
return (
<button
key={i}
type="button"
className={[
styles.dayCell,
c.isOther ? styles.otherMonth : '',
isToday ? styles.today : '',
isSelected ? styles.selected : '',
].join(' ')}
onClick={() => {
onChange(dateStr);
setOpen(false);
}}
>
{c.day}
</button>
);
})}
</div>
</div>
)}
</div>
);
}