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
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import * as echarts from 'echarts/core';
import { BarChart, PieChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { statsOverview } from '@/lib/api';
import { Card, CardBody, CardHeader, Select } from '@/components/ui';
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
echarts.use([BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
export function DashboardPage() {
const [year, setYear] = useState('');
const themeTick = useThemeTick();
const statsQ = useQuery({
queryKey: ['stats', year || undefined],
queryFn: () => statsOverview(year ? Number(year) : undefined)
});
const s = statsQ.data;
const ink2 = inkColor('2');
const ink3 = inkColor('3');
const monthlyRef = useChart(() => ({
grid: { left: 34, right: 12, top: 14, bottom: 24 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: s?.monthly.map(m => m.ym.slice(2)) ?? [],
axisLine: { lineStyle: { color: ink3 } },
axisTick: { show: false },
axisLabel: { color: ink3, fontSize: 10 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } },
axisLabel: { color: ink3, fontSize: 10 }
},
series: [{
type: 'bar',
data: s?.monthly.map(m => m.count) ?? [],
barMaxWidth: 18,
itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }
}]
}), [s, themeTick]);
const typeRef = useChart(() => {
const items = (s?.by_type ?? []).filter(t => t.count > 0);
return {
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', right: 4, top: 'middle', textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
series: [{
type: 'pie',
radius: ['48%', '72%'],
center: ['34%', '50%'],
label: { show: false },
data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color || '#64748b' } }))
}]
};
}, [s, themeTick]);
const levelRef = useChart(() => {
const items = (s?.by_level ?? []).filter(l => l.count > 0).slice().reverse();
return {
grid: { left: 64, right: 24, top: 8, bottom: 22 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } },
axisLabel: { color: ink3, fontSize: 10 }
},
yAxis: {
type: 'category',
data: items.map(l => l.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: ink2, fontSize: 11 }
},
series: [{
type: 'bar',
data: items.map(l => ({
value: l.count,
itemStyle: { color: l.color || '#3056d3', borderRadius: [0, 3, 3, 0] }
})),
barMaxWidth: 14
}]
};
}, [s, themeTick]);
const metricCards = (s?.metrics ?? []).slice(0, 3);
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-3">
<MetricCard label="总记录" value={s?.total ?? 0} />
<MetricCard label={year ? year + ' 年新增' : '本年新增'} value={s?.total_this_year ?? 0} />
<MetricCard label="附件总数" value={s?.attachment_total ?? 0} />
{metricCards.map(m => (
<MetricCard key={m.key} label={m.label} value={m.value} unit={m.unit ?? ''} />
))}
</div>
<Select
className="w-[120px]"
value={year}
onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...(s?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]}
/>
</div>
{!s?.metrics.length && (
<div className="mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3">
提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="近 12 个月活跃度" />
<CardBody><div ref={monthlyRef} className="h-[220px]" /></CardBody>
</Card>
<Card>
<CardHeader title="类型分布" />
<CardBody><div ref={typeRef} className="h-[220px]" /></CardBody>
</Card>
<Card className="col-span-2">
<CardHeader title="级别分布" />
<CardBody><div ref={levelRef} className="h-[200px]" /></CardBody>
</Card>
</div>
</div>
);
}
function MetricCard({ label, value, unit }: { label: string; value: number; unit?: string }) {
return (
<div className="min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
<div className="text-[12px] text-ink-3">{label}</div>
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">
{value}
{unit && <span className="ml-1 text-[12px] font-normal text-ink-3">{unit}</span>}
</div>
</div>
);
}
+511
View File
@@ -0,0 +1,511 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as echarts from 'echarts/core';
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { Plus, Trash2, Pencil, Calculator, GraduationCap } from 'lucide-react';
import { bandDelete, bandUpsert, courseDelete, courseUpsert, gradesOverview, semesterDelete, semesterUpsert } from '@/lib/api';
import type { CourseGrade, GradeBand, SemesterGrades } from '@/lib/types';
import { Badge, Button, Card, CardBody, CardHeader, Confirm, Dialog, Empty, Field, Input, useToast } from '@/components/ui';
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
function termYear(term: string): string {
const m = /^(\d{4})-(\d{4})/.exec(term);
return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期';
}
function autoGpa(courses: CourseGrade[]): number | null {
const withGp = courses.filter(c => c.grade_point != null && c.credit > 0);
if (!withGp.length) return null;
const credits = withGp.reduce((a, c) => a + c.credit, 0);
const weighted = withGp.reduce((a, c) => a + (c.grade_point as number) * c.credit, 0);
return Math.round((weighted / credits) * 100) / 100;
}
export function GradesPage() {
const themeTick = useThemeTick();
const overviewQ = useQuery({ queryKey: ['grades'], queryFn: gradesOverview });
const s = overviewQ.data;
const ink2 = inkColor('2');
const ink3 = inkColor('3');
const tierRef = useChart(() => {
const items = (s?.overall.tier_dist ?? []).filter(t => t.count > 0);
return {
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', right: 8, top: 'middle', itemGap: 10, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
series: [{
type: 'pie', radius: ['48%', '72%'], center: ['34%', '50%'], label: { show: false },
data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color } }))
}]
};
}, [s, themeTick]);
const trendRef = useChart(() => {
const t = s?.overall.gpa_trend ?? [];
return {
grid: { left: 8, right: 8, top: 36, bottom: 6, containLabel: true },
tooltip: { trigger: 'axis' },
legend: { top: 0, left: 'center', itemGap: 16, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
xAxis: {
type: 'category', data: t.map(x => x.term),
axisLine: { lineStyle: { color: ink3 } }, axisTick: { show: false },
axisLabel: { color: ink3, fontSize: 10 }
},
yAxis: [
{ type: 'value', min: 0, max: 100, splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } }, axisLabel: { color: ink3, fontSize: 10 } },
{ type: 'value', min: 0, max: 5, splitLine: { show: false }, axisLabel: { color: ink3, fontSize: 10 } }
],
series: [
{ name: '加权平均分', type: 'bar', barMaxWidth: 18, itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }, data: t.map(x => x.avg) },
{ name: '绩点', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, itemStyle: { color: '#d97706' }, lineStyle: { width: 2 }, data: t.map(x => x.gpa) }
]
};
}, [s, themeTick]);
if (overviewQ.isLoading) {
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
}
const semesters = [...(s?.semesters ?? [])].sort((a, b) => b.term.localeCompare(a.term));
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
{/* 总览指标 */}
<div className="mb-4 flex flex-wrap gap-3">
<MetricCard label="总学分" value={s?.overall.credits ?? 0} />
<MetricCard label="加权平均分" value={s?.overall.avg != null ? s.overall.avg : '—'} />
<MetricCard label="已录课程" value={s?.overall.courses ?? 0} />
<MetricCard label="学期数" value={s?.semesters.length ?? 0} />
</div>
{/* 总览图表 */}
<div className="mb-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="单科成绩等级分布" />
<CardBody><div ref={tierRef} className="h-[220px]" /></CardBody>
</Card>
<Card>
<CardHeader title="学期趋势(加权平均分 / 绩点)" />
<CardBody><div ref={trendRef} className="h-[220px]" /></CardBody>
</Card>
</div>
{/* 成绩梯度设置 */}
<BandEditor bands={s?.bands ?? []} />
{/* 学年学科成绩 */}
<div className="mb-3 mt-5 flex items-center justify-between">
<h2 className="text-[14px] font-semibold text-ink-1">学年学科成绩</h2>
<AddSemesterButton />
</div>
{!semesters.length ? (
<Card><Empty
icon={<GraduationCap size={36} strokeWidth={1.5} />}
title="暂无学期成绩"
description="新增一个学期(如 2024-2025-1),再录入各科成绩。"
/></Card>
) : (
<div className="flex flex-col gap-5">
{groupByYear(semesters).map(([year, list]) => (
<section key={year}>
<div className="mb-2 text-[12px] font-medium text-ink-3">{year}</div>
<div className="flex flex-col gap-3">
{list.map(sem => <SemesterCard key={sem.id} sem={sem} />)}
</div>
</section>
))}
</div>
)}
</div>
);
}
function groupByYear(semesters: SemesterGrades[]): [string, SemesterGrades[]][] {
const map = new Map<string, SemesterGrades[]>();
for (const s of semesters) {
const y = termYear(s.term);
if (!map.has(y)) map.set(y, []);
map.get(y)!.push(s);
}
return [...map.entries()];
}
function MetricCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="min-w-[128px] flex-1 max-w-[220px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
<div className="text-[12px] text-ink-3">{label}</div>
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">{value}</div>
</div>
);
}
// ---------- 梯度设置 ----------
function BandEditor({ bands }: { bands: GradeBand[] }) {
const qc = useQueryClient();
const toast = useToast();
const [adding, setAdding] = useState<GradeBand | null>(null);
const [deleting, setDeleting] = useState<GradeBand | null>(null);
const upMut = useMutation({
mutationFn: bandUpsert,
onSuccess: () => {
toast('success', '梯度已保存');
void qc.invalidateQueries({ queryKey: ['grades'] });
},
onError: (e: Error) => toast('error', e.message)
});
const delMut = useMutation({
mutationFn: bandDelete,
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); setDeleting(null); }
});
return (
<Card>
<CardHeader
title="成绩梯度"
extra={<span className="text-[11px] text-ink-3">按 分数 ≥ min 且 &lt; max 定级,满分档含上限</span>}
/>
<CardBody className="p-0">
<div className="divide-y divide-line">
{bands.map(b => (
<BandRow key={b.id} band={b} onSave={(next) => upMut.mutate(next)} onDelete={() => setDeleting(b)} />
))}
</div>
<div className="border-t border-line px-4 py-2.5">
{adding ? (
<BandRow
band={adding}
isNew
onChange={setAdding}
onSave={() => { upMut.mutate(adding); setAdding(null); }}
onCancel={() => setAdding(null)}
/>
) : (
<Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
<Plus size={13} /> 添加等级
</Button>
)}
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除等级"
content={deleting ? '确定删除「' + deleting.label + '」吗?已录成绩将不再显示该等级。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
band: GradeBand;
isNew?: boolean;
onSave?: (b: GradeBand) => void;
onDelete?: () => void;
onChange?: (b: GradeBand) => void;
onCancel?: () => void;
}) {
const [draft, setDraft] = useState(band);
const value = isNew && onChange ? draft : band;
const set = (patch: Partial<GradeBand>) => {
const next = { ...value, ...patch };
if (onChange) onChange(next); else setDraft(next);
};
const dirty = isNew || JSON.stringify({ ...value, id: 0 }) !== JSON.stringify({ ...band, id: 0 });
return (
<div className="flex flex-wrap items-center gap-2 px-4 py-2">
<input type="color" value={value.color} onChange={e => set({ color: e.target.value })}
className="size-5 shrink-0 cursor-pointer rounded border border-line bg-surface" aria-label="颜色" />
<Input className="w-[96px]" value={value.label} onChange={e => set({ label: e.target.value })} placeholder="等级名" />
<span className="text-[12px] text-ink-3">分数</span>
<Input type="number" className="num w-[72px]" value={String(value.min)} onChange={e => set({ min: Number(e.target.value) })} />
<span className="text-[12px] text-ink-3">至</span>
<Input type="number" className="num w-[72px]" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
{isNew ? (
<>
<Button size="sm" variant="primary" onClick={() => onSave?.(value)}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => onCancel?.()}>取消</Button>
</>
) : (
<div className="ml-auto flex gap-1.5">
<Button size="sm" variant={dirty ? 'primary' : 'secondary'} disabled={!dirty} onClick={() => onSave?.(value)}>保存</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除等级" onClick={() => onDelete?.()}>
<Trash2 size={13} />
</Button>
</div>
)}
</div>
);
}
// ---------- 学期卡片 ----------
function SemesterCard({ sem }: { sem: SemesterGrades }) {
const qc = useQueryClient();
const toast = useToast();
const [editingId, setEditingId] = useState<number | null>(null);
const [deleting, setDeleting] = useState<CourseGrade | null>(null);
const [semDialog, setSemDialog] = useState(false);
const invalidate = () => void qc.invalidateQueries({ queryKey: ['grades'] });
const delCourse = useMutation({
mutationFn: courseDelete,
onSuccess: () => { invalidate(); setDeleting(null); }
});
const autoGpaMut = useMutation({
mutationFn: () => {
const gpa = autoGpa(sem.courses);
if (gpa == null) throw new Error('请先为课程填写绩点');
return semesterUpsert({ id: sem.id, term: sem.term, gpa, rank: sem.rank ?? null, total_students: sem.total_students ?? null });
},
onSuccess: () => { toast('success', '绩点已按课程自动计算'); invalidate(); },
onError: (e: Error) => toast('error', e.message)
});
const delSem = useMutation({
mutationFn: () => semesterDelete(sem.id),
onSuccess: () => { invalidate(); setSemDialog(false); toast('success', '学期已删除'); }
});
return (
<Card>
<CardHeader
title={sem.term}
extra={
<div className="flex items-center gap-2">
<Badge color="var(--brand-text)">绩点 {sem.gpa ?? '—'}</Badge>
{sem.rank != null && <Badge color="var(--success)">排名 {sem.rank}/{sem.total_students ?? '—'}</Badge>}
<Badge color="var(--text-3)">均分 {sem.stats.avg ?? '—'}</Badge>
<Badge color="var(--text-3)">{sem.stats.credits} 学分</Badge>
<Button size="sm" variant="ghost" onClick={() => autoGpaMut.mutate()} disabled={autoGpaMut.isPending}>
<Calculator size={13} /> 自动绩点
</Button>
<Button size="icon-sm" variant="ghost" aria-label="编辑学期" onClick={() => setSemDialog(true)}>
<Pencil size={13} />
</Button>
</div>
}
/>
<CardBody className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => (
<th key={h} className="border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3">{h}</th>
))}
</tr>
</thead>
<tbody>
{sem.courses.map(c => (
editingId === c.id
? <CourseEditRow key={c.id} sem={sem} course={c} onDone={() => setEditingId(null)} />
: (
<tr key={c.id} className="transition-colors hover:bg-surface-2">
<td className="border-b border-line px-3 py-2 font-medium text-ink-1">{c.course_name}</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.credit}</td>
<td className="num border-b border-line px-3 py-2">
{c.score != null ? <span className="font-medium">{c.score}</span> : <span className="text-ink-3">未录入</span>}
</td>
<td className="border-b border-line px-3 py-2">
{c.band ? <Badge color={c.band.color}>{c.band.label}</Badge> : <span className="text-ink-3">—</span>}
</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.grade_point ?? '—'}</td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1">
<Button size="icon-sm" variant="ghost" aria-label="编辑课程" onClick={() => setEditingId(c.id)}>
<Pencil size={13} />
</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除课程" className="text-ink-3 hover:text-danger" onClick={() => setDeleting(c)}>
<Trash2 size={13} />
</Button>
</div>
</td>
</tr>
)
))}
<CourseAddRow sem={sem} />
</tbody>
</table>
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除课程"
content={deleting ? '确定删除「' + deleting.course_name + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delCourse.mutate(deleting.id)}
/>
<SemesterDialog
open={semDialog}
sem={sem}
onClose={() => setSemDialog(false)}
deleteMut={delSem}
/>
</Card>
);
}
function CourseAddRow({ sem }: { sem: SemesterGrades }) {
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState('');
const [credit, setCredit] = useState('');
const [score, setScore] = useState('');
const [gp, setGp] = useState('');
const addMut = useMutation({
mutationFn: () => courseUpsert({
semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp)
}),
onSuccess: () => {
setName(''); setCredit(''); setScore(''); setGp('');
void qc.invalidateQueries({ queryKey: ['grades'] });
},
onError: (e: Error) => toast('error', e.message)
});
return (
<tr className="bg-surface-2">
<td className="px-3 py-2"><Input className="h-7 w-[160px]" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" placeholder="0" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" className="num h-7 w-[72px]" placeholder="0-100" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="px-3 py-2 text-ink-3">—</td>
<td className="px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" placeholder="选填" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="px-3 py-2">
<Button size="sm" variant="secondary" disabled={!name.trim() || addMut.isPending} onClick={() => addMut.mutate()}>
<Plus size={13} /> 添加
</Button>
</td>
</tr>
);
}
function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: CourseGrade; onDone: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState(course.course_name);
const [credit, setCredit] = useState(String(course.credit));
const [score, setScore] = useState(course.score != null ? String(course.score) : '');
const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : '');
const saveMut = useMutation({
mutationFn: () => courseUpsert({
id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp)
}),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); },
onError: (e: Error) => toast('error', e.message)
});
return (
<tr className="bg-brand-subtle/40">
<td className="border-b border-line px-3 py-2"><Input className="h-7 w-[160px]" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" className="num h-7 w-[72px]" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">保存后按梯度定级</td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1.5">
<Button size="sm" variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
<Button size="sm" variant="ghost" onClick={onDone}>取消</Button>
</div>
</td>
</tr>
);
}
// ---------- 学期编辑 ----------
function AddSemesterButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" size="md" onClick={() => setOpen(true)}>
<Plus size={14} /> 新增学期
</Button>
<SemesterDialog open={open} sem={null} onClose={() => setOpen(false)} />
</>
);
}
function SemesterDialog({ open, sem, onClose, deleteMut }: {
open: boolean;
sem: SemesterGrades | null;
onClose: () => void;
deleteMut?: { mutate: () => void; isPending: boolean };
}) {
const qc = useQueryClient();
const toast = useToast();
const [term, setTerm] = useState(sem?.term ?? '');
const [gpa, setGpa] = useState(sem?.gpa != null ? String(sem.gpa) : '');
const [rank, setRank] = useState(sem?.rank != null ? String(sem.rank) : '');
const [total, setTotal] = useState(sem?.total_students != null ? String(sem.total_students) : '');
const [error, setError] = useState('');
const saveMut = useMutation({
mutationFn: () => semesterUpsert({
id: sem?.id, term, gpa: gpa === '' ? null : Number(gpa),
rank: rank === '' ? null : Number(rank), total_students: total === '' ? null : Number(total)
}),
onSuccess: () => {
toast('success', sem ? '学期已更新' : '学期已新增');
void qc.invalidateQueries({ queryKey: ['grades'] });
onClose();
},
onError: (e: Error) => setError(e.message)
});
return (
<Dialog
open={open}
onClose={onClose}
width={480}
title={sem ? '编辑学期 · ' + sem.term : '新增学期'}
footer={
<>
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
{sem && (
<Button variant="danger-ghost" className="mr-auto" onClick={() => deleteMut?.mutate()} disabled={deleteMut?.isPending}>
<Trash2 size={13} /> 删除学期
</Button>
)}
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
</>
}
>
<div className="flex flex-col gap-3.5">
<Field label="学期名称" required hint="如 2024-2025-1">
<Input value={term} onChange={e => setTerm(e.target.value)} placeholder="2024-2025-1" />
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="绩点">
<Input type="number" step="0.01" className="num" value={gpa} onChange={e => setGpa(e.target.value)} placeholder="选填" />
</Field>
<Field label="排名">
<Input type="number" className="num" value={rank} onChange={e => setRank(e.target.value)} placeholder="选填" />
</Field>
<Field label="年级人数">
<Input type="number" className="num" value={total} onChange={e => setTotal(e.target.value)} placeholder="选填" />
</Field>
</div>
<p className="flex items-start gap-1.5 rounded-md bg-surface-2 px-3 py-2 text-[12px] text-ink-3">
<Calculator size={13} className="mt-0.5 shrink-0" />
绩点可手动填写,也可在卡片上点「自动绩点」按课程学分加权计算。
</p>
</div>
</Dialog>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react';
import {
footprintDelete, footprintList, listTaxonomy, listTypes, statsOverview
} from '@/lib/api';
import type { FootprintRow } from '@/lib/types';
import {
Button, Confirm, Dropdown, DropdownItem, Empty, Input, Select, Table, TableWrap, Td, Th, Tr, useToast
} from '@/components/ui';
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge } from '@/components/common/badges';
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
import { fmtRange, fmtDateTime } from '@/lib/format';
const PAGE_SIZE = 20;
export function RecordsPage() {
const qc = useQueryClient();
const toast = useToast();
const [params, setParams] = useSearchParams();
const [q, setQ] = useState(params.get('q') ?? '');
const [typeId, setTypeId] = useState('');
const [year, setYear] = useState('');
const [level, setLevel] = useState('');
const [role, setRole] = useState('');
const [status, setStatus] = useState('');
const [sort, setSort] = useState('date_desc');
const [page, setPage] = useState(1);
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleting, setDeleting] = useState<FootprintRow | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// 新建入口:/records?new=1
useEffect(() => {
if (params.get('new') === '1') {
setEditingId(null);
setEditorOpen(true);
params.delete('new');
setParams(params, { replace: true });
}
}, [params, setParams]);
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
const yearsQ = useQuery({ queryKey: ['stats', undefined], queryFn: () => statsOverview(undefined) });
const query = useMemo(() => ({
q: q || undefined,
types: typeId ? [Number(typeId)] : undefined,
levels: level ? [level] : undefined,
roles: role ? [role] : undefined,
statuses: status ? [status] : undefined,
year: year ? Number(year) : undefined,
sort,
page,
page_size: PAGE_SIZE
}), [q, typeId, year, level, role, status, sort, page]);
const listQ = useQuery({ queryKey: ['footprints', query], queryFn: () => footprintList(query) });
useEffect(() => { setPage(1); }, [q, typeId, year, level, role, status]);
const delMut = useMutation({
mutationFn: (id: number) => footprintDelete(id),
onSuccess: () => {
toast('success', '记录已删除');
void qc.invalidateQueries({ queryKey: ['footprints'] });
void qc.invalidateQueries({ queryKey: ['timeline'] });
void qc.invalidateQueries({ queryKey: ['stats'] });
setDeleting(null);
}
});
const total = listQ.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const openNew = () => { setEditingId(null); setEditorOpen(true); };
const openEdit = (id: number) => { setEditingId(id); setEditorOpen(true); };
const openDetail = (id: number) => { setDetailId(id); setDetailOpen(true); };
return (
<div className="px-6 py-5">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input
className="w-[220px]"
placeholder="搜索标题、内容、结果、标签…"
value={q}
onChange={e => setQ(e.target.value)}
/>
<Select className="w-[130px]" value={typeId} onChange={e => setTypeId(e.target.value)}
options={[{ value: '', label: '全部类型' }, ...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))]} />
<Select className="w-[110px]" value={year} onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...(yearsQ.data?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]} />
<Select className="w-[110px]" value={level} onChange={e => setLevel(e.target.value)}
options={[{ value: '', label: '全部级别' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'level').map(t => ({ value: t.value, label: t.value }))]} />
<Select className="w-[100px]" value={role} onChange={e => setRole(e.target.value)}
options={[{ value: '', label: '全部角色' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'role').map(t => ({ value: t.value, label: t.value }))]} />
<Select className="w-[110px]" value={status} onChange={e => setStatus(e.target.value)}
options={[
{ value: '', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'ongoing', label: '进行中' },
{ value: 'submitted', label: '已提交待果' },
{ value: 'finished', label: '已完结' }
]} />
<Select className="w-[130px]" value={sort} onChange={e => setSort(e.target.value)}
options={[
{ value: 'date_desc', label: '时间从新到旧' },
{ value: 'date_asc', label: '时间从旧到新' },
{ value: 'updated_desc', label: '最近修改' },
{ value: 'weight_desc', label: '重要度优先' }
]} />
<span className="num ml-auto text-[12px] text-ink-3">共 {total} 条</span>
<Button variant="primary" size="md" onClick={openNew}>
<Plus size={14} />
新建
</Button>
</div>
<div className="rounded-lg border border-line bg-surface shadow-card">
<TableWrap className="max-h-[calc(100vh-260px)] overflow-y-auto">
<Table>
<thead>
<tr>
<Th className="w-[32%]">标题</Th>
<Th>类型</Th>
<Th>时间</Th>
<Th>级别</Th>
<Th>奖等</Th>
<Th>角色</Th>
<Th>状态</Th>
<Th className="text-right">附件</Th>
<Th>更新时间</Th>
<Th className="w-[52px]" />
</tr>
</thead>
<tbody>
{(listQ.data?.items ?? []).map(row => (
<Tr key={row.id} onClick={() => openDetail(row.id)}>
<Td>
<div className="flex items-center gap-2">
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: row.type_color }} />
<span className="truncate font-medium" title={row.title}>{row.title}</span>
</div>
</Td>
<Td className="whitespace-nowrap text-ink-2">{row.type_name}</Td>
<Td className="num whitespace-nowrap text-ink-2">{fmtRange(row.start_date, row.end_date)}</Td>
<Td><LevelBadge level={row.level} /></Td>
<Td><GradeBadge grade={row.award_grade} /></Td>
<Td><RoleBadge role={row.role} /></Td>
<Td><StatusBadge status={row.status} /></Td>
<Td className="num text-right text-ink-2">{row.attachment_count > 0 ? row.attachment_count : ''}</Td>
<Td className="num whitespace-nowrap text-ink-3">{fmtDateTime(row.updated_at)}</Td>
<Td onClick={e => e.stopPropagation()}>
<Dropdown
trigger={
<Button variant="ghost" size="icon-sm" aria-label="更多操作">
<MoreHorizontal size={15} />
</Button>
}
>
{close => (
<>
<DropdownItem onClick={() => { close(); openEdit(row.id); }}>
<Pencil size={13} /> 编辑
</DropdownItem>
<DropdownItem danger onClick={() => { close(); setDeleting(row); }}>
<Trash2 size={13} /> 删除
</DropdownItem>
</>
)}
</Dropdown>
</Td>
</Tr>
))}
</tbody>
</Table>
{!listQ.isLoading && !listQ.data?.items.length && (
<Empty
title="没有匹配的记录"
description="调整筛选条件,或新建一条记录。"
action={
<Button variant="primary" onClick={openNew}>
<Plus size={14} /> 新建记录
</Button>
}
/>
)}
</TableWrap>
{total > 0 && (
<div className="flex h-11 items-center justify-end gap-3 border-t border-line px-4 text-[12px] text-ink-3">
<span className="num">第 {page} / {totalPages} 页</span>
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
</div>
)}
</div>
<RecordDetailDialog
open={detailOpen}
onClose={() => setDetailOpen(false)}
footprintId={detailId}
onEdit={id => {
setDetailOpen(false);
openEdit(id);
}}
/>
<RecordEditorDialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
footprintId={editingId}
/>
<Confirm
open={!!deleting}
title="删除记录"
content={deleting ? '确定删除「' + deleting.title + '」吗?该操作不可撤销。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
loading={delMut.isPending}
/>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { deleteDimension, deleteTaxonomy, listDimensions, listTaxonomy, upsertDimension, upsertTaxonomy } from '@/lib/api';
import { Button, Card, CardHeader, Confirm, Input, useToast } from '@/components/ui';
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#16a34a', '#d97706', '#dc2626', '#db2777', '#64748b'];
export function TaxonomyPage() {
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<p className="mb-4 text-[12px] text-ink-3">
字典驱动录入表单中的下拉选项;维度是自定义的成长分类轴,一条记录可挂多个维度。
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TaxonomyCard kind="level" title="级别字典" description="国际级、国家级、省市级等,可按学校口径增改" />
<TaxonomyCard kind="role" title="角色字典" description="主导、核心、参加等参与角色" />
<DimensionCard />
</div>
</div>
);
}
function TaxonomyCard({ kind, title, description }: { kind: string; title: string; description: string }) {
const qc = useQueryClient();
const toast = useToast();
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
const items = (taxQ.data?.items ?? []).filter(t => t.kind === kind).sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertTaxonomy,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['taxonomy-all'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteTaxonomy(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['taxonomy-all'] });
toast('success', '已删除');
setDeleting(null);
},
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title={title} extra={<span className="text-[11px] text-ink-3">{description}</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((t, i) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<input
type="color"
value={t.color ?? PALETTE[i % PALETTE.length]}
className="size-5 cursor-pointer rounded border border-line bg-surface"
onChange={e => upMut.mutate({ id: t.id, kind, value: t.value, color: e.target.value, sort: t.sort })}
/>
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
value={t.value}
onChange={() => {}}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== t.value) upMut.mutate({ id: t.id, kind, value: v, color: t.color, sort: t.sort });
}}
key={t.value}
defaultValue={t.value}
/>
<span className="num w-14 text-right text-[11px] text-ink-3">#{t.sort}</span>
{!t.is_builtin ? (
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: t.id, value: t.value })}>
<Trash2 size={13} />
</Button>
) : (
<span className="w-7 text-center text-[11px] text-ink-3">内置</span>
)}
</div>
))}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增条目,回车或点击按钮"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除字典条目"
content={deleting ? '确定删除「' + deleting.value + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
function DimensionCard() {
const qc = useQueryClient();
const toast = useToast();
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions });
const items = [...(dimsQ.data?.items ?? [])].sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; name: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertDimension,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['dimensions'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteDimension(id),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dimensions'] }); toast('success', '已删除'); setDeleting(null); },
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title="成长维度" extra={<span className="text-[11px] text-ink-3">如:学业深造 / 职业发展 / 兴趣生活</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((d, i) => (
<div key={d.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<span className="size-3 rounded-full" style={{ backgroundColor: d.color ?? PALETTE[i % PALETTE.length] }} />
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
key={d.name}
defaultValue={d.name}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== d.name) upMut.mutate({ id: d.id, name: v, color: d.color, sort: d.sort });
}}
/>
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: d.id, name: d.name })}>
<Trash2 size={13} />
</Button>
</div>
))}
{!items.length && (
<div className="py-2 text-center text-[12px] text-ink-3">尚未配置维度</div>
)}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增维度名称"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => { upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 }); setDraft(''); }}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除维度"
content={deleting ? '确定删除「' + deleting.name + '」吗?记录与该维度的关联将一并移除。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Paperclip, CalendarDays } from 'lucide-react';
import { listTypes, timeline } from '@/lib/api';
import type { FootprintRow } from '@/lib/types';
import { Empty, Input, Select, Badge } from '@/components/ui';
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge, NegativeBadge } from '@/components/common/badges';
import { TypeIcon } from '@/components/common/typeIcons';
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
export function TimelinePage() {
const [q, setQ] = useState('');
const [typeId, setTypeId] = useState('');
const [year, setYear] = useState('');
const [polarity, setPolarity] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const query = useMemo(() => ({
q: q || undefined,
types: typeId ? [Number(typeId)] : undefined,
year: year ? Number(year) : undefined,
polarity: polarity || undefined,
sort: 'date_desc',
page_size: 2000
}), [q, typeId, year, polarity]);
const tlQ = useQuery({ queryKey: ['timeline', query], queryFn: () => timeline(query) });
const years = useMemo(() => {
const ys = new Set<number>();
for (const g of tlQ.data?.groups ?? []) ys.add(g.year);
return Array.from(ys).sort((a, b) => b - a);
}, [tlQ.data]);
const openDetail = (id: number) => {
setDetailId(id);
setDetailOpen(true);
};
return (
<div className="mx-auto max-w-[880px] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
className="w-[220px]"
placeholder="搜索足迹…"
value={q}
onChange={e => setQ(e.target.value)}
/>
<Select
className="w-[130px]"
value={typeId}
onChange={e => setTypeId(e.target.value)}
options={[{ value: '', label: '全部类型' }, ...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))]}
/>
<Select
className="w-[120px]"
value={year}
onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...years.map(y => ({ value: String(y), label: y + ' 年' }))]}
/>
<Select
className="w-[110px]"
value={polarity}
onChange={e => setPolarity(e.target.value)}
options={[
{ value: '', label: '全部性质' },
{ value: 'positive', label: '正面' },
{ value: 'negative', label: '负面' }
]}
/>
<span className="num ml-auto text-[12px] text-ink-3">共 {tlQ.data?.total ?? 0} 条</span>
</div>
{tlQ.isLoading ? (
<div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>
) : !tlQ.data?.groups.length ? (
<Empty
icon={<CalendarDays size={36} strokeWidth={1.5} />}
title="暂无足迹"
description="点击右上角「新建记录」,开始记录你的第一段足迹。"
/>
) : (
<div className="relative">
<div className="absolute bottom-0 left-[7px] top-0 w-px bg-line" aria-hidden />
{tlQ.data.groups.map(group => (
<section key={group.year} className="mb-2">
<div className="sticky top-0 z-10 -mx-6 flex items-center gap-2 bg-bg/90 px-6 py-2 backdrop-blur">
<span className="num text-[16px] font-semibold text-ink-1">{group.year}</span>
<span className="text-[12px] text-ink-3">{group.count} 条记录</span>
</div>
{group.months.map(m => (
<div key={m.month} className="mb-3">
<div className="mb-2 pl-6 text-[12px] font-medium text-ink-3">{m.month} 月</div>
<div className="flex flex-col gap-2">
{m.items.map(item => (
<TimelineItem key={item.id} row={item} onClick={() => openDetail(item.id)} />
))}
</div>
</div>
))}
</section>
))}
</div>
)}
<RecordDetailDialog
open={detailOpen}
onClose={() => setDetailOpen(false)}
footprintId={detailId}
onEdit={id => {
setDetailOpen(false);
setEditingId(id);
setEditorOpen(true);
}}
/>
<RecordEditorDialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
footprintId={editingId}
/>
</div>
);
}
function TimelineItem({ row, onClick }: { row: FootprintRow; onClick: () => void }) {
const date = (row.start_date ?? row.created_at).slice(0, 10);
return (
<button
onClick={onClick}
className="group relative w-full rounded-lg border border-line bg-surface px-4 py-3 text-left shadow-card transition-all hover:border-line-strong hover:shadow-pop"
>
<span
className="absolute left-0 top-3 bottom-3 w-[3px] rounded-full"
style={{ backgroundColor: row.type_color }}
aria-hidden
/>
<div className="flex items-start gap-3">
<div className="num w-[52px] shrink-0 pt-0.5 text-[12px] leading-relaxed text-ink-3">
{date.slice(5, 7)}-{date.slice(8, 10)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[13.5px] font-medium text-ink-1 group-hover:text-brand-text">
{row.title}
</span>
<Badge color={row.type_color}>{row.type_name}</Badge>
<LevelBadge level={row.level} />
<GradeBadge grade={row.award_grade} />
<RoleBadge role={row.role} />
<StatusBadge status={row.status} />
{row.polarity === 'negative' && <NegativeBadge />}
</div>
{row.organizer && (
<div className="mt-0.5 text-[12px] text-ink-3">{row.organizer}</div>
)}
{row.description && (
<p className="mt-1 line-clamp-2 text-[12.5px] leading-relaxed text-ink-2">{row.description}</p>
)}
{(row.result || row.tags.length > 0 || row.attachment_count > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-ink-3">
{row.result && <span className="text-success">结果:{row.result}</span>}
{row.tags.map(t => (
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-px">{t}</span>
))}
{row.attachment_count > 0 && (
<span className="inline-flex items-center gap-1">
<Paperclip size={11} />
{row.attachment_count}
</span>
)}
</div>
)}
</div>
<div className="shrink-0 pt-1 text-ink-3">
<TypeIcon name={row.type_icon} color={row.type_color} size={16} />
</div>
</div>
</button>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Plus, Trash2, EyeOff } from 'lucide-react';
import { deleteType, listTypes, upsertType } from '@/lib/api';
import type { FootprintType, FormField, WidgetType } from '@/lib/types';
import {
Badge, Button, Card, Confirm, Dialog, Field, Input, Select, useToast
} from '@/components/ui';
import { TypeIcon, ICON_CHOICES } from '@/components/common/typeIcons';
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#0f766e', '#16a34a', '#65a30d', '#d97706', '#dc2626', '#db2777', '#64748b'];
const WIDGETS: { value: WidgetType; label: string }[] = [
{ value: 'text', label: '单行文本' },
{ value: 'textarea', label: '多行文本' },
{ value: 'number', label: '数值' },
{ value: 'date', label: '日期' },
{ value: 'select', label: '单选' },
{ value: 'multiselect', label: '多选' },
{ value: 'link', label: '关联记录' }
];
export function TypesPage() {
const qc = useQueryClient();
const toast = useToast();
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const [editing, setEditing] = useState<FootprintType | 'new' | null>(null);
const [deleting, setDeleting] = useState<FootprintType | null>(null);
const delMut = useMutation({
mutationFn: (t: FootprintType) => deleteType(t.id),
onSuccess: () => {
toast('success', '类型已删除');
void qc.invalidateQueries({ queryKey: ['types'] });
setDeleting(null);
},
onError: (e: Error) => {
toast('error', e.message);
setDeleting(null);
}
});
const items = typesQ.data?.items ?? [];
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-[12px] text-ink-3">
每个类型可配置专属字段模板,录入表单、详情页与检索列随之联动。内置类型不可删除,可改名或停用。
</p>
<Button variant="primary" onClick={() => setEditing('new')}>
<Plus size={14} /> 新建类型
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map(t => (
<Card key={t.id} className="flex items-center gap-3 px-4 py-3.5">
<div
className="flex size-9 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: t.color + '18', color: t.color }}
>
<TypeIcon name={t.icon} color={t.color} size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[13.5px] font-medium text-ink-1">{t.name}</span>
{t.is_builtin && <Badge color="var(--text-3)">内置</Badge>}
{t.closed && <Badge color="var(--warning)">已停用</Badge>}
</div>
<div className="num text-[12px] text-ink-3">
{t.usage_count ?? 0} 条记录 · {t.form_template.length} 个类型字段
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon-sm" aria-label="编辑" onClick={() => setEditing(t)}>
<Pencil size={14} />
</Button>
{!t.is_builtin && (
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</Button>
)}
</div>
</Card>
))}
</div>
{editing && (
<TypeEditor
source={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void qc.invalidateQueries({ queryKey: ['types'] });
}}
/>
)}
<Confirm
open={!!deleting}
title="删除类型"
content={deleting ? '确定删除类型「' + deleting.name + '」吗?已有该类型的记录需先迁移。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting)}
/>
</div>
);
}
function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null; onClose: () => void; onSaved: () => void }) {
const toast = useToast();
const [name, setName] = useState(source?.name ?? '');
const [icon, setIcon] = useState(source?.icon ?? 'tag');
const [color, setColor] = useState(source?.color ?? PALETTE[0]);
const [sort, setSort] = useState(String(source?.sort ?? 100));
const [closed, setClosed] = useState(source?.closed ?? false);
const [fields, setFields] = useState<FormField[]>(source?.form_template ?? []);
const [error, setError] = useState('');
const saveMut = useMutation({
mutationFn: () => {
if (!name.trim()) throw new Error('请填写类型名称');
const keys = new Set<string>();
for (const f of fields) {
if (!f.label.trim() || !f.key.trim()) throw new Error('字段标签与键名均不能为空');
if (keys.has(f.key)) throw new Error('字段键名重复:' + f.key);
keys.add(f.key);
if ((f.widget === 'select' || f.widget === 'multiselect') && !(Array.isArray(f.options) && f.options.length)) {
throw new Error('字段「' + f.label + '」需要配置选项');
}
}
return upsertType({
id: source?.id,
name: name.trim(),
icon, color,
sort: Number(sort) || 100,
form_template: fields,
closed
});
},
onSuccess: () => {
toast('success', source ? '类型已更新' : '类型已创建');
onSaved();
},
onError: (e: Error) => setError(e.message)
});
const updateField = (idx: number, patch: Partial<FormField>) =>
setFields(fs => fs.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
return (
<Dialog
open
onClose={onClose}
width={720}
title={source ? '编辑类型 · ' + source.name : '新建类型'}
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}>保存</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_120px_100px] gap-3">
<Field label="名称" required>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:学科竞赛" />
</Field>
<Field label="排序">
<Input type="number" className="num" value={sort} onChange={e => setSort(e.target.value)} />
</Field>
<Field label="停用">
<button
type="button"
onClick={() => setClosed(v => !v)}
className={
'flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-[12px] transition-colors ' +
(closed ? 'border-warning bg-warning-subtle text-warning' : 'border-line bg-surface text-ink-2')
}
>
<EyeOff size={13} />
{closed ? '已停用' : '启用中'}
</button>
</Field>
</div>
<Field label="图标">
<div className="flex flex-wrap gap-1">
{ICON_CHOICES.map(ic => (
<button
key={ic.name}
type="button"
title={ic.label}
className={
'flex size-8 items-center justify-center rounded-md border transition-colors ' +
(icon === ic.name ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-3 hover:border-line-strong')
}
onClick={() => setIcon(ic.name)}
>
<TypeIcon name={ic.name} size={15} color={icon === ic.name ? 'var(--brand-text)' : undefined} />
</button>
))}
</div>
</Field>
<Field label="颜色">
<div className="flex gap-1.5">
{PALETTE.map(c => (
<button
key={c}
type="button"
className={
'size-6 rounded-md border-2 transition-transform ' +
(color === c ? 'scale-110 border-ink-1' : 'border-transparent hover:scale-105')
}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
aria-label={c}
/>
))}
</div>
</Field>
<div className="border-t border-line pt-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-semibold text-ink-2">字段模板({fields.length})</span>
<Button
size="sm"
variant="secondary"
onClick={() => setFields(fs => [...fs, {
key: 'field_' + (fs.length + 1), label: '', widget: 'text'
}])}
>
<Plus size={13} /> 添加字段
</Button>
</div>
<div className="flex flex-col gap-2">
{fields.map((f, i) => (
<div key={i} className="rounded-md border border-line bg-surface-2 p-2.5">
<div className="grid grid-cols-1 sm:grid-cols-[1fr_130px_120px_1fr_28px] items-end gap-2">
<Field label="标签">
<Input value={f.label} onChange={e => updateField(i, { label: e.target.value })} placeholder="显示名" />
</Field>
<Field label="键名">
<Input value={f.key} onChange={e => updateField(i, { key: e.target.value })} />
</Field>
<Field label="控件">
<Select value={f.widget} onChange={e => updateField(i, { widget: e.target.value as WidgetType })} options={WIDGETS} />
</Field>
<Field label={f.widget === 'number' ? '单位' : (f.widget === 'select' || f.widget === 'multiselect') ? '选项(逗号分隔)' : '占位提示'}>
<Input
value={
f.widget === 'number' ? (f.unit ?? '')
: (f.widget === 'select' || f.widget === 'multiselect')
? (Array.isArray(f.options) ? f.options.join(',') : '')
: (f.placeholder ?? '')
}
onChange={e => {
const v = e.target.value;
if (f.widget === 'number') updateField(i, { unit: v });
else if (f.widget === 'select' || f.widget === 'multiselect')
updateField(i, { options: v ? v.split(/[,,]/).map(s => s.trim()).filter(Boolean) : [] });
else updateField(i, { placeholder: v });
}}
/>
</Field>
<Button variant="ghost" size="icon-sm" aria-label="删除字段" className="mb-0.5"
onClick={() => setFields(fs => fs.filter((_, x) => x !== i))}>
<Trash2 size={13} />
</Button>
</div>
<label className="mt-1.5 flex cursor-pointer items-center gap-1.5 text-[12px] text-ink-2">
<input type="checkbox" checked={!!f.required} onChange={e => updateField(i, { required: e.target.checked })} />
必填
{f.widget === 'number' && (
<>
<input type="checkbox" className="ml-2" checked={!!f.metric} onChange={e => updateField(i, { metric: e.target.checked ? f.key : undefined })} />
计入统计指标
</>
)}
</label>
</div>
))}
{!fields.length && (
<div className="rounded-md border border-dashed border-line px-3 py-4 text-center text-[12px] text-ink-3">
该类型暂无专属字段,仅使用通用字段(标题、时间、级别、角色等)
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}