ui: type-aware editor form + detail reordered by importance

This commit is contained in:
root
2026-10-17 10:39:01 +08:00
parent 4a73fddd0c
commit 79dada4226
17 changed files with 519 additions and 465 deletions
@@ -131,7 +131,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
<div className="py-10 text-center text-[13px] text-ink-3">加载中…</div>
) : (
<div className="flex flex-col gap-4">
{/* 徽章行 */}
{/* 徽章行:类型 · 级别 · 奖等 · 角色 · 状态 · 名次 */}
<div className="flex flex-wrap items-center gap-1.5">
<Badge color={d.type_color}>{d.type_name}</Badge>
<LevelBadge level={d.level} />
@@ -142,15 +142,69 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
{d.rank != null && <Badge color="var(--text-3)">第 {d.rank} 名</Badge>}
</div>
{/* 基本信息网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border border-line bg-surface-2 px-3 py-2.5 text-[12.5px]">
<MetaItem label="时间" value={fmtRange(d.start_date, d.end_date) || '—'} />
<MetaItem label="组织方" value={d.organizer || '—'} />
{d.group_id && <MetaItem label="事件组" value={d.group_id} />}
{d.parent_id != null && <MetaItem label="上级记录" value={'#' + d.parent_id} />}
<MetaItem label="创建于" value={fmtDateTime(d.created_at)} />
<MetaItem label="更新于" value={fmtDateTime(d.updated_at)} />
</div>
{/* 事项要素:时间/组织方 + 类型专属字段(按模板定义顺序 = 重要性顺序) */}
<Section title="事项要素">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border border-line bg-surface-2 px-3 py-2.5 text-[12.5px]">
<MetaItem label="时间" value={fmtRange(d.start_date, d.end_date) || '—'} />
<MetaItem label="组织方" value={d.organizer || '—'} />
{fields.map(f => <MetaItem key={f.label} label={f.label} value={f.value} />)}
</div>
</Section>
{/* 附件佐证 */}
{(d.attachments?.length ?? 0) > 0 && (
<Section title={'附件(' + (d.attachments?.length ?? 0) + ')'}>
{atts.length ? (
<div className="flex flex-col gap-2.5">
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{images.map(a => (
<button
key={a.id}
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
onClick={() => setPreviewAtt(a)}
title={'点击预览:' + a.file_name}
>
{imgMap[a.id] ? (
<img
src={imgMap[a.id]}
alt={a.file_name}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.04]"
/>
) : (
<div className="h-full w-full animate-pulse bg-surface-3" />
)}
<span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/60 to-transparent px-1.5 pb-1 pt-3 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{a.file_name}
</span>
</button>
))}
</div>
)}
{docs.length > 0 && (
<div className="flex flex-col gap-1">
{docs.map(a => (
<button
key={a.id}
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
onClick={() => setPreviewAtt(a)}
>
<FileText size={14} className="shrink-0 text-ink-3" />
<span className="truncate text-ink-1">{a.file_name}</span>
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
<Paperclip size={12} className="shrink-0 text-ink-3" />
</button>
))}
</div>
)}
<div className="text-[11px] text-ink-3">点击图片 / 文件可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)</div>
</div>
) : (
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
)}
</Section>
)}
{d.description && (
<Section title="工作内容 / 描述">
@@ -164,14 +218,6 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
</Section>
)}
{fields.length > 0 && (
<Section title={type ? '类型字段 · ' + type.name : '类型字段'}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
{fields.map(f => <MetaItem key={f.label} label={f.label} value={f.value} />)}
</div>
</Section>
)}
{(d.metrics && Object.keys(d.metrics).length > 0) && (
<Section title="量化指标">
<div className="flex flex-wrap gap-1.5">
@@ -197,57 +243,13 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
</Section>
)}
<Section title={'附件(' + (d.attachments?.length ?? 0) + ')'}>
{atts.length ? (
<div className="flex flex-col gap-2.5">
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{images.map(a => (
<button
key={a.id}
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
onClick={() => setPreviewAtt(a)}
title={'点击预览:' + a.file_name}
>
{imgMap[a.id] ? (
<img
src={imgMap[a.id]}
alt={a.file_name}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.04]"
/>
) : (
<div className="h-full w-full animate-pulse bg-surface-3" />
)}
<span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/60 to-transparent px-1.5 pb-1 pt-3 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{a.file_name}
</span>
</button>
))}
</div>
)}
{docs.length > 0 && (
<div className="flex flex-col gap-1">
{docs.map(a => (
<button
key={a.id}
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
onClick={() => setPreviewAtt(a)}
>
<FileText size={14} className="shrink-0 text-ink-3" />
<span className="truncate text-ink-1">{a.file_name}</span>
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
<Paperclip size={12} className="shrink-0 text-ink-3" />
</button>
))}
</div>
)}
<div className="text-[11px] text-ink-3">点击图片 / 文件可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)</div>
</div>
) : (
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
)}
</Section>
{/* 系统信息(最末位) */}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-[11.5px] text-ink-3">
<span>创建于 {fmtDateTime(d.created_at)}</span>
<span>更新于 {fmtDateTime(d.updated_at)}</span>
{d.group_id && <span>事件组 {d.group_id}</span>}
{d.parent_id != null && <span>上级 #{d.parent_id}</span>}
</div>
</div>
)}
@@ -11,6 +11,7 @@ import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/comp
import { TagInput } from '@/components/common/TagInput';
import { DynamicForm, type TaxonomyOptionsMap } from '@/components/common/DynamicForm';
import { AttachmentPicker, fileToPending, type PendingAtt } from '@/components/common/AttachmentPicker';
import { getTypeProfile } from '@/lib/typeProfiles';
interface FormState {
type_id: number | null;
@@ -83,6 +84,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
() => typesQ.data?.items.find(t => t.id === form.type_id),
[typesQ.data, form.type_id]
);
const profile = getTypeProfile(selectedType?.name);
const parentQ = useQuery({
queryKey: ['parent-candidates', form.type_id],
@@ -182,6 +184,38 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
onSuccess: () => void qc.invalidateQueries({ queryKey: ['attachments', footprintId] })
});
const renderCoreField = (key: string) => {
if (key === 'organizer') return (
<Field label="组织方 / 所在组织" key="organizer">
<Input value={form.organizer} onChange={e => set('organizer', e.target.value)} placeholder="如:中国工业与应用数学学会" />
</Field>
);
if (key === 'role') return (
<Field label="角色" key="role">
<Select value={form.role} onChange={e => set('role', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['role'] ?? [])]} />
</Field>
);
if (key === 'level') return (
<Field label="级别" key="level">
<Select value={form.level} onChange={e => set('level', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['level'] ?? [])]} />
</Field>
);
if (key === 'award_grade') return (
<Field label="奖等" key="award_grade">
<Select value={form.award_grade} onChange={e => set('award_grade', e.target.value)}
options={[{ value: '', label: '未设置' }, ...GRADES.map(g => ({ value: g, label: g }))]} />
</Field>
);
if (key === 'rank') return (
<Field label="名次" key="rank">
<Input type="number" className="num" value={form.rank} onChange={e => set('rank', e.target.value)} placeholder="如 2" />
</Field>
);
return null;
};
return (
<Dialog
open={open}
@@ -216,60 +250,39 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-x-3 gap-y-3.5">
<Field label="组织方 / 所在组织">
<Input value={form.organizer} onChange={e => set('organizer', e.target.value)} placeholder="如:中国工业与应用数学学会" />
</Field>
<Field label="开始日期">
<Input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)} />
</Field>
<Field label="结束日期">
<Input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
<Field label="角色">
<Select value={form.role} onChange={e => set('role', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['role'] ?? [])]} />
</Field>
<Field label="级别">
<Select value={form.level} onChange={e => set('level', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['level'] ?? [])]} />
</Field>
<Field label="状态">
<Select value={form.status} onChange={e => set('status', e.target.value)} options={STATUS_OPTS} />
</Field>
<Field label="重要度">
<Select value={form.weight} onChange={e => set('weight', e.target.value)}
options={[1, 2, 3, 4, 5].map(n => ({ value: String(n), label: String(n) }))} />
</Field>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
<Field label="奖等">
<Select value={form.award_grade} onChange={e => set('award_grade', e.target.value)}
options={[{ value: '', label: '未设置' }, ...GRADES.map(g => ({ value: g, label: g }))]} />
</Field>
<Field label="名次">
<Input type="number" className="num" value={form.rank} onChange={e => set('rank', e.target.value)} placeholder="如 2" />
</Field>
<Field label="性质">
<Select value={form.polarity} onChange={e => set('polarity', e.target.value)}
options={[{ value: 'positive', label: '正面' }, { value: 'negative', label: '负面' }]} />
</Field>
<Field label="上级记录" hint="晋升链">
<Select value={form.parent_id} onChange={e => set('parent_id', e.target.value)}
options={[
{ value: '', label: '无' },
...(parentQ.data?.items ?? [])
.filter(p => p.id !== footprintId)
.map(p => ({ value: String(p.id), label: p.title.slice(0, 24) }))
]}
/>
</Field>
<div className="border-t border-line pt-4">
<div className="mb-3 flex items-center gap-2 text-[12px] font-semibold text-ink-2">
类型要素{selectedType ? ' · ' + selectedType.name : ''}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
{profile.core.map(k => renderCoreField(k))}
</div>
{selectedType && selectedType.form_template.length > 0 && (
<div className="mt-3.5">
<div className="mb-2 text-[11.5px] text-ink-3">专项信息</div>
<DynamicForm
fields={selectedType.form_template}
value={form.extras}
onChange={v => set('extras', v)}
taxonomies={taxonomies}
/>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5 border-t border-line pt-4">
<Field label="工作内容 / 描述">
<Textarea value={form.description} onChange={e => set('description', e.target.value)} placeholder="具体做了什么、承担什么分工" />
</Field>
@@ -278,7 +291,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5 border-t border-line pt-4">
<Field label="标签">
<TagInput value={form.tags} onChange={v => set('tags', v)} />
</Field>
@@ -311,25 +324,39 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3">
<Field label="事件组 ID" hint="同一场事件的多条记录共享">
<Input value={form.group_id} onChange={e => set('group_id', e.target.value)} placeholder="如 g2025001(可选)" />
</Field>
</div>
{selectedType && selectedType.form_template.length > 0 && (
<div className="border-t border-line pt-4">
<div className="mb-3 text-[12px] font-semibold text-ink-2">
类型字段 · {selectedType.name}
</div>
<DynamicForm
fields={selectedType.form_template}
value={form.extras}
onChange={v => set('extras', v)}
taxonomies={taxonomies}
/>
<details className="border-t border-line pt-4">
<summary className="cursor-pointer select-none text-[12px] text-ink-3 transition-colors hover:text-ink-2">高级选项(重要度 · 名次 · 性质 · 关联)</summary>
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
<Field label="重要度">
<Select value={form.weight} onChange={e => set('weight', e.target.value)}
options={[1, 2, 3, 4, 5].map(n => ({ value: String(n), label: String(n) }))} />
</Field>
{!profile.core.includes('rank') && (
<Field label="名次">
<Input type="number" className="num" value={form.rank} onChange={e => set('rank', e.target.value)} placeholder="如 2" />
</Field>
)}
<Field label="性质">
<Select value={form.polarity} onChange={e => set('polarity', e.target.value)}
options={[{ value: 'positive', label: '正面' }, { value: 'negative', label: '负面' }]} />
</Field>
<Field label="上级记录" hint="晋升链">
<Select value={form.parent_id} onChange={e => set('parent_id', e.target.value)}
options={[
{ value: '', label: '无' },
...(parentQ.data?.items ?? [])
.filter(p => p.id !== footprintId)
.map(p => ({ value: String(p.id), label: p.title.slice(0, 24) }))
]}
/>
</Field>
</div>
)}
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-3">
<Field label="事件组 ID" hint="同一场事件的多条记录共享">
<Input value={form.group_id} onChange={e => set('group_id', e.target.value)} placeholder="如 g2025001(可选)" />
</Field>
</div>
</details>
{footprintId ? (
<div className="border-t border-line pt-4">