Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 69 additions & 47 deletions src/components/SchedulePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import { ConfirmDialog } from './ConfirmDialog';
// 生成唯一 ID
const generateId = () => Math.random().toString(36).substring(2, 9);

// 小时选项 (0-23)
const HOURS = Array.from({ length: 24 }, (_, i) => i);
// 校验 "HH:mm" 格式
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;

interface SchedulePanelProps {
instanceId: string;
Comment on lines +22 to 25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: 建议对 <input type="time"> 的值做规范化,而不是严格拒绝包含秒的值。

部分浏览器对 type="time" 会输出 HH:mm:ss,这会导致当前的正则匹配失败,从而阻止添加排程,但其实该值是可以安全地缩减到分钟粒度的。建议在将 e.target.value 存入 timeDraft 之前先做规范化处理(例如使用 value.slice(0, 5),或通过 Date/Intl 处理),并只对规范化后的 HH:mm 字符串做校验,以保持其它地方使用的时间格式不变。

建议的实现方式:

 // 校验规范化后的 "HH:mm" 格式
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;

// 规范化 <input type="time"> 的值,将 "HH:mm:ss" 等形式裁剪为 "HH:mm"
const normalizeTimeValue = (value: string): string => {
  if (!value) return value;
  // 绝大多数浏览器时间字符串形如 "HH:mm" 或 "HH:mm:ss"
  // 只要前 5 位是 "HH:mm" 形态,就裁剪前 5 位作为规范格式
  if (value.length >= 5 && value[2] === ':') {
    return value.slice(0, 5);
  }
  return value;
};

interface SchedulePanelProps {

为了完整实现你在描述中希望的行为,你还需要更新时间输入的处理逻辑(在当前代码片段中不可见)。在所有处理时间输入的地方(例如 onChangeonBlur,或者任何将 e.target.value 写入 timeDraft 或用 TIME_PATTERN 校验的地方):

  1. 在使用原始值之前先做规范化:
    • 将类似下面的代码:
      • const value = e.target.value;
    • 替换为:
      • const value = normalizeTimeValue(e.target.value);
  2. 使用规范化后的 value 来:
    • 调用 setTimeDraft(value);
    • 进行任何 TIME_PATTERN.test(...) 调用。
    • 以及所有依赖时间字符串的排程创建/更新逻辑。
  3. 确保来自 props 或外部来源的初始时间值要么已经是 HH:mm 格式,要么同样先通过 normalizeTimeValue 再存储/校验,以保持该不变式的一致性。
Original comment in English

suggestion: Consider normalizing the <input type="time"> value instead of strictly rejecting values that include seconds.

Some browsers emit HH:mm:ss for type="time", which will fail this pattern and block adding a schedule even though the value can be safely reduced to minutes. Consider normalizing e.target.value (e.g., value.slice(0, 5) or via Date/Intl) before storing it in timeDraft, and validate only the normalized HH:mm string to preserve the invariant used elsewhere.

Suggested implementation:

 // 校验规范化后的 "HH:mm" 格式
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;

// 规范化 <input type="time"> 的值,将 "HH:mm:ss" 等形式裁剪为 "HH:mm"
const normalizeTimeValue = (value: string): string => {
  if (!value) return value;
  // 绝大多数浏览器时间字符串形如 "HH:mm" 或 "HH:mm:ss"
  // 只要前 5 位是 "HH:mm" 形态,就裁剪前 5 位作为规范格式
  if (value.length >= 5 && value[2] === ':') {
    return value.slice(0, 5);
  }
  return value;
};

interface SchedulePanelProps {

To fully implement the behavior you described, you should also update the time input handling code (not visible in the snippet). Wherever you currently handle the time input (e.g. onChange, onBlur, or wherever e.target.value is stored into timeDraft or validated via TIME_PATTERN):

