ui: type-aware editor form + detail reordered by importance
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 类型感知字段配置:不同类型的编辑表单与详情展示按此配置裁剪、排序
|
||||
export interface TypeProfile {
|
||||
/** 核心静态字段(类型要素区,按重要性排序) */
|
||||
core: ('organizer' | 'role' | 'level' | 'award_grade' | 'rank')[];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const PROFILES: Record<string, TypeProfile> = {
|
||||
'竞赛': { core: ['level', 'award_grade', 'role', 'organizer'] },
|
||||
'科研': { core: ['level', 'role', 'organizer'] },
|
||||
'实习实践': { core: ['organizer'] },
|
||||
'学生工作': { core: ['role', 'organizer'] },
|
||||
'志愿公益': { core: ['organizer'] },
|
||||
'文体活动': { core: ['level', 'organizer'] },
|
||||
'证书技能': { core: ['level', 'organizer'] },
|
||||
'荣誉惩处': { core: ['award_grade', 'level', 'organizer'] },
|
||||
'学业': { core: ['rank'] },
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILE: TypeProfile = { core: ['level', 'role', 'award_grade', 'organizer'] };
|
||||
|
||||
export function getTypeProfile(typeName?: string | null): TypeProfile {
|
||||
if (!typeName) return DEFAULT_PROFILE;
|
||||
return PROFILES[typeName] ?? DEFAULT_PROFILE;
|
||||
}
|
||||
Reference in New Issue
Block a user