feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验

统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 10:21:05 +08:00
parent 6b772380c9
commit b2d3d6dd26
23 changed files with 2711 additions and 1007 deletions

View File

@@ -0,0 +1,130 @@
"use client";
/** 发帖截止时间:快捷天数 + 自定义时刻 */
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.setSeconds(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");
} catch {
return local;
}
}
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 minLocal = toLocalInputValue(new Date(Date.now() + 60 * 60 * 1000));
const maxLocal = toLocalInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000));
return (
<div className="j13-deadline-picker">
<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={() => {
onModeChange("custom");
if (!customLocal) onCustomLocalChange(defaultCustomLocal(days || 7));
}}
>
指定时刻
</button>
</div>
{mode === "preset" ? (
<div className="j13-deadline-presets">
{presets.map((d) => (
<button
key={d}
type="button"
className={`btn btn-sm ${days === d ? "btn-primary" : "btn-line"}`}
disabled={disabled}
onClick={() => onDaysChange(d)}
>
{d} 天
</button>
))}
</div>
) : (
<div className="j13-deadline-custom">
<input
type="datetime-local"
className="j13-compose-field j13-deadline-datetime"
value={customLocal}
min={minLocal}
max={maxLocal}
disabled={disabled}
onChange={(e) => onCustomLocalChange(e.target.value)}
aria-label="指定截止时刻"
/>
{customLocal ? (
<p className="meta text-[12.5px] !mb-0">将于 {formatLocalPreview(customLocal)} 截止</p>
) : null}
</div>
)}
{hint ? <p className="meta text-[12px] !mb-0">{hint}</p> : null}
</div>
);
}