feat: editable gpa formula via bands, resume edu format per template, dedupe sections, word photo size, text justify
This commit is contained in:
+14
-7
@@ -65,8 +65,11 @@ export function migrateResumeConfig(raw: unknown): ResumeConfig {
|
||||
if (d.key === 'self' && !base.text) base.text = SEED.self.text;
|
||||
return base;
|
||||
});
|
||||
// v1 里用户新增的板块(不在默认定义中)→ 追加为自定义文本板块
|
||||
// 旧版本已被取代的板块 key:直接丢弃,避免残留造成内容重复
|
||||
const KNOWN_LEGACY = new Set(['awards', 'competition', 'practice', 'research']);
|
||||
// 用户新增的自定义板块(不在默认与遗留定义中)→ 追加为自定义文本板块
|
||||
for (const o of oldSections) {
|
||||
if (KNOWN_LEGACY.has(o.key)) continue;
|
||||
if (!sections.some(s => s.key === o.key)) {
|
||||
sections.push({ key: o.key, title: o.title || o.key, enabled: o.enabled ?? true, kind: 'text', text: '' });
|
||||
}
|
||||
@@ -116,6 +119,7 @@ export function assembleSections(
|
||||
): AssembledSection[] {
|
||||
const out: AssembledSection[] = [];
|
||||
const claimed = claimedTypes();
|
||||
const usedRecordIds = new Set<number>(); // 全局去重:一条记录只出现一次
|
||||
|
||||
for (const s of config.sections) {
|
||||
if (!s.enabled) continue;
|
||||
@@ -123,7 +127,9 @@ export function assembleSections(
|
||||
|
||||
if (s.key === 'info') { out.push({ key: s.key, title: s.title, kind: 'info' }); continue; }
|
||||
if (s.key === 'self' || s.kind === 'text') {
|
||||
out.push({ key: s.key, title: s.title, kind: 'text', text: s.text ?? '' });
|
||||
const txt = s.text ?? '';
|
||||
if (!txt.trim()) continue; // 空板块不渲染
|
||||
out.push({ key: s.key, title: s.title, kind: 'text', text: txt });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -132,13 +138,14 @@ export function assembleSections(
|
||||
let derived: FootprintRow[] = [];
|
||||
const types = def?.types;
|
||||
if (types) {
|
||||
derived = records.filter(r => types.includes(r.type_name) && r.status !== 'draft' && !excluded.has(r.id));
|
||||
derived = records.filter(r => types.includes(r.type_name) && r.status !== 'draft' && !excluded.has(r.id) && !usedRecordIds.has(r.id));
|
||||
} else {
|
||||
// 其他桶:未归类的类型
|
||||
derived = records.filter(r => r.status !== 'draft' && !excluded.has(r.id) && !claimed.includes(r.type_name));
|
||||
derived = records.filter(r => r.status !== 'draft' && !excluded.has(r.id) && !usedRecordIds.has(r.id) && !claimed.includes(r.type_name));
|
||||
}
|
||||
const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord))
|
||||
.map(it => ({ ...it, ...(s.overrides?.[String(it.fromRecord)] ?? {}) }));
|
||||
items.forEach(it => { if (it.fromRecord) usedRecordIds.add(it.fromRecord); });
|
||||
for (const c of s.items ?? []) items.push({ ...c });
|
||||
|
||||
items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
|
||||
@@ -185,8 +192,8 @@ export function resumeToWordHtml(
|
||||
const schoolLine = [P.school, P.college, P.major, P.degree].filter(v => v !== '').map(v => esc(v)).join(' · ');
|
||||
const contactLine = [P.phone, P.email, P.homepage].filter(v => v !== '').map(v => esc(v)).join(' · ');
|
||||
const photo = photoUrl && P.photoUrl
|
||||
? '<td style="width:' + (90 * scale).toFixed(0) + 'pt;text-align:right;vertical-align:top">' +
|
||||
'<img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:' + (90 * scale).toFixed(0) + 'pt;height:' + (120 * scale).toFixed(0) + 'pt;object-fit:cover" /></td>'
|
||||
? '<td style="width:' + (120 * scale).toFixed(0) + 'px;text-align:right;vertical-align:top">' +
|
||||
'<img src="' + esc(P.photoUrl) + '" alt="证件照" width="' + Math.round(120 * scale) + '" height="' + Math.round(160 * scale) + '" style="width:' + Math.round(120 * scale) + 'px;height:' + Math.round(160 * scale) + 'px" /></td>'
|
||||
: '';
|
||||
|
||||
const head = '<table style="width:100%;border-collapse:collapse"><tr>' +
|
||||
@@ -232,7 +239,7 @@ export function resumeToWordHtml(
|
||||
} else if (sec.kind === 'list') {
|
||||
inner = '<div>' + (sec.items ?? []).map(itemHtml).join('') + '</div>';
|
||||
} else {
|
||||
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0">' + esc(sec.text ?? '').replace(/\n/g, '<br/>') + '</p>';
|
||||
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0;text-align:justify">' + esc(sec.text ?? '').replace(/\n/g, '<br/>') + '</p>';
|
||||
}
|
||||
return '<div style="' + titleCss[cfg.template] + '">' + esc(sec.title) + '</div>' + inner;
|
||||
}).join('');
|
||||
|
||||
@@ -195,6 +195,7 @@ export interface GradeBand {
|
||||
min: number;
|
||||
max: number;
|
||||
sort: number;
|
||||
gp?: number | null;
|
||||
}
|
||||
|
||||
export interface BandInfo { id: number; label: string; color: string; }
|
||||
|
||||
@@ -17,8 +17,17 @@ function termYear(term: string): string {
|
||||
return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期';
|
||||
}
|
||||
|
||||
function autoGpa(courses: CourseGrade[]): number | null {
|
||||
const withGp = courses.filter(c => c.grade_point != null && c.credit > 0);
|
||||
function autoGpa(courses: CourseGrade[], bands?: { min: number; max: number; gp?: number | null }[]): number | null {
|
||||
// 按梯度公式补算缺失绩点:分数落在哪一档 → 该档绩点(各校规则可在梯度设置中修改)
|
||||
const gpOf = (c: CourseGrade): number | null => {
|
||||
if (c.grade_point != null) return c.grade_point;
|
||||
if (bands && c.score != null) {
|
||||
const hit = bands.find(b => c.score! >= b.min && (c.score! < b.max || (b.max >= 100 && c.score! <= b.max)));
|
||||
if (hit && hit.gp != null) return hit.gp;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const withGp = courses.map(c => ({ ...c, grade_point: gpOf(c) })).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);
|
||||
@@ -169,7 +178,7 @@ function BandEditor({ bands }: { bands: GradeBand[] }) {
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="成绩梯度"
|
||||
extra={<span className="text-[11px] text-ink-3">按 分数 ≥ min 且 < max 定级,满分档含上限</span>}
|
||||
extra={<span className="text-[11px] text-ink-3">各校规则不同:每档可设定级与绩点,「自动绩点」按此计算</span>}
|
||||
/>
|
||||
<CardBody className="p-0">
|
||||
<div className="divide-y divide-line">
|
||||
@@ -187,7 +196,7 @@ function BandEditor({ bands }: { bands: GradeBand[] }) {
|
||||
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 })}>
|
||||
<Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, gp: null, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
|
||||
<Plus size={13} /> 添加等级
|
||||
</Button>
|
||||
)}
|
||||
@@ -233,6 +242,8 @@ function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
|
||||
<Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0 text-right" value={String(value.min)} onChange={e => set({ min: Number(e.target.value) })} />
|
||||
<span className="text-[11.5px] text-ink-3">—</span>
|
||||
<Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
|
||||
<span className="ml-1 text-[11px] text-ink-3">绩点</span>
|
||||
<Input type="number" step="0.1" className="num h-6 w-[56px] border-0 bg-transparent p-0" value={value.gp != null ? String(value.gp) : ''} placeholder="—" onChange={e => set({ gp: e.target.value === '' ? null : Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 sm:ml-auto">
|
||||
{isNew ? (
|
||||
@@ -258,6 +269,7 @@ function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
|
||||
function SemesterCard({ sem }: { sem: SemesterGrades }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const overviewQ = useQuery({ queryKey: ['grades'], queryFn: gradesOverview });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<CourseGrade | null>(null);
|
||||
const [semDialog, setSemDialog] = useState(false);
|
||||
@@ -271,7 +283,7 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
|
||||
|
||||
const autoGpaMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const gpa = autoGpa(sem.courses);
|
||||
const gpa = autoGpa(sem.courses, overviewQ.data?.bands);
|
||||
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 });
|
||||
},
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
} from '@/lib/resume';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
// 2023-09 → 2023.9
|
||||
function fmtEduDate(v: string | undefined): string {
|
||||
const m = /^(\d{4})-(\d{1,2})/.exec(v ?? '');
|
||||
return m ? m[1] + '.' + Number(m[2]) : (v ?? '');
|
||||
}
|
||||
|
||||
// 预览内联编辑组件:点击即改,失焦保存
|
||||
function Ed({ html, onCommit, className }: { html: string; onCommit: (v: string) => void; className?: string }) {
|
||||
const esc = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -51,6 +57,7 @@ export function ResumePage() {
|
||||
const records: FootprintRow[] = listQ.data?.items ?? [];
|
||||
const cfg: ResumeConfig = config ?? defaultResumeConfig();
|
||||
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro };
|
||||
const eduRange = [fmtEduDate(profile.eduStart), fmtEduDate(profile.eduEnd)].filter(Boolean).join('-');
|
||||
const sections = useMemo(() => assembleSections(records, cfg), [records, cfg]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(pc => {
|
||||
@@ -174,16 +181,25 @@ export function ResumePage() {
|
||||
</div>
|
||||
{sec.kind === 'edu' ? (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-1 text-[12.5px] sm:grid-cols-2">
|
||||
{([['学校', profile.school, 'school'], ['专业', profile.major, 'major'], ['学制', profile.eduLength, 'eduLength'], ['学历', profile.degree, 'degree'], ['入学', profile.eduStart, 'eduStart'], ['毕业', profile.eduEnd, 'eduEnd']] as [string, string | undefined, keyof ProfileInfo][])
|
||||
.filter(r => r[1]).map(r => (
|
||||
<div key={r[0]} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{r[0]}</span>
|
||||
<Ed html={r[1] ?? ''} onCommit={v => saveProfileField(r[2], v)} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 text-[13px]">
|
||||
<span className="num shrink-0 text-neutral-500">
|
||||
<Ed html={eduRange} onCommit={v => {
|
||||
const m = /(\d{4})\.(\d{1,2})\s*[-—~至]\s*(\d{4})\.(\d{1,2})/.exec(v);
|
||||
if (!m) { toast('error', '时间格式:2023.9-2027.6'); return; }
|
||||
void profileUpsert({ info: { ...previewProfile, eduStart: m[1] + '-' + m[2].padStart(2, '0'), eduEnd: m[3] + '-' + m[4].padStart(2, '0') } })
|
||||
.then(() => profileQ.refetch()).catch(e => toast('error', e.message));
|
||||
}} />
|
||||
</span>
|
||||
<span className="min-w-0 font-semibold text-neutral-900">
|
||||
<Ed html={profile.school ?? ''} onCommit={v => saveProfileField('school', v)} />
|
||||
</span>
|
||||
<span className="shrink-0 text-neutral-600">
|
||||
<Ed html={profile.major ?? ''} onCommit={v => saveProfileField('major', v)} />
|
||||
{profile.degree ? <span> | <Ed html={profile.degree} onCommit={v => saveProfileField('degree', v)} /></span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{profile.majorCourses && (
|
||||
<p className="mt-2.5 text-[12.5px] leading-relaxed text-neutral-700">
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-justify text-neutral-700">
|
||||
<b>主修课程:</b>
|
||||
<Ed html={profile.majorCourses} onCommit={v => saveProfileField('majorCourses', v)} />
|
||||
</p>
|
||||
@@ -198,7 +214,7 @@ export function ResumePage() {
|
||||
))}
|
||||
</div>
|
||||
) : sec.kind === 'text' ? (
|
||||
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
|
||||
<p className={'whitespace-pre-wrap text-justify text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
|
||||
<Ed html={sec.text ?? ''} onCommit={v => handleEdit({ secKey: sec.key, field: 'text', value: v })} />
|
||||
</p>
|
||||
) : (
|
||||
@@ -215,7 +231,7 @@ export function ResumePage() {
|
||||
</div>
|
||||
{it.subtitle && <div className="text-[12px] text-neutral-600">{it.subtitle}</div>}
|
||||
{it.desc && (
|
||||
<p className={'text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>
|
||||
<p className={'text-justify text-[12px] text-neutral-500 ' + (cfg.density === 'compact' ? '' : 'mt-0.5')}>
|
||||
<Ed html={it.desc} onCommit={v => handleEdit({ secKey: sec.key, field: 'desc', value: v, itemIdx, fromRecord: it.fromRecord })} />
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user