feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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' ? 'col-span-2' : ''}>
|
||||
{renderField(f)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user