指定时刻用日期/时刻分栏与常用芯片替代原生 datetime-local,并收紧布局与左对齐模式切换。 Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
8.9 KiB
TypeScript
300 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { CalendarDays, Clock3 } from "lucide-react";
|
||
|
||
/** 发帖截止时间:快捷天数 + 指定时刻(分栏 + 快捷芯片) */
|
||
|
||
export type DeadlineMode = "preset" | "custom";
|
||
|
||
function pad2(n: number) {
|
||
return String(n).padStart(2, "0");
|
||
}
|
||
|
||
/** datetime-local 值(本地时区) */
|
||
export function toLocalInputValue(d: Date): string {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
}
|
||
|
||
/** 默认自定义:从现在起 +N 天 */
|
||
export function defaultCustomLocal(days = 7): string {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + days);
|
||
d.setHours(18, 0, 0, 0);
|
||
return toLocalInputValue(d);
|
||
}
|
||
|
||
/** 本地 datetime-local → RFC3339(UTC) */
|
||
export function localInputToRFC3339(local: string): string | null {
|
||
const t = Date.parse(local);
|
||
if (!Number.isFinite(t)) return null;
|
||
return new Date(t).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||
}
|
||
|
||
export function formatLocalPreview(local: string): string {
|
||
const t = Date.parse(local);
|
||
if (!Number.isFinite(t)) return "";
|
||
try {
|
||
return new Date(t).toLocaleString("zh-CN", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
weekday: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
});
|
||
} catch {
|
||
return local;
|
||
}
|
||
}
|
||
|
||
function splitLocal(v: string): { date: string; time: string } {
|
||
const [date = "", rawTime = ""] = (v || "").split("T");
|
||
const time = (rawTime || "18:00").slice(0, 5);
|
||
return { date, time: /^\d{2}:\d{2}$/.test(time) ? time : "18:00" };
|
||
}
|
||
|
||
function joinLocal(date: string, time: string): string {
|
||
if (!date) return "";
|
||
const t = /^\d{2}:\d{2}$/.test(time) ? time : "18:00";
|
||
return `${date}T${t}`;
|
||
}
|
||
|
||
function startOfDay(d: Date): Date {
|
||
const x = new Date(d);
|
||
x.setHours(0, 0, 0, 0);
|
||
return x;
|
||
}
|
||
|
||
function withTime(base: Date, hours: number, minutes: number): Date {
|
||
const d = new Date(base);
|
||
d.setHours(hours, minutes, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
/** 至少晚于当前 1 小时;不满足则推到合法时刻 */
|
||
function ensureMinLead(d: Date): Date {
|
||
const min = new Date(Date.now() + 60 * 60 * 1000);
|
||
min.setSeconds(0, 0);
|
||
return d.getTime() < min.getTime() ? min : d;
|
||
}
|
||
|
||
type QuickSet = { id: string; label: string; build: () => Date };
|
||
|
||
function buildQuickSets(): QuickSet[] {
|
||
const now = new Date();
|
||
return [
|
||
{
|
||
id: "tonight",
|
||
label: "今晚 22:00",
|
||
build: () => ensureMinLead(withTime(now, 22, 0)),
|
||
},
|
||
{
|
||
id: "tomorrow-noon",
|
||
label: "明天中午",
|
||
build: () => {
|
||
const d = new Date(now);
|
||
d.setDate(d.getDate() + 1);
|
||
return withTime(d, 12, 0);
|
||
},
|
||
},
|
||
{
|
||
id: "plus3-eve",
|
||
label: "3 天后晚",
|
||
build: () => {
|
||
const d = new Date(now);
|
||
d.setDate(d.getDate() + 3);
|
||
return withTime(d, 21, 0);
|
||
},
|
||
},
|
||
{
|
||
id: "sunday-eve",
|
||
label: "本周日晚",
|
||
build: () => {
|
||
const d = startOfDay(now);
|
||
const day = d.getDay(); // 0=日
|
||
const add = day === 0 ? 7 : 7 - day;
|
||
d.setDate(d.getDate() + add);
|
||
return ensureMinLead(withTime(d, 21, 0));
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
const TIME_CHIPS = ["09:00", "12:00", "18:00", "21:00", "22:00", "23:59"] as const;
|
||
|
||
export default function DeadlinePicker({
|
||
label = "截止时间",
|
||
presets,
|
||
mode,
|
||
onModeChange,
|
||
days,
|
||
onDaysChange,
|
||
customLocal,
|
||
onCustomLocalChange,
|
||
disabled,
|
||
hint,
|
||
}: {
|
||
label?: string;
|
||
presets: readonly number[];
|
||
mode: DeadlineMode;
|
||
onModeChange: (m: DeadlineMode) => void;
|
||
days: number;
|
||
onDaysChange: (d: number) => void;
|
||
customLocal: string;
|
||
onCustomLocalChange: (v: string) => void;
|
||
disabled?: boolean;
|
||
hint?: string;
|
||
}) {
|
||
const minDate = toLocalInputValue(new Date()).slice(0, 10);
|
||
const maxDate = toLocalInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)).slice(0, 10);
|
||
const { date, time } = splitLocal(customLocal);
|
||
const quickSets = buildQuickSets();
|
||
|
||
const applyCustom = (next: Date) => {
|
||
onCustomLocalChange(toLocalInputValue(ensureMinLead(next)));
|
||
};
|
||
|
||
const patchDate = (nextDate: string) => {
|
||
onCustomLocalChange(joinLocal(nextDate, time || "18:00"));
|
||
};
|
||
|
||
const patchTime = (nextTime: string) => {
|
||
const baseDate = date || toLocalInputValue(new Date()).slice(0, 10);
|
||
onCustomLocalChange(joinLocal(baseDate, nextTime));
|
||
};
|
||
|
||
const enterCustom = () => {
|
||
onModeChange("custom");
|
||
if (!customLocal) onCustomLocalChange(defaultCustomLocal(days || 7));
|
||
};
|
||
|
||
return (
|
||
<div className="j13-deadline-picker">
|
||
<div className="j13-deadline-picker-head">
|
||
<div className="j13-deadline-picker-label">{label}</div>
|
||
<div className="j13-deadline-picker-modes" role="tablist" aria-label={label}>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === "preset"}
|
||
className={`j13-deadline-mode${mode === "preset" ? " is-on" : ""}`}
|
||
disabled={disabled}
|
||
onClick={() => onModeChange("preset")}
|
||
>
|
||
快捷时长
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === "custom"}
|
||
className={`j13-deadline-mode${mode === "custom" ? " is-on" : ""}`}
|
||
disabled={disabled}
|
||
onClick={enterCustom}
|
||
>
|
||
指定时刻
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{mode === "preset" ? (
|
||
<div className="j13-deadline-presets" role="group" aria-label="快捷天数">
|
||
{presets.map((d) => (
|
||
<button
|
||
key={d}
|
||
type="button"
|
||
className={`j13-deadline-chip${days === d ? " is-on" : ""}`}
|
||
disabled={disabled}
|
||
aria-pressed={days === d}
|
||
onClick={() => onDaysChange(d)}
|
||
>
|
||
{d} 天
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="j13-deadline-custom">
|
||
<div className="j13-deadline-fields">
|
||
<label className="j13-deadline-field">
|
||
<span className="j13-deadline-field-icon" aria-hidden>
|
||
<CalendarDays size={15} strokeWidth={2} />
|
||
</span>
|
||
<span className="j13-deadline-field-meta">
|
||
<span className="j13-deadline-field-label">日期</span>
|
||
<input
|
||
type="date"
|
||
className="j13-deadline-field-input"
|
||
value={date}
|
||
min={minDate}
|
||
max={maxDate}
|
||
disabled={disabled}
|
||
onChange={(e) => patchDate(e.target.value)}
|
||
aria-label="截止日期"
|
||
/>
|
||
</span>
|
||
</label>
|
||
<label className="j13-deadline-field">
|
||
<span className="j13-deadline-field-icon" aria-hidden>
|
||
<Clock3 size={15} strokeWidth={2} />
|
||
</span>
|
||
<span className="j13-deadline-field-meta">
|
||
<span className="j13-deadline-field-label">时刻</span>
|
||
<input
|
||
type="time"
|
||
className="j13-deadline-field-input"
|
||
value={time}
|
||
disabled={disabled}
|
||
onChange={(e) => patchTime(e.target.value)}
|
||
aria-label="截止时刻"
|
||
/>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="j13-deadline-quick" role="group" aria-label="快捷设到">
|
||
<span className="j13-deadline-quick-label">快捷</span>
|
||
<div className="j13-deadline-quick-chips">
|
||
{quickSets.map((q) => (
|
||
<button
|
||
key={q.id}
|
||
type="button"
|
||
className="j13-deadline-chip j13-deadline-chip-sm"
|
||
disabled={disabled}
|
||
onClick={() => applyCustom(q.build())}
|
||
>
|
||
{q.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="j13-deadline-quick" role="group" aria-label="常用时刻">
|
||
<span className="j13-deadline-quick-label">时刻</span>
|
||
<div className="j13-deadline-quick-chips">
|
||
{TIME_CHIPS.map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
className={`j13-deadline-chip j13-deadline-chip-sm${time === t ? " is-on" : ""}`}
|
||
disabled={disabled || !date}
|
||
aria-pressed={time === t}
|
||
onClick={() => patchTime(t)}
|
||
>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{customLocal ? (
|
||
<p className="j13-deadline-preview">
|
||
将于 <strong>{formatLocalPreview(customLocal)}</strong> 截止
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{hint ? <p className="j13-deadline-hint">{hint}</p> : null}
|
||
</div>
|
||
);
|
||
}
|