新增 AutoRDO 需求清洗工作台与消息中枢,迭代 OneOS V2 设计规范及租赁合同/工作台/车辆等原型,同步云效技能与导航注册;并归档一批 legacy 原型快照。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
219
src/common/oneos-v2-form/calendar/CalendarPanel.tsx
Normal file
219
src/common/oneos-v2-form/calendar/CalendarPanel.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
addMonths,
|
||||
buildMonthCells,
|
||||
formatISODate,
|
||||
formatMonthTitle,
|
||||
isDateInRange,
|
||||
isRangeEnd,
|
||||
isRangeStart,
|
||||
normalizeDateRange,
|
||||
parseISODate,
|
||||
sameCalendarDay,
|
||||
startOfMonth,
|
||||
WEEKDAY_LABELS,
|
||||
type DateRangeValue,
|
||||
} from './dateUtils';
|
||||
|
||||
function MonthPanel({
|
||||
monthDate,
|
||||
start,
|
||||
end,
|
||||
hover,
|
||||
selected,
|
||||
mode,
|
||||
onDayClick,
|
||||
onDayHover,
|
||||
}: {
|
||||
monthDate: Date;
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
hover: Date | null;
|
||||
selected: Date | null;
|
||||
mode: 'single' | 'range';
|
||||
onDayClick: (date: Date) => void;
|
||||
onDayHover: (date: Date | null) => void;
|
||||
}) {
|
||||
const cells = useMemo(() => buildMonthCells(monthDate), [monthDate]);
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
return (
|
||||
<div className="o2-cal__month">
|
||||
<div className="o2-cal__month-title">{formatMonthTitle(monthDate)}</div>
|
||||
<div className="o2-cal__weekdays" aria-hidden>
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<span key={label} className="o2-cal__weekday">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="o2-cal__days" role="grid">
|
||||
{cells.map((cell) => {
|
||||
const key = formatISODate(cell.date);
|
||||
const isToday = sameCalendarDay(cell.date, today);
|
||||
let classNames = ['o2-cal__day'];
|
||||
if (!cell.inMonth) classNames.push('is-outside');
|
||||
if (isToday) classNames.push('is-today');
|
||||
|
||||
if (mode === 'single') {
|
||||
if (selected && sameCalendarDay(cell.date, selected)) classNames.push('is-selected');
|
||||
} else {
|
||||
const selectedStart = isRangeStart(cell.date, start, end, hover);
|
||||
const selectedEnd = isRangeEnd(cell.date, start, end, hover);
|
||||
const inRange = isDateInRange(cell.date, start, end, hover);
|
||||
if (inRange) classNames.push('is-in-range');
|
||||
if (selectedStart) classNames.push('is-range-start');
|
||||
if (selectedEnd) classNames.push('is-range-end');
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={classNames.join(' ')}
|
||||
aria-label={key}
|
||||
aria-pressed={
|
||||
mode === 'single'
|
||||
? Boolean(selected && sameCalendarDay(cell.date, selected))
|
||||
: isRangeStart(cell.date, start, end, hover) ||
|
||||
isRangeEnd(cell.date, start, end, hover)
|
||||
}
|
||||
onMouseEnter={() => onDayHover(cell.date)}
|
||||
onMouseLeave={() => onDayHover(null)}
|
||||
onClick={() => onDayClick(cell.date)}
|
||||
>
|
||||
<span>{cell.date.getDate()}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function O2SingleCalendarPanel({
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const selected = parseISODate(value);
|
||||
const [viewMonth, setViewMonth] = useState(() => startOfMonth(selected || new Date()));
|
||||
|
||||
return (
|
||||
<div className="o2-cal o2-cal--single">
|
||||
<div className="o2-cal__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="o2-cal__nav-btn"
|
||||
aria-label="上一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, -1))}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="o2-cal__nav-btn"
|
||||
aria-label="下一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, 1))}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<MonthPanel
|
||||
monthDate={viewMonth}
|
||||
start={null}
|
||||
end={null}
|
||||
hover={null}
|
||||
selected={selected}
|
||||
mode="single"
|
||||
onDayHover={() => undefined}
|
||||
onDayClick={(date) => {
|
||||
onChange(formatISODate(date));
|
||||
onComplete?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function O2RangeCalendarPanel({
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
onComplete,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onChange: (range: DateRangeValue) => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const start = parseISODate(startDate);
|
||||
const end = parseISODate(endDate);
|
||||
const [viewMonth, setViewMonth] = useState(() => startOfMonth(start || end || new Date()));
|
||||
const [hoverDate, setHoverDate] = useState<Date | null>(null);
|
||||
|
||||
const leftMonth = viewMonth;
|
||||
const rightMonth = addMonths(viewMonth, 1);
|
||||
|
||||
const handleDayClick = (date: Date) => {
|
||||
const iso = formatISODate(date);
|
||||
if (!startDate || (startDate && endDate)) {
|
||||
onChange({ startDate: iso, endDate: '' });
|
||||
return;
|
||||
}
|
||||
const next = normalizeDateRange(startDate, iso);
|
||||
onChange(next);
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="o2-cal o2-cal--range">
|
||||
<div className="o2-cal__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="o2-cal__nav-btn"
|
||||
aria-label="上一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, -1))}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="o2-cal__nav-btn"
|
||||
aria-label="下一月"
|
||||
onClick={() => setViewMonth((prev) => addMonths(prev, 1))}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="o2-cal__body">
|
||||
<MonthPanel
|
||||
monthDate={leftMonth}
|
||||
start={start}
|
||||
end={end}
|
||||
hover={hoverDate}
|
||||
selected={null}
|
||||
mode="range"
|
||||
onDayClick={handleDayClick}
|
||||
onDayHover={setHoverDate}
|
||||
/>
|
||||
<MonthPanel
|
||||
monthDate={rightMonth}
|
||||
start={start}
|
||||
end={end}
|
||||
hover={hoverDate}
|
||||
selected={null}
|
||||
mode="range"
|
||||
onDayClick={handleDayClick}
|
||||
onDayHover={setHoverDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/common/oneos-v2-form/calendar/dateUtils.ts
Normal file
145
src/common/oneos-v2-form/calendar/dateUtils.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'] as const;
|
||||
|
||||
export interface DateRangeValue {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export function parseISODate(value: string): Date | null {
|
||||
if (!value) return null;
|
||||
const parts = value.split('-').map(Number);
|
||||
if (parts.length !== 3) return null;
|
||||
const [year, month, day] = parts;
|
||||
if (!year || !month || !day) return null;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function formatISODate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function sameCalendarDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function addMonths(date: Date, count: number): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth() + count, 1);
|
||||
}
|
||||
|
||||
export function startOfMonth(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
}
|
||||
|
||||
export function compareISODate(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
export function normalizeDateRange(startDate: string, endDate: string): DateRangeValue {
|
||||
if (!startDate || !endDate) return { startDate, endDate };
|
||||
if (compareISODate(startDate, endDate) <= 0) return { startDate, endDate };
|
||||
return { startDate: endDate, endDate: startDate };
|
||||
}
|
||||
|
||||
export function isDateInRange(
|
||||
date: Date,
|
||||
start: Date | null,
|
||||
end: Date | null,
|
||||
hover: Date | null,
|
||||
): boolean {
|
||||
const rangeEnd = end || hover;
|
||||
if (!start || !rangeEnd) return false;
|
||||
const time = date.getTime();
|
||||
const startTime = start.getTime();
|
||||
const endTime = rangeEnd.getTime();
|
||||
const min = Math.min(startTime, endTime);
|
||||
const max = Math.max(startTime, endTime);
|
||||
return time >= min && time <= max;
|
||||
}
|
||||
|
||||
export function isRangeStart(
|
||||
date: Date,
|
||||
start: Date | null,
|
||||
end: Date | null,
|
||||
hover: Date | null,
|
||||
): boolean {
|
||||
if (!start) return false;
|
||||
const rangeEnd = end || hover;
|
||||
if (!rangeEnd) return sameCalendarDay(date, start);
|
||||
const min = start.getTime() <= rangeEnd.getTime() ? start : rangeEnd;
|
||||
return sameCalendarDay(date, min);
|
||||
}
|
||||
|
||||
export function isRangeEnd(
|
||||
date: Date,
|
||||
start: Date | null,
|
||||
end: Date | null,
|
||||
hover: Date | null,
|
||||
): boolean {
|
||||
if (!start) return false;
|
||||
const rangeEnd = end || hover;
|
||||
if (!rangeEnd) return false;
|
||||
const max = start.getTime() >= rangeEnd.getTime() ? start : rangeEnd;
|
||||
return sameCalendarDay(date, max);
|
||||
}
|
||||
|
||||
export function buildMonthCells(monthDate: Date): Array<{ date: Date; inMonth: boolean }> {
|
||||
const year = monthDate.getFullYear();
|
||||
const month = monthDate.getMonth();
|
||||
const firstWeekday = new Date(year, month, 1).getDay();
|
||||
const cells: Array<{ date: Date; inMonth: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < firstWeekday; i += 1) {
|
||||
const date = new Date(year, month, i - firstWeekday + 1);
|
||||
cells.push({ date, inMonth: false });
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth(year, month); day += 1) {
|
||||
cells.push({ date: new Date(year, month, day), inMonth: true });
|
||||
}
|
||||
|
||||
while (cells.length < 42) {
|
||||
const last = cells[cells.length - 1].date;
|
||||
const next = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
|
||||
cells.push({ date: next, inMonth: false });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
export function formatMonthTitle(monthDate: Date): string {
|
||||
return `${monthDate.getFullYear()}年${monthDate.getMonth() + 1}月`;
|
||||
}
|
||||
|
||||
export function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function formatTimeValue(hours: number, minutes: number, seconds?: number): string {
|
||||
const h = pad2(hours);
|
||||
const m = pad2(minutes);
|
||||
if (seconds === undefined) return `${h}:${m}`;
|
||||
return `${h}:${m}:${pad2(seconds)}`;
|
||||
}
|
||||
|
||||
export function parseTimeValue(value: string): { hours: number; minutes: number; seconds: number } | null {
|
||||
if (!value) return null;
|
||||
const parts = value.split(':').map(Number);
|
||||
if (parts.length < 2 || parts.some((n) => Number.isNaN(n))) return null;
|
||||
const [hours, minutes, seconds = 0] = parts;
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
return { hours, minutes, seconds };
|
||||
}
|
||||
Reference in New Issue
Block a user