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,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react';
|
||||
import { attachmentRead, footprintGet, listDimensions, listTypes } from '@/lib/api';
|
||||
import type { Attachment, FormField } from '@/lib/types';
|
||||
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
|
||||
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
|
||||
import { openOrDownload } from '@/lib/file';
|
||||
import { makeThumb } from '@/lib/img';
|
||||
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
|
||||
|
||||
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
footprintId?: number | null;
|
||||
onEdit?: (id: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['footprint', footprintId],
|
||||
queryFn: () => footprintGet(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
|
||||
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
|
||||
|
||||
const d = detailQ.data?.item;
|
||||
const type = typesQ.data?.items.find(t => t.id === d?.type_id);
|
||||
const dimNames = (d?.dimensions ?? [])
|
||||
.map(id => dimsQ.data?.items.find(x => x.id === id)?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['footprints'] });
|
||||
void qc.invalidateQueries({ queryKey: ['timeline'] });
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
toast('success', '记录已删除');
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
const openAtt = async (a: Attachment) => {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 图片直出:逐张读取为 data URL(渐进渲染)
|
||||
const [imgMap, setImgMap] = useState<Record<number, string>>({});
|
||||
const atts = d?.attachments ?? [];
|
||||
const images = atts.filter(a => a.mime?.startsWith('image/'));
|
||||
const docs = atts.filter(a => !a.mime?.startsWith('image/'));
|
||||
const attsKey = atts.map(a => a.id).join(',');
|
||||
useEffect(() => {
|
||||
setImgMap({});
|
||||
if (!open || !attsKey) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map: Record<number, string> = {};
|
||||
const list = atts.filter(a => a.mime?.startsWith('image/'));
|
||||
for (const a of list) {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
if (cancelled) return;
|
||||
// 网格展示用缩略图(降采样至 480px WebP),原图仅在点击查看时读取
|
||||
map[a.id] = await makeThumb(r.data_b64, r.mime || 'image/png');
|
||||
setImgMap({ ...map });
|
||||
} catch {
|
||||
// 解码失败则退回原图
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
map[a.id] = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
|
||||
setImgMap({ ...map });
|
||||
} catch { /* 单张失败不影响其余 */ }
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, footprintId, attsKey]);
|
||||
|
||||
// 动态字段渲染
|
||||
const fields: { label: string; value: string }[] = [];
|
||||
if (d && type) {
|
||||
for (const f of (type.form_template ?? []) as FormField[]) {
|
||||
const v = (d.extras ?? {})[f.key];
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
const text = Array.isArray(v) ? v.join('、') : String(v);
|
||||
fields.push({ label: f.label, value: f.unit ? text + ' ' + f.unit : text });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={680}
|
||||
title={d ? d.title : '记录详情'}
|
||||
footer={
|
||||
d ? (
|
||||
<>
|
||||
<Button variant="danger-ghost" className="mr-auto" onClick={() => setConfirmOpen(true)} disabled={delMut.isPending}>
|
||||
<Trash2 size={13} /> 删除
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>关闭</Button>
|
||||
<Button variant="primary" onClick={() => footprintId && onEdit?.(footprintId)}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detailQ.isLoading || !d ? (
|
||||
<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} />
|
||||
<GradeBadge grade={d.award_grade} />
|
||||
<RoleBadge role={d.role} />
|
||||
<StatusBadge status={d.status} />
|
||||
{d.polarity === 'negative' && <NegativeBadge />}
|
||||
{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>
|
||||
|
||||
{d.description && (
|
||||
<Section title="工作内容 / 描述">
|
||||
<p className="whitespace-pre-wrap text-[13px] leading-relaxed text-ink-2">{d.description}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{d.result && (
|
||||
<Section title="结果 / 产出">
|
||||
<p className="whitespace-pre-wrap rounded-md bg-success-subtle px-3 py-2 text-[13px] leading-relaxed text-success">{d.result}</p>
|
||||
</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">
|
||||
{Object.entries(d.metrics).map(([k, v]) => (
|
||||
<span key={k} className="num rounded-sm border border-line bg-surface-2 px-2 py-0.5 text-[12px] text-ink-2">
|
||||
{k}: <span className="font-medium text-ink-1">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{((d.tags?.length ?? 0) > 0 || dimNames.length > 0) && (
|
||||
<Section title="标签与维度">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{d.tags.map(t => (
|
||||
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-0.5 text-[12px] text-ink-2">{t}</span>
|
||||
))}
|
||||
{dimNames.map(n => (
|
||||
<span key={n} className="rounded-sm border border-brand/30 bg-brand-subtle px-1.5 py-0.5 text-[12px] text-brand-text">{n}</span>
|
||||
))}
|
||||
</div>
|
||||
</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={() => openAtt(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={() => openAtt(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">点击图片查看原图(原始比例)</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={confirmOpen}
|
||||
title="删除记录"
|
||||
content={d ? '确定删除「' + d.title + '」吗?该操作不可撤销。' : ''}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onOk={() => delMut.mutate()}
|
||||
loading={delMut.isPending}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<span className="shrink-0 text-ink-3">{label}</span>
|
||||
<span className="min-w-0 truncate font-medium text-ink-1" title={value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-semibold text-ink-2">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
attachmentAdd, attachmentDelete, attachmentList, attachmentRead,
|
||||
footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes
|
||||
} from '@/lib/api';
|
||||
import type { Attachment } from '@/lib/types';
|
||||
import type { FootprintInput } from '@/lib/types';
|
||||
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
import { TagInput } from '@/components/common/TagInput';
|
||||
import { DynamicForm, type TaxonomyOptionsMap } from '@/components/common/DynamicForm';
|
||||
import { AttachmentPicker, fileToPending, type PendingAtt } from '@/components/common/AttachmentPicker';
|
||||
|
||||
interface FormState {
|
||||
type_id: number | null;
|
||||
title: string;
|
||||
organizer: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
role: string;
|
||||
level: string;
|
||||
award_grade: string;
|
||||
rank: string;
|
||||
polarity: string;
|
||||
weight: string;
|
||||
description: string;
|
||||
result: string;
|
||||
tags: string[];
|
||||
dimension_ids: number[];
|
||||
group_id: string;
|
||||
parent_id: string;
|
||||
extras: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const BLANK: FormState = {
|
||||
type_id: null, title: '', organizer: '', start_date: '', end_date: '',
|
||||
status: 'draft', role: '', level: '', award_grade: '', rank: '', polarity: 'positive',
|
||||
weight: '3', description: '', result: '', tags: [], dimension_ids: [],
|
||||
group_id: '', parent_id: '', extras: {}
|
||||
};
|
||||
|
||||
const GRADES = ['特等奖', '一等奖', '二等奖', '三等奖', '优秀奖', '无'];
|
||||
const MAX_ATT_BYTES = 80 * 1024 * 1024; // 80MB(Base64 膨胀后约 107MB,低于桥接/服务端 160MB 上限)
|
||||
const STATUS_OPTS = [
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'ongoing', label: '进行中' },
|
||||
{ value: 'submitted', label: '已提交待果' },
|
||||
{ value: 'finished', label: '已完结' }
|
||||
];
|
||||
|
||||
import { openOrDownload } from '@/lib/file';
|
||||
|
||||
export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
footprintId?: number | null;
|
||||
onSaved?: (id: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<FormState>(BLANK);
|
||||
const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]);
|
||||
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
|
||||
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy(), enabled: open });
|
||||
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
|
||||
const detailQ = useQuery({
|
||||
queryKey: ['footprint', footprintId],
|
||||
queryFn: () => footprintGet(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
const attsQ = useQuery({
|
||||
queryKey: ['attachments', footprintId],
|
||||
queryFn: () => attachmentList(footprintId!),
|
||||
enabled: open && !!footprintId
|
||||
});
|
||||
|
||||
const selectedType = useMemo(
|
||||
() => typesQ.data?.items.find(t => t.id === form.type_id),
|
||||
[typesQ.data, form.type_id]
|
||||
);
|
||||
|
||||
const parentQ = useQuery({
|
||||
queryKey: ['parent-candidates', form.type_id],
|
||||
queryFn: () => footprintList({ types: form.type_id ? [form.type_id] : undefined, page_size: 200, sort: 'date_desc' }),
|
||||
enabled: open && !!form.type_id
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError('');
|
||||
setPendingAtts([]);
|
||||
if (footprintId && detailQ.data) {
|
||||
const d = detailQ.data.item;
|
||||
setForm({
|
||||
type_id: d.type_id, title: d.title, organizer: d.organizer ?? '',
|
||||
start_date: (d.start_date ?? '').slice(0, 10), end_date: (d.end_date ?? '').slice(0, 10),
|
||||
status: d.status, role: d.role ?? '', level: d.level ?? '',
|
||||
award_grade: d.award_grade ?? '', rank: d.rank != null ? String(d.rank) : '',
|
||||
polarity: d.polarity, weight: String(d.weight ?? 3),
|
||||
description: d.description ?? '', result: d.result ?? '',
|
||||
tags: d.tags ?? [], dimension_ids: d.dimensions ?? [],
|
||||
group_id: d.group_id ?? '', parent_id: d.parent_id != null ? String(d.parent_id) : '',
|
||||
extras: d.extras ?? {}
|
||||
});
|
||||
} else {
|
||||
setForm(BLANK);
|
||||
}
|
||||
}, [open, footprintId, detailQ.data]);
|
||||
|
||||
const taxonomies: TaxonomyOptionsMap = useMemo(() => {
|
||||
const map: TaxonomyOptionsMap = {};
|
||||
for (const t of taxQ.data?.items ?? []) {
|
||||
(map[t.kind] ??= []).push({ value: t.value, label: t.value });
|
||||
}
|
||||
return map;
|
||||
}, [taxQ.data]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!form.type_id) throw new Error('请选择记录类型');
|
||||
if (!form.title.trim()) throw new Error('请填写标题');
|
||||
|
||||
const metrics: Record<string, number> = {};
|
||||
for (const f of selectedType?.form_template ?? []) {
|
||||
if (f.metric && f.widget === 'number') {
|
||||
const v = form.extras[f.key];
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) metrics[f.metric] = v;
|
||||
}
|
||||
}
|
||||
const input: FootprintInput = {
|
||||
id: footprintId ?? undefined,
|
||||
type_id: form.type_id,
|
||||
title: form.title.trim(),
|
||||
organizer: form.organizer || null,
|
||||
start_date: form.start_date || null,
|
||||
end_date: form.end_date || null,
|
||||
status: form.status,
|
||||
role: form.role || null,
|
||||
level: form.level || null,
|
||||
award_grade: form.award_grade && form.award_grade !== '无' ? form.award_grade : null,
|
||||
rank: form.rank ? Number(form.rank) : null,
|
||||
polarity: form.polarity,
|
||||
description: form.description,
|
||||
result: form.result,
|
||||
tags: form.tags,
|
||||
dimension_ids: form.dimension_ids,
|
||||
group_id: form.group_id || null,
|
||||
parent_id: form.parent_id ? Number(form.parent_id) : null,
|
||||
weight: Number(form.weight) || 3,
|
||||
extras: form.extras,
|
||||
metrics
|
||||
};
|
||||
const saved = await footprintUpsert(input);
|
||||
for (const p of pendingAtts) {
|
||||
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
|
||||
}
|
||||
return saved.id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
void qc.invalidateQueries({ queryKey: ['footprints'] });
|
||||
void qc.invalidateQueries({ queryKey: ['timeline'] });
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] });
|
||||
void qc.invalidateQueries({ queryKey: ['footprint'] });
|
||||
void qc.invalidateQueries({ queryKey: ['attachments', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['types'] });
|
||||
toast('success', footprintId ? '记录已更新' : '记录已创建');
|
||||
onSaved?.(id);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message)
|
||||
});
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, v: FormState[K]) => setForm(f => ({ ...f, [key]: v }));
|
||||
|
||||
const delAtt = useMutation({
|
||||
mutationFn: (id: number) => attachmentDelete(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['attachments', footprintId] })
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={760}
|
||||
title={footprintId ? '编辑记录' : '新建记录'}
|
||||
footer={
|
||||
<>
|
||||
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
||||
{saveMut.isPending ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-x-3 gap-y-3.5">
|
||||
<Field label="类型" required>
|
||||
<Select
|
||||
value={form.type_id != null ? String(form.type_id) : ''}
|
||||
onChange={e => set('type_id', e.target.value ? Number(e.target.value) : null)}
|
||||
options={[
|
||||
{ value: '', label: '请选择' },
|
||||
...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题" required>
|
||||
<Input value={form.title} onChange={e => set('title', e.target.value)} placeholder="事项名称,如:2025 全国大学生数学建模竞赛" />
|
||||
</Field>
|
||||
</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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
|
||||
<Field label="工作内容 / 描述">
|
||||
<Textarea value={form.description} onChange={e => set('description', e.target.value)} placeholder="具体做了什么、承担什么分工" />
|
||||
</Field>
|
||||
<Field label="结果 / 产出">
|
||||
<Textarea value={form.result} onChange={e => set('result', e.target.value)} placeholder="获奖等级、成果、最终产品等" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
|
||||
<Field label="标签">
|
||||
<TagInput value={form.tags} onChange={v => set('tags', v)} />
|
||||
</Field>
|
||||
<Field label="成长维度">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-8 items-center">
|
||||
{(dimsQ.data?.items ?? []).map(d => {
|
||||
const active = form.dimension_ids.includes(d.id);
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
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('dimension_ids', active
|
||||
? form.dimension_ids.filter(x => x !== d.id)
|
||||
: [...form.dimension_ids, d.id])
|
||||
}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!dimsQ.data?.items.length && <span className="text-[12px] text-ink-3">尚未配置维度(配置页可添加)</span>}
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footprintId ? (
|
||||
<div className="border-t border-line pt-4">
|
||||
<AttachmentPicker
|
||||
items={attsQ.data?.items ?? []}
|
||||
pending={pendingAtts}
|
||||
uploading={uploadingAtt}
|
||||
onPick={async files => {
|
||||
if (!footprintId) return;
|
||||
setUploadingAtt(true);
|
||||
try {
|
||||
let done = 0;
|
||||
for (const f of Array.from(files)) {
|
||||
if (f.size > MAX_ATT_BYTES) {
|
||||
toast('error', '「' + f.name + '」超过 80MB,已跳过');
|
||||
continue;
|
||||
}
|
||||
const p = await fileToPending(f);
|
||||
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
|
||||
done++;
|
||||
}
|
||||
if (done > 0) {
|
||||
void qc.invalidateQueries({ queryKey: ['attachments', footprintId] });
|
||||
toast('success', '已上传 ' + done + ' 个附件');
|
||||
}
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
} finally {
|
||||
setUploadingAtt(false);
|
||||
}
|
||||
}}
|
||||
onDeleteSaved={id => delAtt.mutate(id)}
|
||||
onDeletePending={() => {}}
|
||||
onOpenSaved={async (a: Attachment) => {
|
||||
try {
|
||||
const r = await attachmentRead(a.id);
|
||||
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
|
||||
} catch (e) {
|
||||
toast('error', (e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1.5 text-[11px] text-ink-3">
|
||||
点击文件名可预览(支持图片 / PDF,其余下载)· 单个文件不超过 80MB
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-line pt-4">
|
||||
<AttachmentPicker
|
||||
items={[]}
|
||||
pending={pendingAtts}
|
||||
onPick={async files => {
|
||||
const list: PendingAtt[] = [];
|
||||
for (const f of Array.from(files)) {
|
||||
if (f.size > MAX_ATT_BYTES) {
|
||||
toast('error', '「' + f.name + '」超过 80MB,已跳过');
|
||||
continue;
|
||||
}
|
||||
list.push(await fileToPending(f));
|
||||
}
|
||||
setPendingAtts(prev => [...prev, ...list]);
|
||||
}}
|
||||
onDeleteSaved={() => {}}
|
||||
onDeletePending={idx => setPendingAtts(prev => prev.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
<div className="mt-1.5 text-[11px] text-ink-3">
|
||||
保存记录时将一并上传 · 单个文件不超过 80MB
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user