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>
148 lines
5.3 KiB
TypeScript
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}><</button>
|
|
<span className={styles.monthLabel}>{viewYear}年 {monthNames[viewMonth]}</span>
|
|
<button type="button" className={styles.navBtn} onClick={nextMonth}>></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>
|
|
);
|
|
}
|