docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果
v1 声称完成的下游工作经核查实际未完成: - api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败 - teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平) - iam: proto 缺 BatchGetUsers rpc 声明 v2 记录详细核查证据和修复要求
This commit is contained in:
134
packages/ui-components/src/data-table.tsx
Normal file
134
packages/ui-components/src/data-table.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { Loading } from "./loading.js";
|
||||
import { Empty } from "./empty.js";
|
||||
|
||||
/**
|
||||
* DataTable - 通用数据表格
|
||||
*
|
||||
* 用途:列表数据的标准展示容器,内置加载态、空态、行交互。
|
||||
* 复用 Loading / Empty 组件保持一致的降级 UI。
|
||||
*
|
||||
* @example
|
||||
* const columns: Column<User>[] = [
|
||||
* { key: "name", header: "姓名" },
|
||||
* { key: "score", header: "分数", align: "right", render: (u) => u.score },
|
||||
* ];
|
||||
* <DataTable columns={columns} data={users} loading={isLoading} rowKey={(u) => u.id} />
|
||||
*/
|
||||
|
||||
/** 列对齐方式 */
|
||||
export type ColumnAlign = "left" | "center" | "right";
|
||||
|
||||
/** 列定义 */
|
||||
export interface Column<T> {
|
||||
/** 唯一标识(用于 key) */
|
||||
key: string;
|
||||
/** 表头内容 */
|
||||
header: ReactNode;
|
||||
/** 自定义单元格渲染;未提供时取 row[key] */
|
||||
render?: (row: T) => ReactNode;
|
||||
/** 自定义单元格类名 */
|
||||
className?: string;
|
||||
/** 对齐方式 */
|
||||
align?: ColumnAlign;
|
||||
/** 列宽(CSS 值,如 "200px" / "20%") */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
/** 列定义 */
|
||||
columns: Column<T>[];
|
||||
/** 数据数组 */
|
||||
data: T[];
|
||||
/** 加载态;为 true 时显示 Loading */
|
||||
loading?: boolean;
|
||||
/** 空态自定义内容;未提供时使用默认 Empty */
|
||||
empty?: ReactNode;
|
||||
/** 行点击回调 */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** 行 key 生成函数;未提供时使用索引 */
|
||||
rowKey?: (row: T) => string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ALIGN_CLASS: Record<ColumnAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
empty,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
className,
|
||||
}: DataTableProps<T>): ReactNode {
|
||||
if (loading) {
|
||||
return <Loading lines={Math.max(columns.length, 3)} />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return empty ?? <Empty title="暂无数据" description="调整筛选条件后重试" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto border border-rule rounded-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr className="border-b border-rule">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={cn(
|
||||
"py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted font-medium",
|
||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||
)}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, index) => {
|
||||
const key = rowKey ? rowKey(row) : String(index);
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={cn(
|
||||
"border-b border-rule",
|
||||
onRowClick && "cursor-pointer hover:bg-subtle",
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"py-2 px-3 text-ink",
|
||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||
col.className,
|
||||
)}
|
||||
>
|
||||
{col.render ? col.render(row) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
packages/ui-components/src/filter-bar.tsx
Normal file
63
packages/ui-components/src/filter-bar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* FilterBar - 通用筛选栏
|
||||
*
|
||||
* 用途:包裹一组筛选项(Select / Input 等),提供重置与应用按钮。
|
||||
* 横向 flex-wrap 布局,按钮固定在右侧。
|
||||
*
|
||||
* @example
|
||||
* <FilterBar onReset={handleReset} onApply={handleApply}>
|
||||
* <select>...</select>
|
||||
* <input type="text" />
|
||||
* </FilterBar>
|
||||
*/
|
||||
|
||||
export interface FilterBarProps {
|
||||
/** 筛选项(Select / Input 等),由调用方传入 */
|
||||
children: ReactNode;
|
||||
/** 重置回调 */
|
||||
onReset?: () => void;
|
||||
/** 应用回调 */
|
||||
onApply?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
children,
|
||||
onReset,
|
||||
onApply,
|
||||
className,
|
||||
}: FilterBarProps): ReactNode {
|
||||
const hasAction = onReset !== undefined || onApply !== undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-3", className)}>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-3">{children}</div>
|
||||
{hasAction && (
|
||||
<div className="flex items-center gap-3">
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
{onApply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
className="px-4 py-1.5 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
packages/ui-components/src/form.tsx
Normal file
286
packages/ui-components/src/form.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, type FormEvent, type ChangeEvent } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* Form - 轻量级表单(不依赖 react-hook-form)
|
||||
*
|
||||
* 用途:简单表单场景,通过 FormData 收集所有命名字段的值。
|
||||
* 复杂表单(校验 / 联动)仍推荐 react-hook-form + zodResolver。
|
||||
*
|
||||
* @example
|
||||
* <Form onSubmit={(data) => console.log(data)}>
|
||||
* <FormField label="姓名" name="name" required>
|
||||
* <FormInput name="name" />
|
||||
* </FormField>
|
||||
* <SubmitButton>提交</SubmitButton>
|
||||
* </Form>
|
||||
*/
|
||||
|
||||
// ============ Form 容器 ============
|
||||
|
||||
export interface FormProps {
|
||||
/** 提交回调,data 为所有命名字段的值 */
|
||||
onSubmit: (data: Record<string, string>) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Form({ onSubmit, children, className }: FormProps): ReactNode {
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data: Record<string, string> = {};
|
||||
formData.forEach((value, key) => {
|
||||
data[key] = String(value);
|
||||
});
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn("space-y-4", className)}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ FormField 字段容器 ============
|
||||
|
||||
export interface FormFieldProps {
|
||||
label: ReactNode;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
name,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
className,
|
||||
}: FormFieldProps): ReactNode {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="mb-1 block text-tiny uppercase tracking-wide text-ink-muted"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <p className="mt-1 text-tiny text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ FormInput 受控输入 ============
|
||||
|
||||
export interface FormInputProps {
|
||||
name: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
name,
|
||||
type = "text",
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: FormInputProps): ReactNode {
|
||||
return (
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ FormSelect 受控下拉 ============
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FormSelectProps {
|
||||
name: string;
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormSelect({
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: FormSelectProps): ReactNode {
|
||||
return (
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ FormTextarea 受控文本域 ============
|
||||
|
||||
export interface FormTextareaProps {
|
||||
name: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormTextarea({
|
||||
name,
|
||||
rows = 3,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: FormTextareaProps): ReactNode {
|
||||
return (
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ FormCheckbox 受控复选框 ============
|
||||
|
||||
export interface FormCheckboxProps {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormCheckbox({
|
||||
name,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: FormCheckboxProps): ReactNode {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-ink",
|
||||
disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)
|
||||
: undefined
|
||||
}
|
||||
className="rounded border-rule"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ SubmitButton 提交按钮 ============
|
||||
|
||||
export interface SubmitButtonProps {
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
loading,
|
||||
disabled,
|
||||
className,
|
||||
}: SubmitButtonProps): ReactNode {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"rounded-button bg-accent px-4 py-2 text-sm text-ink-on-accent hover:bg-accent-hover disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{loading ? "提交中..." : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -9,13 +9,17 @@
|
||||
* - Loading:骨架屏 / 加载占位
|
||||
* - Empty:空态展示
|
||||
* - RequirePermission:L3 组件级视口控制
|
||||
* - DataTable:通用数据表格
|
||||
* - FilterBar:通用筛选栏
|
||||
* - StatusBadge:状态徽章
|
||||
* - Modal:模态对话框
|
||||
* - Form:轻量级表单(Form/FormField/FormInput/FormSelect/FormTextarea/FormCheckbox/SubmitButton)
|
||||
* - cn:类名合并工具(project_rules §3.9)
|
||||
*
|
||||
* 后续扩展(P2+):
|
||||
* - AppShell(MF Shell 布局)
|
||||
* - shadcn/ui 基础组件(Button/Input/Select/Modal/Toast/DataTable)
|
||||
* - shadcn/ui 基础组件(Button/Input/Select/Toast)
|
||||
* - A11y 工具集(useA11yId/mergeA11yProps/describeInput/focus-trap)
|
||||
* - Form(react-hook-form + zodResolver)
|
||||
* - RichTextEditor(Tiptap 封装)
|
||||
* - Chart(recharts 封装)
|
||||
*/
|
||||
@@ -35,4 +39,36 @@ export type { EmptyProps } from "./empty.js";
|
||||
export { RequirePermission } from "./require-permission.js";
|
||||
export type { RequirePermissionProps } from "./require-permission.js";
|
||||
|
||||
export { DataTable } from "./data-table.js";
|
||||
export type { Column, ColumnAlign, DataTableProps } from "./data-table.js";
|
||||
|
||||
export { FilterBar } from "./filter-bar.js";
|
||||
export type { FilterBarProps } from "./filter-bar.js";
|
||||
|
||||
export { StatusBadge } from "./status-badge.js";
|
||||
export type { StatusBadgeProps, StatusVariant } from "./status-badge.js";
|
||||
|
||||
export { Modal } from "./modal.js";
|
||||
export type { ModalProps, ModalSize } from "./modal.js";
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormSelect,
|
||||
FormTextarea,
|
||||
FormCheckbox,
|
||||
SubmitButton,
|
||||
} from "./form.js";
|
||||
export type {
|
||||
FormProps,
|
||||
FormFieldProps,
|
||||
FormInputProps,
|
||||
FormSelectProps,
|
||||
FormTextareaProps,
|
||||
FormCheckboxProps,
|
||||
SubmitButtonProps,
|
||||
SelectOption,
|
||||
} from "./form.js";
|
||||
|
||||
export { cn } from "./utils/cn.js";
|
||||
|
||||
100
packages/ui-components/src/modal.tsx
Normal file
100
packages/ui-components/src/modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* Modal - 模态对话框
|
||||
*
|
||||
* 用途:覆盖在页面之上的居中弹窗,用于编辑表单、确认操作等。
|
||||
* 支持 ESC 关闭、点击遮罩关闭。
|
||||
*
|
||||
* @example
|
||||
* <Modal open={isOpen} onClose={() => setOpen(false)} title="编辑" size="lg"
|
||||
* footer={<><button>取消</button><button>保存</button></>}
|
||||
* >
|
||||
* <p>内容</p>
|
||||
* </Modal>
|
||||
*/
|
||||
|
||||
export type ModalSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export interface ModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean;
|
||||
/** 关闭回调(ESC / 点击遮罩时触发) */
|
||||
onClose: () => void;
|
||||
/** 标题 */
|
||||
title?: ReactNode;
|
||||
/** 内容区 */
|
||||
children: ReactNode;
|
||||
/** 底部按钮区 */
|
||||
footer?: ReactNode;
|
||||
/** 尺寸 */
|
||||
size?: ModalSize;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<ModalSize, string> = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-2xl",
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = "md",
|
||||
className,
|
||||
}: ModalProps): ReactNode {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"max-h-[90vh] w-full overflow-y-auto rounded-card border border-rule bg-paper p-6 shadow-xl",
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-lg font-serif text-ink">{title}</h2>
|
||||
<div className="rule-thin mb-4 mt-2" />
|
||||
</>
|
||||
)}
|
||||
<div className="text-ink">{children}</div>
|
||||
{footer && (
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
packages/ui-components/src/status-badge.tsx
Normal file
92
packages/ui-components/src/status-badge.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* StatusBadge - 状态徽章
|
||||
*
|
||||
* 用途:用简短的彩色标签展示状态(如"已发布"/"待审核"/"已拒绝")。
|
||||
* 未指定 variant 时根据 status 字符串自动推断语义色。
|
||||
*
|
||||
* @example
|
||||
* <StatusBadge status="published" />
|
||||
* <StatusBadge status="custom" variant="info">自定义文案</StatusBadge>
|
||||
*/
|
||||
|
||||
export type StatusVariant =
|
||||
"success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
/** 状态字符串(用于推断 variant 及默认文案) */
|
||||
status: string;
|
||||
/** 显式指定变体;未提供时根据 status 自动推断 */
|
||||
variant?: StatusVariant;
|
||||
/** 自定义文案;未提供时显示 status */
|
||||
children?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** status → variant 自动推断映射 */
|
||||
const STATUS_VARIANT_MAP: Record<string, StatusVariant> = {
|
||||
// success
|
||||
approved: "success",
|
||||
active: "success",
|
||||
published: "success",
|
||||
completed: "success",
|
||||
success: "success",
|
||||
done: "success",
|
||||
passed: "success",
|
||||
// warning
|
||||
pending: "warning",
|
||||
draft: "warning",
|
||||
review: "warning",
|
||||
waiting: "warning",
|
||||
processing: "warning",
|
||||
// danger
|
||||
rejected: "danger",
|
||||
cancelled: "danger",
|
||||
failed: "danger",
|
||||
error: "danger",
|
||||
inactive: "danger",
|
||||
deleted: "danger",
|
||||
// info
|
||||
info: "info",
|
||||
new: "info",
|
||||
syncing: "info",
|
||||
loading: "info",
|
||||
};
|
||||
|
||||
/** variant → Tailwind 类名映射 */
|
||||
const VARIANT_CLASS: Record<StatusVariant, string> = {
|
||||
success: "border-success text-success bg-success/10",
|
||||
warning: "border-warning text-warning bg-warning/10",
|
||||
danger: "border-danger text-danger bg-danger/10",
|
||||
info: "border-info text-info bg-info/10",
|
||||
neutral: "border-rule text-ink-muted bg-subtle",
|
||||
};
|
||||
|
||||
function inferVariant(status: string): StatusVariant {
|
||||
const normalized = status.toLowerCase();
|
||||
return STATUS_VARIANT_MAP[normalized] ?? "neutral";
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
variant,
|
||||
children,
|
||||
className,
|
||||
}: StatusBadgeProps): ReactNode {
|
||||
const resolvedVariant = variant ?? inferVariant(status);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-button border px-2 py-0.5 text-tiny font-medium",
|
||||
VARIANT_CLASS[resolvedVariant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user