Initial CMPP frontend prototype
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user