新增 AutoRDO 需求清洗工作台与消息中枢,迭代 OneOS V2 设计规范及租赁合同/工作台/车辆等原型,同步云效技能与导航注册;并归档一批 legacy 原型快照。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-28 15:50:35 +08:00
parent b14425da5a
commit 5d51a6bf7a
1393 changed files with 488042 additions and 10046 deletions

View File

@@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Calendar, X } from 'lucide-react';
import { O2Field } from './Field';
import { O2SingleCalendarPanel } from './calendar/CalendarPanel';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps } from './types';
export type O2DatePickerProps = O2FieldProps & {
value: string;
onChange: (value: string) => void;
placeholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
export function O2DatePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
onChange,
placeholder = 'YYYY-MM-DD',
allowClear = true,
ariaLabel,
}: O2DatePickerProps) {
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 280 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const width = Math.max(280, rect.width);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
let top = rect.bottom + 6;
if (top + 340 > window.innerHeight - 8 && rect.top > 340) {
top = Math.max(8, rect.top - 340 - 6);
}
setPos({ top, left, width });
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
};
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--calendar"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<O2SingleCalendarPanel
value={value}
onChange={onChange}
onComplete={dismiss}
/>
<div className="o2-cal__footer">
<button
type="button"
className="o2-cal__chip"
onClick={() => {
const d = new Date();
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
onChange(iso);
dismiss();
}}
>
</button>
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<Calendar size={14} className="o2-control__leading" aria-hidden />
<input
id={fieldId}
type="text"
className="o2-control__input o2-control--tabular"
value={value}
placeholder={placeholder}
readOnly
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : '日期')}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPicker();
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange('');
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Calendar, X } from 'lucide-react';
import { O2Field } from './Field';
import { O2RangeCalendarPanel } from './calendar/CalendarPanel';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { DateRangeValue, O2FieldProps } from './types';
export type { DateRangeValue };
export type O2DateRangePickerProps = O2FieldProps & {
startDate: string;
endDate: string;
onChange: (range: DateRangeValue) => void;
startPlaceholder?: string;
endPlaceholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
const POPOVER_MIN_WIDTH = 560;
export function O2DateRangePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
startDate,
endDate,
onChange,
startPlaceholder = '开始日期',
endPlaceholder = '结束日期',
allowClear = true,
ariaLabel = '日期范围',
}: O2DateRangePickerProps) {
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: POPOVER_MIN_WIDTH });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const hasValue = Boolean(startDate || endDate);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const maxWidth = Math.max(160, window.innerWidth - 16);
const width = Math.min(Math.max(rect.width, POPOVER_MIN_WIDTH), maxWidth);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
if (left < 8) left = 8;
let top = rect.bottom + 6;
if (top + 380 > window.innerHeight - 8 && rect.top > 380) {
top = Math.max(8, rect.top - 380 - 6);
}
setPos({ top, left, width });
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = (which?: 'start' | 'end') => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
void which;
};
const controlClass = [
'o2-control',
'o2-control--range',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--calendar o2-popover--range"
style={{ top: pos.top, left: pos.left, width: pos.width }}
role="dialog"
aria-label={ariaLabel}
>
<O2RangeCalendarPanel
startDate={startDate}
endDate={endDate}
onChange={onChange}
onComplete={dismiss}
/>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={`${fieldId}-start`}
>
<div className="o2-picker o2-picker--range" ref={anchorRef}>
<div className={controlClass}>
<button
type="button"
className="o2-range__part"
id={`${fieldId}-start`}
disabled={disabled}
onClick={() => openPicker('start')}
aria-label={startPlaceholder}
>
<Calendar size={14} aria-hidden />
<span className={startDate ? 'o2-range__value' : 'o2-range__placeholder'}>
{startDate || startPlaceholder}
</span>
</button>
<span className="o2-range__sep"></span>
<button
type="button"
className="o2-range__part"
id={`${fieldId}-end`}
disabled={disabled}
onClick={() => openPicker('end')}
aria-label={endPlaceholder}
>
<Calendar size={14} aria-hidden />
<span className={endDate ? 'o2-range__value' : 'o2-range__placeholder'}>
{endDate || endPlaceholder}
</span>
</button>
{allowClear && hasValue && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange({ startDate: '', endDate: '' });
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { O2FieldProps } from './types';
type Props = O2FieldProps & {
children: React.ReactNode;
htmlFor?: string;
};
export function O2Field({
label,
required,
help,
error,
className,
style,
children,
htmlFor,
}: Props) {
const classes = ['o2-field', className].filter(Boolean).join(' ');
return (
<div className={classes} style={style} data-invalid={error ? 'true' : undefined}>
{label ? (
<label className="o2-field__label" htmlFor={htmlFor}>
{label}
{required ? <span className="o2-field__required" aria-hidden>*</span> : null}
</label>
) : null}
{children}
{error ? (
<p className="o2-field__error" role="alert">
{error}
</p>
) : help ? (
<p className="o2-field__help">{help}</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React, { forwardRef, useId } from 'react';
import { O2Field } from './Field';
import type { O2FieldProps } from './types';
export type O2InputProps = O2FieldProps &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'className' | 'style'> & {
tabularNums?: boolean;
inputClassName?: string;
};
export const O2Input = forwardRef<HTMLInputElement, O2InputProps>(function O2Input(
{
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
tabularNums,
inputClassName,
...rest
},
ref,
) {
const autoId = useId();
const inputId = id || autoId;
const controlClass = [
'o2-control',
`o2-control--${size}`,
error ? 'o2-control--error' : '',
tabularNums ? 'o2-control--tabular' : '',
inputClassName,
]
.filter(Boolean)
.join(' ');
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={inputId}
>
<div className={controlClass}>
<input
ref={ref}
id={inputId}
className="o2-control__input"
disabled={disabled}
aria-invalid={error ? true : undefined}
{...rest}
/>
</div>
</O2Field>
);
});

View File

@@ -0,0 +1,252 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronDown, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps, O2SelectOption } from './types';
export type O2MultiSelectProps = O2FieldProps & {
value: string[];
options: O2SelectOption[] | string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchable?: boolean;
ariaLabel?: string;
maxTagCount?: number;
};
function normalizeOptions(options: O2SelectOption[] | string[]): O2SelectOption[] {
return options.map((item) =>
typeof item === 'string' ? { value: item, label: item } : item,
);
}
export function O2MultiSelect({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
options,
onChange,
placeholder = '请选择(可多选)',
searchable = true,
ariaLabel,
maxTagCount = 3,
}: O2MultiSelectProps) {
const autoId = useId();
const fieldId = id || autoId;
const opts = useMemo(() => normalizeOptions(options), [options]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedSet = useMemo(() => new Set(value), [value]);
const selectedLabels = useMemo(
() => opts.filter((o) => selectedSet.has(o.value)),
[opts, selectedSet],
);
const dismiss = useCallback(() => {
setOpen(false);
setQuery('');
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({ top: rect.bottom + 6, left: rect.left, width: Math.max(rect.width, 220) });
}, []);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos, query, value.length]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !searchable) return opts;
return opts.filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
);
}, [opts, query, searchable]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(() => {
updatePos();
inputRef.current?.focus();
});
};
const toggle = (next: string) => {
if (selectedSet.has(next)) {
onChange(value.filter((v) => v !== next));
} else {
onChange([...value, next]);
}
setQuery('');
};
const removeTag = (next: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange(value.filter((v) => v !== next));
};
const clearAll = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange([]);
};
const visibleTags = selectedLabels.slice(0, maxTagCount);
const overflow = selectedLabels.length - visibleTags.length;
const controlClass = [
'o2-control',
'o2-control--picker',
'o2-control--multi',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal"
role="listbox"
aria-multiselectable
aria-label={ariaLabel || (typeof label === 'string' ? label : '多选')}
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-popover__list">
{filtered.length === 0 ? (
<p className="o2-popover__empty"></p>
) : (
filtered.map((item) => {
const checked = selectedSet.has(item.value);
return (
<button
key={item.value}
type="button"
role="option"
aria-selected={checked}
disabled={item.disabled}
className={`o2-popover__option ${checked ? 'is-checked' : ''}`}
onClick={() => !item.disabled && toggle(item.value)}
>
<span className={`o2-check ${checked ? 'is-on' : ''}`} aria-hidden>
{checked ? <Check size={12} /> : null}
</span>
<span className="o2-popover__option-label">{item.label}</span>
</button>
);
})
)}
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<div className="o2-multi-tags">
{visibleTags.map((tag) => (
<span key={tag.value} className="o2-tag">
{tag.label}
{!disabled ? (
<button
type="button"
className="o2-tag__remove"
aria-label={`移除${tag.label}`}
onClick={(e) => removeTag(tag.value, e)}
>
<X size={12} aria-hidden />
</button>
) : null}
</span>
))}
{overflow > 0 ? <span className="o2-tag o2-tag--more">+{overflow}</span> : null}
<input
ref={inputRef}
id={fieldId}
type="text"
className="o2-control__input o2-multi-input"
value={open && searchable ? query : ''}
placeholder={selectedLabels.length === 0 ? placeholder : ''}
readOnly={!searchable || !open}
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
onChange={(e) => {
if (!searchable) return;
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Backspace' && !query && value.length > 0) {
onChange(value.slice(0, -1));
}
}}
/>
</div>
{value.length > 0 && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空全部"
tabIndex={-1}
onClick={clearAll}
>
<X size={14} aria-hidden />
</button>
) : null}
<ChevronDown
size={14}
className={`o2-control__chevron ${open ? 'is-open' : ''}`}
aria-hidden
/>
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,37 @@
# OneOS V2 统一表单控件
路径:`src/common/oneos-v2-form/`
预览原型:`/prototypes/oneos-v2-form-kit`
## 导出
| 组件 | 文件 | 说明 |
|------|------|------|
| `O2Input` | `Input.tsx` | 单行输入 |
| `O2Select` | `Select.tsx` | 单选(可搜索) |
| `O2MultiSelect` | `MultiSelect.tsx` | 多选 Tag |
| `O2DatePicker` | `DatePicker.tsx` | 单日日历 |
| `O2DateRangePicker` | `DateRangePicker.tsx` | 开始「至」结束 · 双月 |
| `O2TimePicker` | `TimePicker.tsx` | `HH:mm` / `HH:mm:ss` |
样式:`oneos-v2-form.css`(依赖 `oneos-ds-tokens.css`
## 引用
```tsx
import '../../resources/design-system/oneos-ds-tokens.css';
import '../../common/oneos-v2-form/oneos-v2-form.css';
import {
O2Input,
O2Select,
O2MultiSelect,
O2DatePicker,
O2DateRangePicker,
O2TimePicker,
} from '../../common/oneos-v2-form';
```
主色 Stripe Violet `#533AFD`;高度 `md=36` / `sm=32`;圆角 8。
区间日期:开始框 +「至」+ 结束框 + 双月历(对标 `DateRangeFilterField`)。
预览:`/prototypes/oneos-v2-form-kit` · `/prototypes/oneos-v2?view=form-kit`

View File

@@ -0,0 +1,219 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronDown, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps, O2SelectOption } from './types';
export type O2SelectProps = O2FieldProps & {
value: string;
options: O2SelectOption[] | string[];
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
ariaLabel?: string;
};
function normalizeOptions(options: O2SelectOption[] | string[]): O2SelectOption[] {
return options.map((item) =>
typeof item === 'string' ? { value: item, label: item } : item,
);
}
export function O2Select({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
options,
onChange,
placeholder = '请选择',
searchable = true,
allowClear = true,
ariaLabel,
}: O2SelectProps) {
const autoId = useId();
const fieldId = id || autoId;
const opts = useMemo(() => normalizeOptions(options), [options]);
const selected = opts.find((o) => o.value === value);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dismiss = useCallback(() => {
setOpen(false);
setQuery('');
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({ top: rect.bottom + 6, left: rect.left, width: rect.width });
}, []);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos, query]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !searchable) return opts;
return opts.filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
);
}, [opts, query, searchable]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
setQuery('');
window.requestAnimationFrame(() => {
updatePos();
if (searchable) inputRef.current?.focus();
});
};
const pick = (next: string) => {
onChange(next);
dismiss();
};
const clear = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange('');
dismiss();
};
const display = open && searchable ? query : selected?.label || '';
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal"
role="listbox"
aria-label={ariaLabel || (typeof label === 'string' ? label : '选择')}
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-popover__list">
{filtered.length === 0 ? (
<p className="o2-popover__empty"></p>
) : (
filtered.map((item) => {
const checked = item.value === value;
return (
<button
key={item.value}
type="button"
role="option"
aria-selected={checked}
disabled={item.disabled}
className={`o2-popover__option ${checked ? 'is-checked' : ''}`}
onClick={() => !item.disabled && pick(item.value)}
>
<span className="o2-popover__option-label">{item.label}</span>
{checked ? <Check size={14} aria-hidden /> : null}
</button>
);
})
)}
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<input
ref={inputRef}
id={fieldId}
type="text"
className="o2-control__input"
value={display}
placeholder={placeholder}
readOnly={!searchable || !open}
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : undefined)}
onChange={(e) => {
if (!searchable) return;
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
if (!open) {
e.preventDefault();
openPicker();
}
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={clear}
>
<X size={14} aria-hidden />
</button>
) : null}
<ChevronDown
size={14}
className={`o2-control__chevron ${open ? 'is-open' : ''}`}
aria-hidden
/>
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Clock, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps } from './types';
export type O2TimePickerProps = O2FieldProps & {
value: string;
onChange: (value: string) => void;
/** HH:mm 或 HH:mm:ss */
format?: 'HH:mm' | 'HH:mm:ss';
placeholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
function pad(n: number) {
return String(n).padStart(2, '0');
}
function parseTime(value: string, withSeconds: boolean) {
const parts = value.split(':').map((p) => Number(p));
return {
h: Number.isFinite(parts[0]) ? parts[0] : 0,
m: Number.isFinite(parts[1]) ? parts[1] : 0,
s: withSeconds && Number.isFinite(parts[2]) ? parts[2] : 0,
};
}
function formatTime(h: number, m: number, s: number, withSeconds: boolean) {
return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}`;
}
function Column({
values,
selected,
onSelect,
label,
}: {
values: number[];
selected: number;
onSelect: (n: number) => void;
label: string;
}) {
return (
<div className="o2-time__col" role="listbox" aria-label={label}>
{values.map((n) => (
<button
key={n}
type="button"
role="option"
aria-selected={n === selected}
className={`o2-time__item ${n === selected ? 'is-selected' : ''}`}
onClick={() => onSelect(n)}
>
{pad(n)}
</button>
))}
</div>
);
}
export function O2TimePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
onChange,
format = 'HH:mm',
placeholder,
allowClear = true,
ariaLabel,
}: O2TimePickerProps) {
const withSeconds = format === 'HH:mm:ss';
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 200 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const draft = useMemo(() => parseTime(value || '00:00:00', withSeconds), [value, withSeconds]);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const width = Math.max(withSeconds ? 220 : 180, rect.width);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
let top = rect.bottom + 6;
if (top + 280 > window.innerHeight - 8 && rect.top > 280) {
top = Math.max(8, rect.top - 280 - 6);
}
setPos({ top, left, width });
}, [withSeconds]);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
};
const commit = (h: number, m: number, s: number) => {
onChange(formatTime(h, m, s, withSeconds));
};
const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
const minutes = useMemo(() => Array.from({ length: 60 }, (_, i) => i), []);
const seconds = minutes;
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--time"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-time">
<Column
label="时"
values={hours}
selected={draft.h}
onSelect={(h) => commit(h, draft.m, draft.s)}
/>
<Column
label="分"
values={minutes}
selected={draft.m}
onSelect={(m) => commit(draft.h, m, draft.s)}
/>
{withSeconds ? (
<Column
label="秒"
values={seconds}
selected={draft.s}
onSelect={(s) => commit(draft.h, draft.m, s)}
/>
) : null}
</div>
<div className="o2-cal__footer">
<button
type="button"
className="o2-cal__chip"
onClick={() => {
const now = new Date();
commit(now.getHours(), now.getMinutes(), now.getSeconds());
dismiss();
}}
>
</button>
<button type="button" className="o2-cal__chip o2-cal__chip--primary" onClick={dismiss}>
</button>
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<Clock size={14} className="o2-control__leading" aria-hidden />
<input
id={fieldId}
type="text"
className="o2-control__input o2-control--tabular"
value={value}
placeholder={placeholder || format}
readOnly
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : '时间')}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPicker();
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange('');
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View 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>
);
}

View 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 };
}

View File

@@ -0,0 +1,45 @@
import { useEffect, type RefObject } from 'react';
function isInsideContainers(
target: EventTarget | null,
containers: Array<RefObject<HTMLElement | null>>,
): boolean {
const node = target as Node | null;
if (!node) return false;
return containers.some((ref) => ref.current?.contains(node));
}
export function useDismissOnFocusOutside(
open: boolean,
containerRef: RefObject<HTMLElement | null> | Array<RefObject<HTMLElement | null>>,
onDismiss: () => void,
) {
useEffect(() => {
if (!open) return;
const containers = Array.isArray(containerRef) ? containerRef : [containerRef];
const closeIfOutside = (target: EventTarget | null) => {
if (!isInsideContainers(target, containers)) {
onDismiss();
}
};
const handlePointerDown = (event: MouseEvent) => {
closeIfOutside(event.target);
};
const handleFocusIn = () => {
window.requestAnimationFrame(() => {
closeIfOutside(document.activeElement);
});
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('focusin', handleFocusIn);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('focusin', handleFocusIn);
};
}, [open, onDismiss, containerRef]);
}

View File

@@ -0,0 +1,28 @@
/**
* OneOS V2 Form Kit
*
* 新 V2 / 迁入页统一表单控件Input / Select / MultiSelect / Date / DateRange / Time
* 旧 vm 页可继续使用 FilterPickerField / DateRangeFilterField。
*/
import '../../resources/design-system/oneos-ds-tokens.css';
import './oneos-v2-form.css';
export { O2Field } from './Field';
export { O2Input } from './Input';
export type { O2InputProps } from './Input';
export { O2Select } from './Select';
export type { O2SelectProps } from './Select';
export { O2MultiSelect } from './MultiSelect';
export type { O2MultiSelectProps } from './MultiSelect';
export { O2DatePicker } from './DatePicker';
export type { O2DatePickerProps } from './DatePicker';
export { O2DateRangePicker } from './DateRangePicker';
export type { O2DateRangePickerProps, DateRangeValue } from './DateRangePicker';
export { O2TimePicker } from './TimePicker';
export type { O2TimePickerProps } from './TimePicker';
export type { O2ControlSize, O2FieldProps, O2SelectOption } from './types';
export {
O2SingleCalendarPanel,
O2RangeCalendarPanel,
} from './calendar/CalendarPanel';

View File

@@ -0,0 +1,549 @@
/**
* OneOS V2 Form Controls — Stripe Violet skin
* Import with: import '../../resources/design-system/oneos-ds-tokens.css'
* import './oneos-v2-form.css'
*/
.o2-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.o2-field__label {
font-size: 12px;
font-weight: 600;
color: var(--ln-body, #425466);
line-height: 1.33;
}
.o2-field__required {
color: var(--ln-error, #ef4444);
margin-left: 2px;
}
.o2-field__help,
.o2-field__error {
margin: 0;
font-size: 12px;
line-height: 1.33;
}
.o2-field__help {
color: var(--ln-muted, #627d98);
}
.o2-field__error {
color: var(--ln-error, #ef4444);
}
.o2-control {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 8px;
background: var(--ln-canvas-soft, #f8fafc);
color: var(--ln-ink, #0a2540);
font-size: 13px;
font-weight: 500;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.o2-control--md {
min-height: 36px;
padding: 7px 12px;
}
.o2-control--sm {
min-height: 32px;
padding: 5px 10px;
font-size: 12px;
}
.o2-control:hover:not(.is-disabled):not(:focus-within):not(.o2-control--error) {
border-color: var(--ln-hairline-strong, #d4d4d8);
}
.o2-control:focus-within:not(.o2-control--error),
.o2-control.is-open:not(.o2-control--error) {
border-color: var(--ln-primary, #533afd);
box-shadow: 0 0 0 3px rgba(83, 58, 253, 0.2);
background: var(--ln-surface-card, #ffffff);
}
.o2-control--error {
border-color: var(--ln-error, #ef4444);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
.o2-control.is-disabled,
.o2-control:disabled {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-muted, #627d98);
cursor: not-allowed;
opacity: 0.85;
}
.o2-control--tabular,
.o2-control--tabular .o2-control__input,
.o2-range__value {
font-variant-numeric: tabular-nums;
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
}
.o2-control__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
width: 100%;
}
.o2-control__input::placeholder {
color: var(--ln-muted-soft, #9fb3c8);
font-weight: 400;
font-family: inherit;
}
.o2-control__leading {
flex-shrink: 0;
color: var(--ln-muted, #627d98);
}
.o2-control__chevron,
.o2-control__trailing-icon {
flex-shrink: 0;
color: var(--ln-muted, #627d98);
transition: transform 0.15s ease;
pointer-events: none;
}
.o2-control__chevron.is-open {
transform: rotate(180deg);
color: var(--ln-primary, #533afd);
}
.o2-control__input--tabular {
font-variant-numeric: tabular-nums;
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
}
.o2-cal__body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.o2-popover--cal,
.o2-popover--cal-range,
.o2-popover--calendar,
.o2-popover--range,
.o2-popover--time {
border-radius: 12px;
padding: 0;
}
.o2-tag--more {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-body, #425466);
}
.o2-control--multi {
min-height: 36px;
height: auto;
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.o2-control--multi.o2-control--sm {
min-height: 32px;
}
.o2-control__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ln-muted, #627d98);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.o2-control__icon-btn:hover {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-ink, #0a2540);
}
.o2-control--picker {
cursor: pointer;
}
.o2-picker {
position: relative;
width: 100%;
}
.o2-control--range {
display: flex;
align-items: center;
gap: 8px;
padding-right: 8px;
}
.o2-range__part {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0 4px;
text-align: left;
}
.o2-range__part:disabled {
cursor: not-allowed;
}
.o2-range__sep {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--ln-body, #425466);
padding: 0 2px;
}
.o2-range__placeholder {
color: var(--ln-muted-soft, #9fb3c8);
font-weight: 400;
font-family: inherit;
}
.o2-range__value {
color: var(--ln-ink, #0a2540);
}
.o2-popover {
background: var(--ln-surface-card, #ffffff);
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 1200;
overflow: hidden;
}
.o2-popover--portal {
position: fixed;
}
.o2-popover__list {
max-height: 260px;
overflow: auto;
padding: 6px;
}
.o2-popover__empty {
margin: 0;
padding: 12px;
font-size: 12px;
color: var(--ln-muted, #627d98);
text-align: center;
}
.o2-popover__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: none;
background: transparent;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: var(--ln-ink, #0a2540);
cursor: pointer;
text-align: left;
}
.o2-popover__option:hover:not(:disabled) {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-popover__option.is-checked {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-weight: 600;
}
.o2-popover__option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.o2-tags,
.o2-multi-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
align-items: center;
}
.o2-multi-input {
min-width: 72px;
flex: 1;
}
.o2-check {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--ln-hairline-strong, #d4d4d8);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.o2-check.is-on {
background: var(--ln-primary, #533afd);
border-color: var(--ln-primary, #533afd);
}
.o2-tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 8px;
border-radius: 9999px;
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.o2-tag__remove {
display: inline-flex;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
}
.o2-cal {
padding: 12px;
}
.o2-cal--range .o2-cal__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.o2-cal__nav {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.o2-cal__nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 6px;
background: var(--ln-surface-card, #ffffff);
color: var(--ln-body, #425466);
cursor: pointer;
}
.o2-cal__nav-btn:hover {
border-color: var(--ln-primary, #533afd);
color: var(--ln-primary, #533afd);
}
.o2-cal__month-title {
text-align: center;
font-size: 13px;
font-weight: 700;
color: var(--ln-ink, #0a2540);
margin-bottom: 8px;
}
.o2-cal__weekdays,
.o2-cal__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.o2-cal__weekday {
text-align: center;
font-size: 11px;
color: var(--ln-muted, #627d98);
padding: 4px 0;
}
.o2-cal__day {
width: 100%;
aspect-ratio: 1;
max-width: 36px;
margin: 0 auto;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ln-ink, #0a2540);
font-size: 12px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.o2-cal__day.is-outside {
color: var(--ln-hairline-strong, #d4d4d8);
}
.o2-cal__day.is-today {
border: 1px solid var(--ln-primary, #533afd);
color: var(--ln-primary, #533afd);
font-weight: 700;
}
.o2-cal__day.is-selected,
.o2-cal__day.is-range-start,
.o2-cal__day.is-range-end {
background: var(--ln-primary, #533afd);
color: #ffffff;
font-weight: 700;
}
.o2-cal__day.is-in-range {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
border-radius: 0;
}
.o2-cal__day:hover:not(.is-selected):not(.is-range-start):not(.is-range-end) {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-cal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px 12px;
border-top: 1px solid var(--ln-hairline, #e3e8ee);
}
.o2-cal__chip {
border: 1px solid var(--ln-hairline, #e3e8ee);
background: var(--ln-surface-card, #ffffff);
color: var(--ln-ink, #0a2540);
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.o2-cal__chip--primary {
border: none;
background: var(--ln-primary, #533afd);
color: #ffffff;
}
.o2-time {
display: flex;
max-height: 220px;
overflow: hidden;
}
.o2-time__col {
flex: 1;
overflow-y: auto;
border-right: 1px solid var(--ln-hairline, #e3e8ee);
padding: 4px;
}
.o2-time__col:last-child {
border-right: none;
}
.o2-time__item {
width: 100%;
border: none;
background: transparent;
border-radius: 6px;
padding: 6px 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ln-ink, #0a2540);
cursor: pointer;
}
.o2-time__item:hover {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-time__item.is-selected {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-weight: 700;
}
[data-ds-mode='dark'] .o2-control,
[data-oneos-theme='dark'] .o2-control {
background: #1a1d24;
border-color: var(--ln-hairline, #23272f);
color: var(--ln-ink, #f7fafc);
}
[data-ds-mode='dark'] .o2-control:focus-within,
[data-oneos-theme='dark'] .o2-control:focus-within,
[data-ds-mode='dark'] .o2-control.is-open,
[data-oneos-theme='dark'] .o2-control.is-open {
background: #121418;
}
[data-ds-mode='dark'] .o2-control.is-disabled,
[data-oneos-theme='dark'] .o2-control.is-disabled {
background: #16181f;
}
[data-ds-mode='dark'] .o2-popover,
[data-oneos-theme='dark'] .o2-popover {
background: #121418;
border-color: #23272f;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
[data-ds-mode='dark'] .o2-cal__day.is-outside,
[data-oneos-theme='dark'] .o2-cal__day.is-outside {
color: #4a5568;
}

View File

@@ -0,0 +1,28 @@
import type { CSSProperties, ReactNode } from 'react';
/** 筛选区 32px / 表单区 36px */
export type O2ControlSize = 'sm' | 'md';
export type O2SelectOption = {
value: string;
label: string;
disabled?: boolean;
};
export type O2FieldProps = {
label?: ReactNode;
required?: boolean;
help?: ReactNode;
error?: ReactNode;
className?: string;
style?: CSSProperties;
/** 控件尺寸sm=筛选 32pxmd=表单 36px默认 */
size?: O2ControlSize;
disabled?: boolean;
id?: string;
};
export type DateRangeValue = {
startDate: string;
endDate: string;
};