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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 且 < 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user