Initial CMPP frontend prototype

This commit is contained in:
hectorzhao
2026-06-30 16:09:46 +08:00
commit 2f3c274a30
98 changed files with 25255 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useLocation } from 'react-router-dom';
const pageTitleMap: Record<string, string> = {
'/client/send': '短信发送',
'/client/templates': '模板管理',
'/client/signatures': '签名与引流信息',
'/client/billing': '充值套餐',
'/client/invoices': '账单流水',
'/client/settings': '账号设置',
'/admin/monitor': '发送监控',
'/admin/analytics': '数据统计',
'/admin/customers': '客户管理',
'/admin/templates': '模板审核',
'/admin/signatures': '签名审核',
'/admin/channels': '通道管理',
'/admin/billing': '账单流水',
'/admin/settings': '系统配置',
};
export function PagePlaceholder() {
const location = useLocation();
const title = pageTitleMap[location.pathname] ?? '页面建设中';
return (
<section className="page-stack">
<div className="page-heading">
<div>
<p className="eyebrow"></p>
<h1>{title}</h1>
</div>
<p className="muted"></p>
</div>
<div className="surface empty-state">
<strong>{title}</strong>
<span></span>
</div>
</section>
);
}
+1
View File
@@ -0,0 +1 @@
+39
View File
@@ -0,0 +1,39 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
iconOnly?: boolean;
};
export function Button({
className = '',
variant = 'primary',
size = 'md',
icon,
iconOnly = false,
children,
type = 'button',
...props
}: ButtonProps) {
const classes = [
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
iconOnly ? 'ui-button--icon-only' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<button className={classes} type={type} {...props}>
{icon ? <span className="ui-button__icon">{icon}</span> : null}
{iconOnly ? <span className="sr-only">{children}</span> : children}
</button>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import * as echarts from 'echarts';
type ChartProps = {
option: EChartsOption;
height?: number;
};
export function Chart({ option, height = 280 }: ChartProps) {
const chartRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!chartRef.current) {
return undefined;
}
const chart = echarts.init(chartRef.current);
chart.setOption(option);
const resizeObserver = new ResizeObserver(() => chart.resize());
resizeObserver.observe(chartRef.current);
return () => {
resizeObserver.disconnect();
chart.dispose();
};
}, [option]);
return <div className="ui-chart" ref={chartRef} style={{ height }} />;
}
+199
View File
@@ -0,0 +1,199 @@
import { useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight, X } from 'lucide-react';
export type DateRangeValue = {
start?: string;
end?: string;
};
type DateRangeInputProps = {
label?: string;
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
};
const weekDays = ['一', '二', '三', '四', '五', '六', '日'];
const presets = [
{ label: '今天', days: 0 },
{ label: '近7天', days: 6 },
{ label: '近30天', days: 29 },
];
function formatDate(date: Date) {
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}`;
}
function parseDate(value?: string) {
if (!value) {
return undefined;
}
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
function getMonthLabel(date: Date) {
return `${date.getFullYear()}${date.getMonth() + 1}`;
}
function isSameDate(left: Date, right?: Date) {
if (!right) {
return false;
}
return formatDate(left) === formatDate(right);
}
function getCalendarDays(viewDate: Date) {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const firstDate = new Date(year, month, 1);
const firstWeekday = (firstDate.getDay() + 6) % 7;
const startDate = new Date(year, month, 1 - firstWeekday);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(startDate);
date.setDate(startDate.getDate() + index);
return {
date,
dateString: formatDate(date),
inCurrentMonth: date.getMonth() === month,
};
});
}
export function DateRangeInput({ label, value, onChange }: DateRangeInputProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => parseDate(value.start) ?? new Date());
const startDate = parseDate(value.start);
const endDate = parseDate(value.end);
const calendarDays = useMemo(() => getCalendarDays(viewDate), [viewDate]);
const displayValue = useMemo(() => {
if (value.start && value.end) {
return `${value.start}${value.end}`;
}
if (value.start) {
return `${value.start} 至 ...`;
}
return '选择提交时间区间';
}, [value.end, value.start]);
function moveMonth(offset: number) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
}
function applyPreset(days: number) {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - days);
onChange({ start: formatDate(start), end: formatDate(end) });
setViewDate(start);
}
function selectDate(dateString: string) {
if (!value.start || value.end) {
onChange({ start: dateString, end: undefined });
return;
}
if (dateString < value.start) {
onChange({ start: dateString, end: value.start });
return;
}
onChange({ start: value.start, end: dateString });
}
return (
<div className="ui-field ui-date-range-field">
{label ? <span className="ui-field__label">{label}</span> : null}
<button
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value.start || value.end ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
onClick={() => setOpen((current) => !current)}
type="button"
>
<CalendarDays size={16} />
<span>{displayValue}</span>
{value.start || value.end ? (
<span
aria-label="清空时间区间"
className="ui-date-range__clear"
onClick={(event) => {
event.stopPropagation();
onChange({});
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onChange({});
}
}}
role="button"
tabIndex={0}
>
<X size={14} />
</span>
) : null}
</button>
{open ? (
<div className="ui-date-range-popover">
<div className="ui-date-range-presets">
{presets.map((item) => (
<button key={item.label} onClick={() => applyPreset(item.days)} type="button">{item.label}</button>
))}
</div>
<div className="ui-calendar">
<div className="ui-calendar__header">
<button aria-label="上个月" onClick={() => moveMonth(-1)} type="button">
<ChevronLeft size={16} />
</button>
<strong>{getMonthLabel(viewDate)}</strong>
<button aria-label="下个月" onClick={() => moveMonth(1)} type="button">
<ChevronRight size={16} />
</button>
</div>
<div className="ui-calendar__weekdays">
{weekDays.map((day) => <span key={day}>{day}</span>)}
</div>
<div className="ui-calendar__grid">
{calendarDays.map((item) => {
const isStart = isSameDate(item.date, startDate);
const isEnd = isSameDate(item.date, endDate);
const isInRange = Boolean(startDate && endDate && item.date > startDate && item.date < endDate);
return (
<button
className={[
'ui-calendar__day',
item.inCurrentMonth ? '' : 'ui-calendar__day--muted',
isStart ? 'ui-calendar__day--start' : '',
isEnd ? 'ui-calendar__day--end' : '',
isInRange ? 'ui-calendar__day--range' : '',
].filter(Boolean).join(' ')}
key={item.dateString}
onClick={() => selectDate(item.dateString)}
type="button"
>
{item.date.getDate()}
</button>
);
})}
</div>
</div>
<div className="ui-date-range-actions">
<button onClick={() => onChange({})} type="button"></button>
<button disabled={!value.start} onClick={() => setOpen(false)} type="button"></button>
</div>
</div>
) : null}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight, Clock3, X } from 'lucide-react';
type DateTimeInputProps = {
label?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
const weekDays = ['一', '二', '三', '四', '五', '六', '日'];
function formatDate(date: Date) {
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}`;
}
function parseDate(value?: string) {
if (!value) {
return undefined;
}
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
function getMonthLabel(date: Date) {
return `${date.getFullYear()}${date.getMonth() + 1}`;
}
function getCalendarDays(viewDate: Date) {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const firstDate = new Date(year, month, 1);
const firstWeekday = (firstDate.getDay() + 6) % 7;
const startDate = new Date(year, month, 1 - firstWeekday);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(startDate);
date.setDate(startDate.getDate() + index);
return {
date,
dateString: formatDate(date),
inCurrentMonth: date.getMonth() === month,
};
});
}
function splitDateTime(value: string) {
const [date = '', time = ''] = value.split('T');
return { date, time };
}
export function DateTimeInput({ label, value, onChange, placeholder = '选择定时发送时间' }: DateTimeInputProps) {
const { date, time } = splitDateTime(value);
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => parseDate(date) ?? new Date());
const calendarDays = useMemo(() => getCalendarDays(viewDate), [viewDate]);
function moveMonth(offset: number) {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
}
function updateDate(nextDate: string) {
onChange(`${nextDate}T${time || '09:00'}`);
}
function updateTime(nextTime: string) {
onChange(`${date || formatDate(new Date())}T${nextTime}`);
}
return (
<div className="ui-field ui-date-range-field">
{label ? <span className="ui-field__label">{label}</span> : null}
<button
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
onClick={() => setOpen((current) => !current)}
type="button"
>
<CalendarDays size={16} />
<span>{value ? `${date} ${time}` : placeholder}</span>
{value ? (
<span
aria-label="清空定时发送时间"
className="ui-date-range__clear"
onClick={(event) => {
event.stopPropagation();
onChange('');
}}
role="button"
tabIndex={0}
>
<X size={14} />
</span>
) : null}
</button>
{open ? (
<div className="ui-date-range-popover ui-date-time-popover">
<div className="ui-calendar">
<div className="ui-calendar__header">
<button aria-label="上个月" onClick={() => moveMonth(-1)} type="button">
<ChevronLeft size={16} />
</button>
<strong>{getMonthLabel(viewDate)}</strong>
<button aria-label="下个月" onClick={() => moveMonth(1)} type="button">
<ChevronRight size={16} />
</button>
</div>
<div className="ui-calendar__weekdays">
{weekDays.map((day) => <span key={day}>{day}</span>)}
</div>
<div className="ui-calendar__grid">
{calendarDays.map((item) => (
<button
className={[
'ui-calendar__day',
item.inCurrentMonth ? '' : 'ui-calendar__day--muted',
item.dateString === date ? 'ui-calendar__day--selected' : '',
].filter(Boolean).join(' ')}
key={item.dateString}
onClick={() => updateDate(item.dateString)}
type="button"
>
{item.date.getDate()}
</button>
))}
</div>
</div>
<label className="ui-time-picker">
<span><Clock3 size={15} /> </span>
<input onChange={(event) => updateTime(event.target.value)} type="time" value={time || '09:00'} />
</label>
<div className="ui-date-range-actions">
<button onClick={() => onChange('')} type="button"></button>
<button disabled={!value} onClick={() => setOpen(false)} type="button"></button>
</div>
</div>
) : null}
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import type { ReactNode } from 'react';
export type RateTone = 'low' | 'medium' | 'high';
type DetailTitleProps = {
title: ReactNode;
subtitle?: ReactNode;
};
type DetailSectionProps = {
title: ReactNode;
extra?: ReactNode;
children: ReactNode;
};
export type DetailInfoItem = {
label: ReactNode;
value: ReactNode;
tone?: 'default' | 'primary' | 'success' | 'danger';
full?: boolean;
};
type DetailInfoGridProps = {
items: DetailInfoItem[];
};
type DetailProgressStatsProps = {
label: ReactNode;
percent: number;
meta?: ReactNode;
status?: 'completed' | 'sending' | 'terminated';
stats: Array<{
label: ReactNode;
value: ReactNode;
}>;
};
type RateOverviewProps = {
label: ReactNode;
rate: number;
tone: RateTone;
metrics: Array<{
label: ReactNode;
value: ReactNode;
}>;
};
type RateCardProps = {
title: ReactNode;
rate: number;
tone: RateTone;
meta?: ReactNode;
};
type ProgressBarProps = {
percent: number;
tone?: RateTone;
status?: 'completed' | 'sending' | 'terminated';
};
export function getRateTone(rate: number): RateTone {
if (rate < 50) {
return 'low';
}
if (rate <= 80) {
return 'medium';
}
return 'high';
}
export function DetailTitle({ title, subtitle }: DetailTitleProps) {
return (
<div className="ui-detail-title">
<h2>{title}</h2>
{subtitle ? <p>{subtitle}</p> : null}
</div>
);
}
export function DetailSection({ title, extra, children }: DetailSectionProps) {
return (
<section className="ui-detail-section">
<div className="ui-detail-section__header">
<h3>{title}</h3>
{extra ? <div className="ui-detail-section__extra">{extra}</div> : null}
</div>
{children}
</section>
);
}
export function DetailInfoGrid({ items }: DetailInfoGridProps) {
return (
<div className="ui-detail-info-grid">
{items.map((item, index) => (
<div className={item.full ? 'ui-detail-info-grid__item ui-detail-info-grid__item--full' : 'ui-detail-info-grid__item'} key={index}>
<span>{item.label}</span>
<strong className={item.tone && item.tone !== 'default' ? `ui-detail-text--${item.tone}` : undefined}>{item.value}</strong>
</div>
))}
</div>
);
}
export function DetailProgressStats({ label, percent, meta, status, stats }: DetailProgressStatsProps) {
return (
<div className="ui-detail-progress-stats">
<div className="ui-detail-progress-card">
<span>{label}</span>
<strong>{percent}%</strong>
<ProgressBar percent={percent} status={status} />
{meta ? <small>{meta}</small> : null}
</div>
<div className="ui-detail-stat-list">
{stats.map((item, index) => (
<div key={index}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
</div>
);
}
export function RateOverview({ label, rate, tone, metrics }: RateOverviewProps) {
return (
<div className={`ui-rate-overview ui-rate-tone-${tone}`}>
<span>{label}</span>
<strong>{rate.toFixed(2)}%</strong>
<div className="ui-rate-overview__metrics">
{metrics.map((item, index) => (
<div key={index}>
<span>{item.label}</span>
<b>{item.value}</b>
</div>
))}
</div>
</div>
);
}
export function RateCard({ title, rate, tone, meta }: RateCardProps) {
return (
<div className={`ui-rate-card ui-rate-tone-${tone}`}>
<div className="ui-rate-card__header">
<strong>{title}</strong>
<span>{rate.toFixed(2)}%</span>
</div>
<ProgressBar percent={rate} tone={tone} />
{meta ? <p>{meta}</p> : null}
</div>
);
}
export function ProgressBar({ percent, tone, status }: ProgressBarProps) {
const toneClass = tone ? `ui-rate-tone-${tone}` : '';
const statusClass = status ? `ui-progress-bar--${status}` : '';
return (
<div className={['ui-progress-bar', toneClass, statusClass].filter(Boolean).join(' ')}>
<span style={{ width: `${percent}%` }} />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
type NativeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'>;
type InputProps = NativeInputProps & {
label?: string;
hint?: string;
error?: string;
prefix?: ReactNode;
suffix?: ReactNode;
};
export function Input({
className = '',
label,
hint,
error,
prefix,
suffix,
id,
...props
}: InputProps) {
const inputId = id ?? props.name;
return (
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={inputId}>
{label ? <span className="ui-field__label">{label}</span> : null}
<span className={['ui-input', error ? 'ui-input--error' : ''].filter(Boolean).join(' ')}>
{prefix ? <span className="ui-input__addon">{prefix}</span> : null}
<input id={inputId} {...props} />
{suffix ? <span className="ui-input__addon">{suffix}</span> : null}
</span>
{error ? <span className="ui-field__error">{error}</span> : null}
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/Button';
type ModalProps = {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
size?: 'md' | 'xl';
onClose: () => void;
};
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
if (open) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) {
return null;
}
return createPortal(
<div className="ui-modal" role="presentation">
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
<header className="ui-modal__header">
<div className="ui-modal__title">{title}</div>
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
</Button>
</header>
<div className="ui-modal__body">{children}</div>
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
</section>
</div>,
document.body,
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
type QueryPanelProps = {
title: ReactNode;
summary?: ReactNode;
children: ReactNode;
};
type PaginationProps = {
total: number;
page?: number;
previousDisabled?: boolean;
nextDisabled?: boolean;
onPrevious?: () => void;
onNext?: () => void;
};
type InlineTextPreviewProps = {
label: ReactNode;
leading?: ReactNode;
children: ReactNode;
};
export function QueryPanel({ title, summary, children }: QueryPanelProps) {
return (
<div className="surface ui-query-panel">
<h2>{title}</h2>
<div className="ui-query-panel__grid">{children}</div>
{summary ? <p className="ui-query-panel__summary">{summary}</p> : null}
</div>
);
}
export function Pagination({
total,
page = 1,
previousDisabled = true,
nextDisabled = true,
onPrevious,
onNext,
}: PaginationProps) {
return (
<div className="ui-pagination">
<span> {total} </span>
<div>
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">{page}</Button>
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost"></Button>
</div>
</div>
);
}
export function InlineTextPreview({ label, leading, children }: InlineTextPreviewProps) {
return (
<div className="ui-inline-text-preview">
<span>{label}</span>
<p>{leading}{children}</p>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { SelectHTMLAttributes } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
export type SelectOption = {
label: string;
value: string;
};
type NativeSelectProps = Omit<
SelectHTMLAttributes<HTMLSelectElement>,
'children' | 'defaultValue' | 'multiple' | 'onChange' | 'size' | 'value'
>;
type SelectProps = NativeSelectProps & {
label?: string;
hint?: string;
error?: string;
options: SelectOption[];
value?: string;
defaultValue?: string;
placeholder?: string;
onChange?: (event: { target: { value: string } }) => void;
};
export function Select({
className = '',
label,
hint,
error,
options,
id,
value,
defaultValue,
onChange,
disabled,
placeholder,
...props
}: SelectProps) {
const selectId = id ?? props.name;
const rootRef = useRef<HTMLLabelElement | null>(null);
const [open, setOpen] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
const selectedValue = value ?? internalValue;
const selectedOption = useMemo(
() => options.find((option) => option.value === selectedValue),
[options, selectedValue],
);
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, []);
function selectOption(nextValue: string) {
setInternalValue(nextValue);
onChange?.({ target: { value: nextValue } });
setOpen(false);
}
return (
<label
className={['ui-field', className].filter(Boolean).join(' ')}
htmlFor={selectId}
ref={rootRef}
>
{label ? <span className="ui-field__label">{label}</span> : null}
<span
className={[
'ui-select',
open ? 'ui-select--open' : '',
error ? 'ui-select--error' : '',
disabled ? 'ui-select--disabled' : '',
].filter(Boolean).join(' ')}
>
<button
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
id={selectId}
onClick={() => setOpen((current) => !current)}
type="button"
>
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
{selectedOption?.label ?? placeholder ?? '请选择'}
</span>
<ChevronDown size={16} />
</button>
{open ? (
<div className="ui-select__dropdown" role="listbox">
{options.map((option) => (
<button
aria-selected={option.value === selectedValue}
key={option.value}
onClick={() => selectOption(option.value)}
role="option"
type="button"
>
{option.label}
</button>
))}
</div>
) : null}
</span>
{error ? <span className="ui-field__error">{error}</span> : null}
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
export type TableColumn<T> = {
key: string;
title: string;
width?: string;
align?: 'left' | 'center' | 'right';
render: (record: T, index: number) => ReactNode;
};
type TableProps<T> = {
columns: Array<TableColumn<T>>;
data: T[];
rowKey: keyof T | ((record: T) => string);
emptyText?: string;
};
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) {
function getRowKey(record: T) {
if (typeof rowKey === 'function') {
return rowKey(record);
}
return String(record[rowKey]);
}
return (
<div className="ui-table-wrap">
<table className="ui-table">
<thead>
<tr>
{columns.map((column) => (
<th
key={column.key}
style={{ width: column.width, textAlign: column.align ?? 'left' }}
>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={columns.length}>
{emptyText}
</td>
</tr>
) : (
data.map((record, index) => (
<tr key={getRowKey(record)}>
{columns.map((column) => (
<td
key={column.key}
style={{ textAlign: column.align ?? 'left' }}
>
{column.render(record, index)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
export type TabItem = {
label: string;
value: string;
content: ReactNode;
};
type TabsProps = {
items: TabItem[];
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
};
export function Tabs({ items, defaultValue, value, onChange }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.value);
const activeValue = value ?? internalValue;
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
function handleChange(nextValue: string) {
setInternalValue(nextValue);
onChange?.(nextValue);
}
return (
<div className="ui-tabs">
<div className="ui-tabs__list" role="tablist">
{items.map((item) => (
<button
aria-selected={item.value === activeValue}
className="ui-tabs__tab"
key={item.value}
onClick={() => handleChange(item.value)}
role="tab"
type="button"
>
{item.label}
</button>
))}
</div>
<div className="ui-tabs__panel" role="tabpanel">
{activeItem?.content}
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { HTMLAttributes, ReactNode } from 'react';
type TagTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent';
type TagProps = HTMLAttributes<HTMLSpanElement> & {
tone?: TagTone;
children: ReactNode;
};
export function Tag({ className = '', tone = 'neutral', children, ...props }: TagProps) {
return (
<span className={['ui-tag', `ui-tag--${tone}`, className].filter(Boolean).join(' ')} {...props}>
{children}
</span>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { TextareaHTMLAttributes } from 'react';
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string;
hint?: string;
error?: string;
};
export function Textarea({ className = '', label, hint, error, id, ...props }: TextareaProps) {
const textareaId = id ?? props.name;
return (
<label className={['ui-field', className].filter(Boolean).join(' ')} htmlFor={textareaId}>
{label ? <span className="ui-field__label">{label}</span> : null}
<textarea
className={['ui-textarea', error ? 'ui-textarea--error' : ''].filter(Boolean).join(' ')}
id={textareaId}
{...props}
/>
{error ? <span className="ui-field__error">{error}</span> : null}
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}
+18
View File
@@ -0,0 +1,18 @@
export { Button } from './Button';
export { Chart } from './Chart';
export { DateRangeInput } from './DateRangeInput';
export { DateTimeInput } from './DateTimeInput';
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
export { Input } from './Input';
export { Modal } from './Modal';
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
export { Select } from './Select';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Tag } from './Tag';
export { Textarea } from './Textarea';
export type { SelectOption } from './Select';
export type { DetailInfoItem, RateTone } from './Detail';
export type { DateRangeValue } from './DateRangeInput';
export type { TableColumn } from './Table';
export type { TabItem } from './Tabs';