Files
FootPrint/app/src/components/common/DynamicForm.tsx
T

119 lines
4.5 KiB
TypeScript

import type { FormField } from '@/lib/types';
import { Input, Textarea, Select, Field, type Option } from '@/components/ui';
export type TaxonomyOptionsMap = Record<string, Option[]>;
function resolveOptions(f: FormField, taxonomies: TaxonomyOptionsMap): Option[] {
if (!f.options) return [];
if (Array.isArray(f.options)) return f.options.map(v => ({ value: v, label: v }));
return taxonomies[f.options.taxonomy] ?? [];
}
export function DynamicForm({ fields, value, onChange, taxonomies }: {
fields: FormField[];
value: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
taxonomies: TaxonomyOptionsMap;
}) {
if (!fields.length) return null;
const set = (key: string, v: unknown) => onChange({ ...value, [key]: v });
const renderField = (f: FormField) => {
const v = value[f.key];
const commonError = f.required && (v === undefined || v === null || v === '') ? '该项必填' : '';
const withUnit = (node: React.ReactNode) =>
f.unit ? (
<div className="flex items-center gap-2">
{node}
<span className="text-[12px] text-ink-3 shrink-0">{f.unit}</span>
</div>
) : node;
switch (f.widget) {
case 'textarea':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Textarea value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
case 'number':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
{withUnit(
<Input type="number" step="any" className="num" value={v === undefined || v === null ? '' : String(v)}
onChange={e => set(f.key, e.target.value === '' ? undefined : Number(e.target.value))} />
)}
</Field>
);
case 'date':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Input type="date" value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} />
</Field>
);
case 'select':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Select
value={String(v ?? '')}
onChange={e => set(f.key, e.target.value || undefined)}
options={[{ value: '', label: '请选择' }, ...resolveOptions(f, taxonomies)]}
/>
</Field>
);
case 'multiselect': {
const opts = resolveOptions(f, taxonomies);
const arr = Array.isArray(v) ? (v as string[]) : [];
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<div className="flex flex-wrap gap-1.5">
{opts.map(o => {
const active = arr.includes(o.value);
return (
<button
key={o.value}
type="button"
className={
'rounded-sm border px-2 py-0.5 text-[12px] transition-colors ' +
(active
? 'border-brand bg-brand-subtle text-brand-text'
: 'border-line bg-surface text-ink-2 hover:border-line-strong')
}
onClick={() => set(f.key, active ? arr.filter(x => x !== o.value) : [...arr, o.value])}
>
{o.label}
</button>
);
})}
{!opts.length && <span className="text-[12px] text-ink-3">未配置选项</span>}
</div>
</Field>
);
}
case 'link':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError || '关联记录功能规划中,暂以文本记录'}>
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
default:
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
}
};
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
{fields.map(f => (
<div key={f.key} className={f.widget === 'textarea' ? 'sm:col-span-2' : ''}>
{renderField(f)}
</div>
))}
</div>
);
}