  1. Normalize the raw value before using it:
    • Change something like:
      • const value = e.target.value;
    • To:
      • const value = normalizeTimeValue(e.target.value);
  2. Use the normalized value for:
    • setTimeDraft(value);
    • Any TIME_PATTERN.test(...) calls.
    • Any other schedule creation/update logic that relies on time strings.
  3. Ensure that any initial values coming from props or external sources that represent a time value are either already HH:mm or also passed through normalizeTimeValue before being stored/validated, to keep the invariant consistent.

Expand All @@ -43,6 +43,7 @@ function PolicyCard({
const { t } = useTranslation();
const { confirmBeforeDelete } = useAppStore();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [timeDraft, setTimeDraft] = useState('08:00');

const weekdayLabels = t('schedule.weekdays', { returnObjects: true }) as string[];

Expand All @@ -53,11 +54,15 @@ function PolicyCard({
onUpdate({ weekdays: newWeekdays });
};

const handleToggleHour = (hour: number) => {
const newHours = policy.hours.includes(hour)
? policy.hours.filter((h) => h !== hour)
: [...policy.hours, hour].sort((a, b) => a - b);
onUpdate({ hours: newHours });
const handleAddTime = () => {
if (!TIME_PATTERN.test(timeDraft)) return;
if (policy.times.includes(timeDraft)) return;
const newTimes = [...policy.times, timeDraft].sort((a, b) => a.localeCompare(b));
onUpdate({ times: newTimes });
};

const handleRemoveTime = (time: string) => {
onUpdate({ times: policy.times.filter((t) => t !== time) });
};

const handleSelectAllWeekdays = () => {
Expand All @@ -69,15 +74,6 @@ function PolicyCard({
}
};

const handleSelectAllHours = () => {
// 已全选时取消全选,否则全选
if (policy.hours.length === 24) {
onUpdate({ hours: [] });
} else {
onUpdate({ hours: HOURS });
}
};

// 格式化显示已选周几
const formatWeekdays = () => {
if (policy.weekdays.length === 0) return t('schedule.noWeekdays');
Expand All @@ -86,13 +82,12 @@ function PolicyCard({
};

// 格式化显示已选时间
const formatHours = () => {
if (policy.hours.length === 0) return t('schedule.noHours');
if (policy.hours.length === 24) return t('schedule.everyHour');
if (policy.hours.length <= 3) {
return policy.hours.map((h) => `${h.toString().padStart(2, '0')}:00`).join(', ');
const formatTimes = () => {
if (policy.times.length === 0) return t('schedule.noTimes');
if (policy.times.length <= 3) {
return policy.times.join(', ');
}
return `${policy.hours.length} ${t('schedule.hoursSelected')}`;
return `${policy.times.length} ${t('schedule.timesSelected')}`;
};

return (
Expand Down Expand Up @@ -217,34 +212,61 @@ function PolicyCard({
({t('schedule.multiSelect')})
</span>
</label>
{/* 时间网格 */}
<div className="grid grid-cols-7 gap-1">
{/* 时间点添加 */}
<div className="flex items-center gap-1.5">
<input
type="time"
value={timeDraft}
onChange={(e) => setTimeDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTime();
}
}}
className={clsx(
'flex-1 px-2 py-1.5 text-sm rounded border',
'bg-bg-primary text-text-primary border-border',
'focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20',
)}
/>
<button
onClick={handleSelectAllHours}
onClick={handleAddTime}
disabled={!TIME_PATTERN.test(timeDraft) || policy.times.includes(timeDraft)}
className={clsx(
'px-1 py-1.5 text-xs rounded border transition-colors',
policy.hours.length === 24
? 'bg-accent text-white border-accent'
: 'bg-bg-primary text-text-secondary border-border hover:border-accent hover:text-accent',
'flex items-center gap-1 px-2 py-1.5 text-xs rounded border transition-colors',
'border-border text-text-secondary',
'hover:border-accent hover:text-accent',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:text-text-secondary',
)}
title={t('schedule.addTime')}
>
{t('schedule.all')}
<Plus className="w-3.5 h-3.5" />
<span>{t('schedule.addTime')}</span>
</button>
{HOURS.map((hour) => (
<button
key={hour}
onClick={() => handleToggleHour(hour)}
className={clsx(
'px-1 py-1.5 text-xs rounded border transition-colors',
policy.hours.includes(hour)
? 'bg-accent text-white border-accent'
: 'bg-bg-primary text-text-secondary border-border hover:border-accent hover:text-accent',
)}
>
{hour.toString().padStart(2, '0')}
</button>
))}
</div>
{/* 已选时间点 */}
{policy.times.length > 0 ? (
<div className="flex flex-wrap gap-1">
{policy.times.map((time) => (
<span
key={time}
className="flex items-center gap-1 pl-2 pr-1 py-1 text-xs rounded border border-accent bg-accent/10 text-accent"
>
{time}
<button
onClick={() => handleRemoveTime(time)}
className="p-0.5 rounded hover:bg-accent/20"
title={t('common.delete')}
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
) : (
<p className="text-xs text-text-muted">{t('schedule.noTimes')}</p>
)}
<p className="text-xs text-text-muted">
{t('schedule.timeZoneHint')} (
{(() => {
Expand All @@ -258,7 +280,7 @@ function PolicyCard({
{/* 摘要显示 */}
<div className="pt-2 border-t border-border">
<p className="text-xs text-text-secondary">
{formatWeekdays()} · {formatHours()}
{formatWeekdays()} · {formatTimes()}
</p>
</div>
</div>
Expand All @@ -268,7 +290,7 @@ function PolicyCard({
{!isExpanded && (
<div className="px-3 pb-2">
<p className="text-xs text-text-muted truncate">
{formatWeekdays()} · {formatHours()}
{formatWeekdays()} · {formatTimes()}
</p>
</div>
)}
Expand Down Expand Up @@ -318,7 +340,7 @@ export function SchedulePanel({ instanceId, onClose }: SchedulePanelProps) {
name: `${t('schedule.defaultPolicyName')} ${policies.length + 1}`,
enabled: true,
weekdays: [1, 2, 3, 4, 5], // 默认工作日
hours: [8], // 默认早上8点
times: ['08:00'], // 默认早上 8 点
};
updateInstance(instanceId, {
schedulePolicies: [...policies, newPolicy],
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,13 +712,11 @@ export default {
repeatDays: 'Repeat Days',
startTime: 'Start Time',
selectDays: 'Select days...',
selectHours: 'Select hours...',
addTime: 'Add time',
noWeekdays: 'No days selected',
noHours: 'No hours selected',
noTimes: 'No times selected',
everyday: 'Every day',
everyHour: 'Every hour',
all: 'All',
hoursSelected: 'hours selected',
timesSelected: 'times selected',
timeZoneHint: 'Using local timezone',
multiSelect: 'multi-select',
enable: 'Enable schedule',
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,13 +711,11 @@ export default {
repeatDays: '繰り返し日',
startTime: '開始時刻',
selectDays: '日を選択...',
selectHours: '時刻を選択...',
addTime: '時刻を追加',
noWeekdays: '日が選択されていません',
noHours: '時刻が選択されていません',
noTimes: '時刻が選択されていません',
everyday: '毎日',
everyHour: '毎時',
all: 'すべて',
hoursSelected: '件の時刻',
timesSelected: '件の時刻',
timeZoneHint: 'ローカルタイムゾーンを使用',
multiSelect: '複数選択可',
enable: 'スケジュールを有効化',
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,13 +704,11 @@ export default {
repeatDays: '반복 요일',
startTime: '시작 시간',
selectDays: '요일 선택...',
selectHours: '시간 선택...',
addTime: '시간 추가',
noWeekdays: '요일이 선택되지 않았습니다',
noHours: '시간이 선택되지 않았습니다',
noTimes: '시간이 선택되지 않았습니다',
everyday: '매일',
everyHour: '매시',
all: '전체',
hoursSelected: '개의 시간',
timesSelected: '개의 시간',
timeZoneHint: '로컬 시간대 사용',
multiSelect: '다중 선택',
enable: '예약 활성화',
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,13 +706,11 @@ export default {
repeatDays: '重复日期',
startTime: '开始时间',
selectDays: '选择日期...',
selectHours: '选择时间...',
addTime: '添加时间',
noWeekdays: '未选择日期',
noHours: '未选择时间',
noTimes: '未选择时间',
everyday: '每天',
everyHour: '每小时',
all: '全部',
hoursSelected: '个时间点',
timesSelected: '个时间点',
timeZoneHint: '使用本地时区',
multiSelect: '可多选',
enable: '启用策略',
Expand Down
8 changes: 3 additions & 5 deletions src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,13 +691,11 @@ export default {
repeatDays: '重複日期',
startTime: '開始時間',
selectDays: '選擇日期...',
selectHours: '選擇時間...',
addTime: '新增時間',
noWeekdays: '未選擇日期',
noHours: '未選擇時間',
noTimes: '未選擇時間',
everyday: '每天',
everyHour: '每小時',
all: '全部',
hoursSelected: '個時間点',
timesSelected: '個時間點',
timeZoneHint: '使用本地時區',
multiSelect: '可多選',
enable: '啟用策略',
Expand Down
Loading
Loading