119 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|