Files
jiang13-bbs/frontend/components/DeadlinePicker.tsx
freefire 8024f54f8c polish(compose): 截止时间控件改为分栏与快捷芯片
指定时刻用日期/时刻分栏与常用芯片替代原生 datetime-local,并收紧布局与左对齐模式切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:50:52 +08:00

300 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}