feat: 足迹 Footprints 个人留痕系统

桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
root
2026-10-16 13:53:15 +08:00
commit 42ca57abfd
107 changed files with 234676 additions and 0 deletions
@@ -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>
);
}