fix(resume): remove toast line from ListSectionEditor type
This commit is contained in:
+174
-65
@@ -1,115 +1,224 @@
|
||||
import type { FootprintRow, ProfileInfo, ResumeConfig } from './types';
|
||||
|
||||
// ---------- 简历数据模型 v2 ----------
|
||||
|
||||
export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number }
|
||||
|
||||
export interface ResumeSectionDef {
|
||||
key: string; defaultTitle: string; types?: string[]; fixed?: boolean;
|
||||
key: string; defaultTitle: string;
|
||||
kind: 'info' | 'list' | 'text';
|
||||
types?: string[]; // list 类:从哪些记录类型装配
|
||||
fixed?: boolean; // 不可删除/停用(基本信息、自我评价)
|
||||
}
|
||||
|
||||
// 简历板块定义(顺序 = 默认顺序)
|
||||
export const RESUME_SECTION_DEFS: ResumeSectionDef[] = [
|
||||
{ key: 'info', defaultTitle: '基本信息', fixed: true },
|
||||
{ key: 'education', defaultTitle: '教育背景', types: ['学业'] },
|
||||
{ key: 'skills', defaultTitle: '技能证书', types: ['证书技能'] },
|
||||
{ key: 'awards', defaultTitle: '荣誉奖项', types: ['荣誉惩处'] },
|
||||
{ key: 'competition', defaultTitle: '竞赛经历', types: ['竞赛'] },
|
||||
{ key: 'research', defaultTitle: '科研经历', types: ['科研'] },
|
||||
{ key: 'practice', defaultTitle: '实践经历', types: ['志愿公益', '实习实践', '学生工作'] },
|
||||
{ key: 'activity', defaultTitle: '文体活动', types: ['文体活动'] },
|
||||
{ key: 'other', defaultTitle: '其他经历' },
|
||||
{ key: 'self', defaultTitle: '自我评价', fixed: true },
|
||||
{ key: 'info', defaultTitle: '基本信息', kind: 'info', fixed: true },
|
||||
{ key: 'education', defaultTitle: '教育背景', kind: 'list', types: ['学业'] },
|
||||
{ key: 'skills', defaultTitle: '技能证书', kind: 'list', types: ['证书技能'] },
|
||||
{ key: 'awards', defaultTitle: '荣誉奖项', kind: 'list', types: ['荣誉惩处'] },
|
||||
{ key: 'competition', defaultTitle: '竞赛经历', kind: 'list', types: ['竞赛'] },
|
||||
{ key: 'research', defaultTitle: '科研经历', kind: 'list', types: ['科研'] },
|
||||
{ key: 'practice', defaultTitle: '实践经历', kind: 'list', types: ['志愿公益', '实习实践', '学生工作'] },
|
||||
{ key: 'activity', defaultTitle: '文体活动', kind: 'list', types: ['文体活动'] },
|
||||
{ key: 'other', defaultTitle: '其他经历', kind: 'list' }, // 未归类的自定义类型 + 自定义条目
|
||||
{ key: 'self', defaultTitle: '自我评价', kind: 'text', fixed: true },
|
||||
];
|
||||
|
||||
export function defaultResumeConfig(): ResumeConfig {
|
||||
return { sections: RESUME_SECTION_DEFS.map(s => ({ key: s.key, enabled: true, title: s.defaultTitle })) };
|
||||
export interface ResumeSectionCfg {
|
||||
key: string;
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
kind?: 'info' | 'list' | 'text';
|
||||
types?: string[];
|
||||
excluded?: number[]; // 隐藏的记录 id
|
||||
items?: ResumeItemCfg[]; // 自定义条目
|
||||
text?: string; // text 板块内容
|
||||
}
|
||||
|
||||
export interface ResumeItem {
|
||||
row: FootprintRow;
|
||||
export type ResumeTemplate = 'classic' | 'tech' | 'minimal';
|
||||
|
||||
|
||||
export function defaultResumeConfig(): ResumeConfig {
|
||||
return {
|
||||
version: 2, template: 'tech', accent: '#3056d3',
|
||||
fontScale: 1, density: 'normal', onePage: false,
|
||||
sections: RESUME_SECTION_DEFS.map(d => ({
|
||||
key: d.key, title: d.defaultTitle, enabled: true, kind: d.kind, types: d.types,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// v1({sections:[{key,enabled,title}]})→ v2 迁移
|
||||
export function migrateResumeConfig(raw: unknown): ResumeConfig {
|
||||
const def = defaultResumeConfig();
|
||||
if (!raw || typeof raw !== 'object') return def;
|
||||
const r = raw as { version?: number; template?: ResumeTemplate; accent?: string; fontScale?: number; density?: ResumeConfig['density']; onePage?: boolean; sections?: { key: string; enabled?: boolean; title?: string }[] };
|
||||
const oldSections = Array.isArray(r.sections) ? r.sections : [];
|
||||
const sections = def.sections.map(d => {
|
||||
const old = oldSections.find(o => o.key === d.key);
|
||||
const dd = RESUME_SECTION_DEFS.find(x => x.key === d.key);
|
||||
return { ...d, enabled: old?.enabled ?? true, title: old?.title ?? dd?.defaultTitle ?? d.title };
|
||||
});
|
||||
// v1 里用户新增的板块(不在默认定义中)→ 追加为自定义文本板块
|
||||
for (const o of oldSections) {
|
||||
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: '' });
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
template: r.template ?? 'tech',
|
||||
accent: r.accent ?? '#3056d3',
|
||||
fontScale: Math.min(1.2, Math.max(0.8, r.fontScale ?? 1)),
|
||||
density: r.density ?? 'normal',
|
||||
onePage: r.onePage ?? false,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 装配 ----------
|
||||
|
||||
export interface AssembledItem {
|
||||
title: string; subtitle?: string; date?: string; desc?: string;
|
||||
fromRecord?: number; excluded?: boolean;
|
||||
}
|
||||
|
||||
export interface AssembledSection {
|
||||
key: string; title: string;
|
||||
items?: ResumeItem[];
|
||||
text?: string;
|
||||
key: string; title: string; kind: 'info' | 'list' | 'text';
|
||||
items?: AssembledItem[]; text?: string;
|
||||
}
|
||||
|
||||
function claimedTypes(): string[] {
|
||||
return RESUME_SECTION_DEFS.filter(d => d.types).flatMap(d => d.types!);
|
||||
}
|
||||
|
||||
function recordToItem(r: FootprintRow): ResumeItemCfg {
|
||||
const subtitle = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(String).join(' · ');
|
||||
const desc = [r.result, r.description].filter(v => v != null && v !== '').map(String).join(' — ');
|
||||
return {
|
||||
title: r.title,
|
||||
subtitle: subtitle || undefined,
|
||||
date: r.start_date ?? r.created_at.slice(0, 10),
|
||||
desc: desc || undefined,
|
||||
fromRecord: r.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function assembleSections(
|
||||
records: FootprintRow[],
|
||||
profile: Partial<ProfileInfo> | undefined,
|
||||
config: ResumeConfig | undefined
|
||||
config: ResumeConfig
|
||||
): AssembledSection[] {
|
||||
const cfg = config?.sections?.length ? config.sections : defaultResumeConfig().sections;
|
||||
const byKey = new Map(RESUME_SECTION_DEFS.map(s => [s.key, s]));
|
||||
|
||||
const out: AssembledSection[] = [];
|
||||
for (const s of cfg) {
|
||||
const claimed = claimedTypes();
|
||||
|
||||
for (const s of config.sections) {
|
||||
if (!s.enabled) continue;
|
||||
const def = byKey.get(s.key);
|
||||
if (s.key === 'info') { out.push({ key: s.key, title: s.title, text: 'info' }); continue; }
|
||||
if (s.key === 'self') { out.push({ key: s.key, title: s.title, text: profile?.selfIntro || '' }); continue; }
|
||||
const def = RESUME_SECTION_DEFS.find(d => d.key === s.key);
|
||||
|
||||
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 ?? '' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// list:记录装配(按 types 或「其他」桶)+ 自定义条目合并
|
||||
const excluded = new Set(s.excluded ?? []);
|
||||
let derived: FootprintRow[] = [];
|
||||
const types = def?.types;
|
||||
if (!types) continue;
|
||||
const items = records.filter(r => types.includes(r.type_name) && r.status !== 'draft');
|
||||
if (types) {
|
||||
derived = records.filter(r => types.includes(r.type_name) && r.status !== 'draft' && !excluded.has(r.id));
|
||||
} else {
|
||||
// 其他桶:未归类的类型
|
||||
derived = records.filter(r => r.status !== 'draft' && !excluded.has(r.id) && !claimed.includes(r.type_name));
|
||||
}
|
||||
const items: AssembledItem[] = derived.map(recordToItem).filter(it => !it.fromRecord || !excluded.has(it.fromRecord));
|
||||
for (const c of s.items ?? []) items.push({ ...c });
|
||||
|
||||
items.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
|
||||
if (!items.length) continue;
|
||||
items.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at));
|
||||
out.push({ key: s.key, title: s.title, items: items.map(row => ({ row })) });
|
||||
out.push({ key: s.key, title: s.title, kind: 'list', items });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Word 导出:简历 HTML(内联样式,Word/WPS 可直接打开)
|
||||
// ---------- 模板 ----------
|
||||
|
||||
export const TEMPLATES: { key: ResumeTemplate; name: string; desc: string }[] = [
|
||||
{ key: 'tech', name: '互联网技术风', desc: '品牌色标题 + 左侧色条,紧凑现代' },
|
||||
{ key: 'minimal', name: '极简风', desc: '灰色大写字距标题,大量留白' },
|
||||
{ key: 'classic', name: '经典风', desc: '黑色衬线标题 + 通栏分隔线,正式通用' },
|
||||
];
|
||||
|
||||
export const ACCENT_CHOICES = ['#3056d3', '#0f766e', '#7c3aed', '#c2410c', '#0e7490', '#1f2937'];
|
||||
|
||||
// ---------- Word 导出(内联样式,按模板着色) ----------
|
||||
|
||||
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
export function resumeToWordHtml(
|
||||
sections: AssembledSection[],
|
||||
profile: Partial<ProfileInfo> | undefined,
|
||||
profile: Partial<ProfileInfo>,
|
||||
cfg: ResumeConfig,
|
||||
photoUrl?: string
|
||||
): string {
|
||||
const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const P = profile ?? {};
|
||||
const scale = cfg.fontScale;
|
||||
const pt = (base: number) => (base * scale).toFixed(1) + 'pt';
|
||||
const accent = cfg.accent;
|
||||
const titleStyle: Record<ResumeTemplate, string> = {
|
||||
classic: 'font-size:' + pt(14) + ';border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:12pt 0 6pt;color:#111',
|
||||
tech: 'font-size:' + pt(14) + ';color:' + accent + ';border-left:3pt solid ' + accent + ';padding-left:6pt;margin:12pt 0 6pt',
|
||||
minimal: 'font-size:' + pt(11.5) + ';color:#555;letter-spacing:1.5pt;text-transform:uppercase;margin:14pt 0 6pt',
|
||||
};
|
||||
|
||||
const secHtml = sections.map(sec => {
|
||||
let inner = '';
|
||||
if (sec.key === 'info') {
|
||||
if (sec.kind === 'info') {
|
||||
const rows: [string, string][] = ([
|
||||
['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''],
|
||||
['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''],
|
||||
['学校', P.school ?? ''], ['学院', P.college ?? ''], ['专业', P.major ?? ''],
|
||||
['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''],
|
||||
['手机', P.phone ?? ''], ['邮箱', P.email ?? ''],
|
||||
['姓名', P0(profile).name], ['性别', P0(profile).gender], ['出生年月', P0(profile).birth],
|
||||
['民族', P0(profile).nation], ['籍贯', P0(profile).native], ['政治面貌', P0(profile).political],
|
||||
['学校', P0(profile).school], ['学院', P0(profile).college], ['专业', P0(profile).major],
|
||||
['班级', P0(profile).className], ['学号', P0(profile).studentId], ['学历', P0(profile).degree],
|
||||
['手机', P0(profile).phone], ['邮箱', P0(profile).email],
|
||||
] as [string, string][]).filter(r => r[1] !== '');
|
||||
inner = '<table style="width:100%;border-collapse:collapse;font-size:12pt">' +
|
||||
inner = '<table style="width:100%;border-collapse:collapse;font-size:' + pt(12) + '">' +
|
||||
rows.map(([k, v], i) =>
|
||||
(i % 2 === 0 ? '<tr>' : '') +
|
||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(String(k)) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(String(v)) + '</td>' +
|
||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(k) + '</td>' +
|
||||
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
|
||||
(i % 2 === 1 ? '</tr>' : '')
|
||||
).join('') + '</table>';
|
||||
} else if (sec.items) {
|
||||
inner = '<div style="margin-bottom:6pt">' + sec.items.map(it => {
|
||||
const r = it.row;
|
||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
||||
const sub = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(v => esc(String(v))).join(' · ');
|
||||
} else if (sec.kind === 'list') {
|
||||
inner = '<div style="margin-bottom:6pt">' + (sec.items ?? []).map(it => {
|
||||
const sub = it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '';
|
||||
const desc = it.desc ? '<div style="color:#444;font-size:' + pt(11) + ';line-height:1.5">' + esc(it.desc) + '</div>' : '';
|
||||
return '<div style="margin-bottom:7pt">' +
|
||||
'<div style="display:flex;justify-content:space-between"><b style="font-size:12pt">' + esc(r.title) + '</b>' +
|
||||
'<span style="color:#666;font-size:10.5pt">' + esc(date) + '</span></div>' +
|
||||
(sub ? '<div style="color:#666;font-size:10.5pt">' + sub + '</div>' : '') +
|
||||
(r.result ? '<div style="color:#177245;font-size:11pt">' + esc(r.result) + '</div>' : '') +
|
||||
'</div>';
|
||||
'<div style="display:flex;justify-content:space-between"><b style="font-size:' + pt(12) + '">' + esc(it.title) + '</b>' +
|
||||
'<span style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.date ?? '') + '</span></div>' +
|
||||
sub + desc + '</div>';
|
||||
}).join('') + '</div>';
|
||||
} else if (sec.text) {
|
||||
inner = '<p style="font-size:12pt;line-height:1.7;margin:0">' + esc(sec.text) + '</p>';
|
||||
} else {
|
||||
inner = '<p style="font-size:' + pt(12) + ';line-height:1.7;margin:0">' + esc(sec.text ?? '') + '</p>';
|
||||
}
|
||||
return '<h2 style="font-size:14pt;border-bottom:1.5pt solid #333;padding-bottom:3pt;margin:14pt 0 7pt">' + esc(sec.title) + '</h2>' + inner;
|
||||
return '<h2 style="' + titleStyle[cfg.template] + '">' + esc(sec.title) + '</h2>' + inner;
|
||||
}).join('');
|
||||
|
||||
const photo = photoUrl && P.photoUrl
|
||||
? '<div style="text-align:right"><img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:90pt;height:120pt;object-fit:cover" /></div>'
|
||||
const photo = photoUrl && profile.photoUrl
|
||||
? '<div style="text-align:right"><img src="' + esc(profile.photoUrl) + '" alt="证件照" style="width:' + (90 * scale).toFixed(0) + 'pt;height:' + (120 * scale).toFixed(0) + 'pt;object-fit:cover" /></div>'
|
||||
: '';
|
||||
|
||||
const headSub = [P0(profile).school, P0(profile).college, P0(profile).major, P0(profile).degree].filter(v => v !== '').map(v => esc(v)).join(' · ');
|
||||
|
||||
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
|
||||
'<head><meta charset="utf-8"><title>个人简历</title></head>' +
|
||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt">' +
|
||||
'<head><meta charset="utf-8"><title>' + esc(P0(profile).name || '个人简历') + '</title></head>' +
|
||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start">' +
|
||||
'<h1 style="font-size:20pt;margin:0 0 4pt">' + esc(P.name || '个人简历') + '</h1>' + photo + '</div>' +
|
||||
(P.major || P.school ? '<div style="color:#555;font-size:11pt;margin-bottom:8pt">' +
|
||||
[P.school, P.college, P.major, P.degree].filter(v => !!v).map(v => esc(String(v))).join(' · ') + '</div>' : '') +
|
||||
'<h1 style="font-size:' + pt(20) + ';margin:0 0 4pt;color:' + (cfg.template === 'tech' ? accent : '#111') + '">' + esc(P0(profile).name || '个人简历') + '</h1>' + photo + '</div>' +
|
||||
(headSub ? '<div style="color:#555;font-size:' + pt(11) + ';margin-bottom:8pt">' + headSub + '</div>' : '') +
|
||||
secHtml +
|
||||
'</body></html>';
|
||||
}
|
||||
|
||||
// 避免循环引用的小工具
|
||||
function P0(p: Partial<ProfileInfo> | undefined): Partial<ProfileInfo> {
|
||||
return p ?? {};
|
||||
}
|
||||
|
||||
+20
-2
@@ -239,5 +239,23 @@ export interface ProfileInfo {
|
||||
phone: string; email: string; homepage: string; photoUrl: string;
|
||||
selfIntro: string;
|
||||
}
|
||||
export interface ResumeSectionCfg { key: string; enabled: boolean; title: string; }
|
||||
export interface ResumeConfig { sections: ResumeSectionCfg[]; }
|
||||
export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number }
|
||||
|
||||
export interface ResumeSectionCfg {
|
||||
key: string; title: string; enabled: boolean;
|
||||
kind?: 'info' | 'list' | 'text';
|
||||
types?: string[];
|
||||
excluded?: number[];
|
||||
items?: ResumeItemCfg[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ResumeConfig {
|
||||
version: 2;
|
||||
template: 'classic' | 'tech' | 'minimal';
|
||||
accent: string;
|
||||
fontScale: number;
|
||||
density: 'compact' | 'normal' | 'relaxed';
|
||||
onePage: boolean;
|
||||
sections: ResumeSectionCfg[];
|
||||
}
|
||||
|
||||
+331
-142
@@ -1,53 +1,69 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowDown, ArrowUp, Download, Printer } from 'lucide-react';
|
||||
import { footprintList, profileGet } from '@/lib/api';
|
||||
import type { ProfileInfo, ResumeConfig } from '@/lib/types';
|
||||
import { assembleSections, defaultResumeConfig, RESUME_SECTION_DEFS } from '@/lib/resume';
|
||||
import { profileUpsert } from '@/lib/api';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
function selfIntroDraftFix(v: string) { return v; }
|
||||
import { ArrowDown, ArrowUp, ChevronRight, Download, GripVertical, PanelRightClose, PanelRightOpen, Plus, Printer, Trash2 } from 'lucide-react';
|
||||
import { footprintList, profileGet, profileUpsert } from '@/lib/api';
|
||||
import type { FootprintRow, ProfileInfo, ResumeConfig, ResumeItemCfg, ResumeSectionCfg } from '@/lib/types';
|
||||
import {
|
||||
ACCENT_CHOICES, assembleSections, defaultResumeConfig, migrateResumeConfig,
|
||||
RESUME_SECTION_DEFS, TEMPLATES, type ResumeTemplate,
|
||||
} from '@/lib/resume';
|
||||
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
export function ResumePage() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
const listQ = useQuery({ queryKey: ['resume-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile = { ...(stored?.info ?? {}) } as Partial<ProfileInfo>;
|
||||
const [config, setConfig] = useState<ResumeConfig>(() => stored?.resume ?? defaultResumeConfig());
|
||||
const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? '');
|
||||
const introDirty = selfIntro !== (profile.selfIntro ?? '');
|
||||
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) };
|
||||
const [config, setConfig] = useState<ResumeConfig | null>(null);
|
||||
const [selfIntro, setSelfIntro] = useState('');
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [openEditors, setOpenEditors] = useState<Set<string>>(new Set());
|
||||
const [fitZoom, setFitZoom] = useState(1);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
const records = listQ.data?.items ?? [];
|
||||
const sections = useMemo(
|
||||
() => assembleSections(records, previewProfile, config),
|
||||
[records, previewProfile, config]
|
||||
);
|
||||
// 初始化(迁移 v1 配置)
|
||||
useEffect(() => {
|
||||
const stored = profileQ.data?.content;
|
||||
if (!stored) return;
|
||||
setConfig(migrateResumeConfig(stored.resume));
|
||||
setSelfIntro((stored.info as ProfileInfo | undefined)?.selfIntro ?? '');
|
||||
}, [profileQ.data]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(c => {
|
||||
const profile: Partial<ProfileInfo> = { ...(profileQ.data?.content?.info ?? {}) };
|
||||
const records: FootprintRow[] = listQ.data?.items ?? [];
|
||||
const cfg: ResumeConfig = config ?? defaultResumeConfig();
|
||||
const sections = useMemo(() => assembleSections(records, cfg), [records, profile, selfIntro, cfg]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => setConfig(pc => {
|
||||
if (!pc) return pc;
|
||||
const c = pc;
|
||||
const arr = [...c.sections];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= arr.length) return c;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const toggle = (i: number) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], enabled: !arr[i].enabled };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const retitle = (i: number, title: string) => setConfig(c => {
|
||||
const arr = [...c.sections];
|
||||
arr[i] = { ...arr[i], title };
|
||||
return { ...c, sections: arr };
|
||||
});
|
||||
const persist = (next: ResumeConfig) => {
|
||||
setConfig(next);
|
||||
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
|
||||
};
|
||||
|
||||
// 一页限定:内容超高时自动缩放
|
||||
useEffect(() => {
|
||||
if (!cfg.onePage) { setFitZoom(1); return; }
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const available = 1043; // A4 高 1123 - 上下 padding 80
|
||||
const h = el.scrollHeight;
|
||||
setFitZoom(h > available ? Math.max(0.7, available / h) : 1);
|
||||
}, [cfg.onePage, cfg.fontScale, cfg.density, sections]);
|
||||
|
||||
const zoom = cfg.fontScale * (cfg.onePage ? fitZoom : 1);
|
||||
|
||||
const exportWord = () => {
|
||||
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
|
||||
const html = resumeToWordHtml(sections, previewProfile, previewProfile.photoUrl);
|
||||
const html = resumeToWordHtml(sections, { ...profile, selfIntro }, cfg, profile.photoUrl);
|
||||
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -55,138 +71,311 @@ export function ResumePage() {
|
||||
a.download = (profile.name || '个人简历') + '.doc';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
toast('success', 'Word 文档已导出');
|
||||
toast('success', 'Word 文档已导出(按当前模板与字号)');
|
||||
});
|
||||
};
|
||||
|
||||
const sectionTitles = (key: string) => RESUME_SECTION_DEFS.find(d => d.key === key)?.defaultTitle ?? key;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="mx-auto max-w-[1280px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">我的简历</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setPanelOpen(v => !v)}>
|
||||
{panelOpen ? <PanelRightClose size={13} /> : <PanelRightOpen size={13} />}
|
||||
{panelOpen ? '收起设置' : '展开设置'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / 导出 PDF</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印 / PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
|
||||
{/* 布局面板 */}
|
||||
<Card className="no-print">
|
||||
<CardHeader title="板块布局" />
|
||||
<CardBody className="p-0">
|
||||
<div className="flex flex-col divide-y divide-line">
|
||||
{config.sections.map((s, i) => {
|
||||
const hasItems = sections.find(x => x.key === s.key);
|
||||
return (
|
||||
<div key={s.key} className="p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={s.enabled} onChange={() => toggle(i)} aria-label="启用板块" className="size-3.5" />
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 transition-colors hover:border-line focus:border-brand focus:outline-none"
|
||||
value={s.title}
|
||||
onChange={e => retitle(i, e.target.value)}
|
||||
/>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={13} /></button>
|
||||
<button className="rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-ink-1 disabled:opacity-30"
|
||||
aria-label="下移" disabled={i === config.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={13} /></button>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[11px] text-ink-3">
|
||||
{RESUME_SECTION_DEFS.find(d => d.key === s.key)?.types?.join(' / ') ?? '固定板块'}
|
||||
{s.enabled && !hasItems && <span className="text-warning"> · 暂无内容</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
<div className="border-t border-line p-3">
|
||||
<Field label="自我评价">
|
||||
<Textarea rows={4} value={selfIntro} onChange={e => setSelfIntro(e.target.value)} placeholder="简述性格、能力、研究方向…" />
|
||||
</Field>
|
||||
<Button size="sm" variant="primary" className="mt-2 w-full"
|
||||
disabled={!introDirty} onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await profileUpsert({ info: { ...profile, selfIntro }, resume: config });
|
||||
await profileQ.refetch();
|
||||
toast('success', '自我评价已保存');
|
||||
} catch (e) { toast('error', (e as Error).message); }
|
||||
})();
|
||||
}}>保存自我评价</Button>
|
||||
<p className="mt-1 text-[11px] text-ink-3">板块顺序与标题自动保存 · 内容来自「个人信息」与各类型记录</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 简历预览(A4 比例白卡) */}
|
||||
<div className={'grid gap-4 ' + (panelOpen ? 'lg:grid-cols-[1fr_340px]' : 'grid-cols-1')}>
|
||||
{/* 简历预览(A4 白卡,打印区) */}
|
||||
<div>
|
||||
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-8 text-[13px] leading-relaxed text-neutral-900 shadow-card sm:p-10">
|
||||
<ResumePreviewBody sections={sections} profile={previewProfile} />
|
||||
<div
|
||||
className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white shadow-card"
|
||||
style={{ minHeight: cfg.onePage ? 1123 : undefined }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={'p-8 sm:p-10 resume-tpl-' + cfg.template}
|
||||
style={{ zoom, '--accent': cfg.accent } as React.CSSProperties}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className={'text-[26px] font-bold leading-tight ' + (cfg.template === 'tech' ? 'text-[var(--accent)]' : 'text-neutral-900')}>
|
||||
{profile.name || '姓名'}
|
||||
</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[profile.school, profile.college, profile.major, profile.degree].filter(Boolean).join(' · ') || '完善个人信息后自动展示'}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[profile.phone, profile.email, profile.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
{profile.photoUrl && (
|
||||
<img src={profile.photoUrl} alt="证件照" className="h-[110px] w-[82px] rounded border border-neutral-200 object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 板块 */}
|
||||
{sections.map(sec => (
|
||||
<div key={sec.key} className="mb-5 r-sec">
|
||||
<div className="r-sec-title mb-2.5 text-[14px] font-bold text-neutral-900">{sec.title}</div>
|
||||
{sec.kind === 'info' ? (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([['姓名', profile.name], ['性别', profile.gender], ['出生年月', profile.birth], ['民族', profile.nation],
|
||||
['籍贯', profile.native], ['政治面貌', profile.political], ['班级', profile.className], ['学号', profile.studentId],
|
||||
['手机', profile.phone], ['邮箱', profile.email]] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
) : sec.kind === 'text' ? (
|
||||
<p className={'whitespace-pre-wrap text-neutral-700 ' + (cfg.density === 'compact' ? '' : 'leading-relaxed')}>
|
||||
{sec.text || '(在右侧板块设置中填写内容)'}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cfg.density === 'compact' ? 'flex flex-col gap-1.5' : 'flex flex-col gap-3'}>
|
||||
{sec.items?.map(it => (
|
||||
<div key={it.title + it.date}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[13px] font-semibold text-neutral-900 r-item-title">{it.title}</span>
|
||||
<span className="num shrink-0 text-[11.5px] text-neutral-500">{it.date ?? ''}</span>
|
||||
</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')}>{it.desc}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.onePage && (
|
||||
<div className="no-print mt-2 text-center text-[11.5px] text-ink-3">
|
||||
一页限定已启用:内容自动缩放 {Math.round(zoom * 100)}% 以适配单页 A4
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧设置面板(手机在下方) */}
|
||||
{panelOpen && (
|
||||
<div className="no-print flex flex-col gap-4 lg:sticky lg:top-4 lg:self-start">
|
||||
{/* 模板 */}
|
||||
<Card>
|
||||
<CardHeader title="模板与风格" />
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{TEMPLATES.map(t => (
|
||||
<button key={t.key}
|
||||
className={'rounded-md border px-3 py-2 text-left transition-all ' + (cfg.template === t.key ? 'border-brand bg-brand-subtle' : 'border-line bg-surface hover:border-line-strong')}
|
||||
onClick={() => persist({ ...cfg, template: t.key as ResumeTemplate })}>
|
||||
<div className={'text-[12.5px] font-semibold ' + (cfg.template === t.key ? 'text-brand-text' : 'text-ink-1')}>{t.name}</div>
|
||||
<div className="text-[11px] text-ink-3">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="主题色">
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{ACCENT_CHOICES.map(c => (
|
||||
<button key={c} aria-label={'主题色 ' + c}
|
||||
className={'size-5 rounded-full border-2 transition-transform ' + (cfg.accent === c ? 'scale-110 border-ink-1' : 'border-transparent')}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => persist({ ...cfg, accent: c })} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="字号">
|
||||
<Select value={String(cfg.fontScale)} onChange={e => persist({ ...cfg, fontScale: Number(e.target.value) })}
|
||||
options={[{ value: '0.9', label: '小' }, { value: '1', label: '标准' }, { value: '1.1', label: '大' }]} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="间距">
|
||||
<Select value={cfg.density} onChange={e => persist({ ...cfg, density: e.target.value as ResumeConfig['density'] })}
|
||||
options={[{ value: 'compact', label: '紧凑' }, { value: 'normal', label: '标准' }, { value: 'relaxed', label: '宽松' }]} />
|
||||
</Field>
|
||||
<Field label="单页限定">
|
||||
<button className={'flex h-9 w-full items-center justify-center gap-1.5 rounded-md border text-[12px] transition-colors ' + (cfg.onePage ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-2')}
|
||||
onClick={() => persist({ ...cfg, onePage: !cfg.onePage })}>
|
||||
{cfg.onePage ? '已开启' : '已关闭'}
|
||||
</button>
|
||||
</Field>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 板块布局:拖拽排序 + 启停 + 内容编辑 */}
|
||||
<Card>
|
||||
<CardHeader title="板块布局" extra={<span className="text-[11px] text-ink-3">拖动排序 · 内容可编辑</span>} />
|
||||
<CardBody className="p-0">
|
||||
<div className="flex flex-col divide-y divide-line">
|
||||
{cfg.sections.map((s, i) => {
|
||||
const open = openEditors.has(s.key);
|
||||
const kindLabel = s.kind === 'info' ? '固定' : s.kind === 'text' ? '文本' : '记录';
|
||||
return (
|
||||
<div key={s.key}
|
||||
draggable
|
||||
onDragStart={() => { dragKey.current = s.key; }}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
const from = cfg.sections.findIndex(x => x.key === dragKey.current);
|
||||
if (from >= 0 && from !== i) {
|
||||
const arr = [...cfg.sections];
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(i, 0, moved);
|
||||
persist({ ...cfg, sections: arr });
|
||||
}
|
||||
dragKey.current = null;
|
||||
}}
|
||||
className={'bg-surface ' + (dragKey.current === s.key ? 'opacity-40' : '')}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3 py-2">
|
||||
<GripVertical size={13} className="shrink-0 cursor-grab text-ink-3" />
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-transparent bg-transparent px-1 py-0.5 text-[12.5px] font-medium text-ink-1 hover:border-line focus:border-brand focus:outline-none"
|
||||
value={s.title}
|
||||
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, title: e.target.value } : x) })}
|
||||
/>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="上移"
|
||||
disabled={i === 0} onClick={() => move(i, -1)}><ArrowUp size={12} /></button>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1 disabled:opacity-30" aria-label="下移"
|
||||
disabled={i === cfg.sections.length - 1} onClick={() => move(i, 1)}><ArrowDown size={12} /></button>
|
||||
<button
|
||||
className={'ml-0.5 rounded-sm border px-1.5 py-0.5 text-[11px] transition-colors ' + (s.enabled ? 'border-brand/40 bg-brand-subtle text-brand-text' : 'border-line bg-surface-2 text-ink-3')}
|
||||
onClick={() => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, enabled: !x.enabled } : x) })}>
|
||||
{s.enabled ? '显示' : '隐藏'}
|
||||
</button>
|
||||
<button className="rounded p-1 text-ink-3 hover:text-ink-1" aria-label="展开编辑"
|
||||
onClick={() => setOpenEditors(prev => { const n = new Set(prev); n.has(s.key) ? n.delete(s.key) : n.add(s.key); return n; })}>
|
||||
<ChevronRight size={13} className={'transition-transform ' + (open ? 'rotate-90' : '')} />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="border-t border-line bg-surface-2 px-3 py-2.5">
|
||||
{s.kind === 'text' ? (
|
||||
<Textarea rows={4} value={s.text ?? ''} placeholder="输入该板块内容…"
|
||||
onChange={e => persist({ ...cfg, sections: cfg.sections.map(x => x.key === s.key ? { ...x, text: e.target.value } : x) })} />
|
||||
) : s.kind === 'list' ? (
|
||||
<ListSectionEditor
|
||||
cfg={cfg} section={s} records={records} persist={persist}
|
||||
sectionTitle={sectionTitles(s.key)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11.5px] text-ink-3">基本信息板块来自「个人信息」页,无法在此编辑。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-1.5 text-[10.5px] text-ink-3">{kindLabel}{s.types?.length ? ' · ' + s.types.join('/') : ''}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="p-3">
|
||||
<Button size="sm" variant="secondary" className="w-full"
|
||||
onClick={() => {
|
||||
const key = 'custom_' + Date.now();
|
||||
persist({
|
||||
...cfg,
|
||||
sections: [...cfg.sections, { key, title: '自定义板块', enabled: true, kind: 'text', text: '' }],
|
||||
});
|
||||
setOpenEditors(prev => new Set(prev).add(key));
|
||||
}}>
|
||||
<Plus size={13} /> 新增文本板块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePreviewBody({ sections, profile: P }: {
|
||||
sections: ReturnType<typeof assembleSections>;
|
||||
profile: Partial<ProfileInfo>;
|
||||
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
|
||||
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||
cfg: ResumeConfig;
|
||||
section: ResumeSectionCfg;
|
||||
records: FootprintRow[];
|
||||
persist: (c: ResumeConfig) => void;
|
||||
sectionTitle: string;
|
||||
}) {
|
||||
const [adding, setAdding] = useState<ResumeItemCfg | null>(null);
|
||||
const excluded = new Set(section.excluded ?? []);
|
||||
const derived = records
|
||||
.filter(r => (section.types ?? []).includes(r.type_name) && r.status !== 'draft')
|
||||
.sort((a, b) => (b.start_date ?? b.created_at).localeCompare(a.start_date ?? a.created_at));
|
||||
const customs = section.items ?? [];
|
||||
|
||||
const setSection = (patch: Partial<ResumeSectionCfg>) =>
|
||||
persist({ ...cfg, sections: cfg.sections.map(x => x.key === section.key ? { ...x, ...patch } : x) });
|
||||
|
||||
const toggleRecord = (id: number) => {
|
||||
const next = new Set(section.excluded ?? []);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
setSection({ excluded: [...next] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-[26px] font-bold leading-tight tracking-tight text-neutral-900">{P.name || '姓名'}</h1>
|
||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
||||
{[P.school, P.college, P.major, P.degree].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-neutral-500">
|
||||
{[P.phone, P.email, P.homepage].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-[11px] text-ink-3">
|
||||
来自记录的条目(勾选 = 显示)· 另可添加自定义条目
|
||||
</div>
|
||||
{derived.map(r => (
|
||||
<label key={r.id} className="flex cursor-pointer items-center gap-2 text-[12px]">
|
||||
<input type="checkbox" className="size-3.5" checked={!excluded.has(r.id)} onChange={() => toggleRecord(r.id)} />
|
||||
<span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span>
|
||||
<span className="num shrink-0 text-[11px] text-ink-3">{(r.start_date ?? '').slice(0, 7)}</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="mt-1 border-t border-line pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11.5px] font-medium text-ink-2">自定义条目({customs.length})</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAdding({ title: '' })}>
|
||||
<Plus size={12} /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
{P.photoUrl && <img src={P.photoUrl} alt="证件照" className="h-[120px] w-[90px] rounded border border-neutral-200 object-cover" />}
|
||||
{adding && (
|
||||
<div className="mb-2 rounded-md border border-line bg-surface p-2">
|
||||
<Input className="mb-1.5" placeholder="标题" value={adding.title} onChange={e => setAdding({ ...adding, title: e.target.value })} />
|
||||
<div className="mb-1.5 grid grid-cols-2 gap-1.5">
|
||||
<Input placeholder="副标题(单位/角色)" value={adding.subtitle ?? ''} onChange={e => setAdding({ ...adding, subtitle: e.target.value })} />
|
||||
<Input placeholder="日期(2025-06)" value={adding.date ?? ''} onChange={e => setAdding({ ...adding, date: e.target.value })} />
|
||||
</div>
|
||||
<Textarea rows={2} placeholder="描述" value={adding.desc ?? ''} onChange={e => setAdding({ ...adding, desc: e.target.value })} />
|
||||
<div className="mt-1.5 flex gap-1.5">
|
||||
<Button size="sm" variant="primary" disabled={!adding.title.trim()}
|
||||
onClick={() => {
|
||||
if (!adding.title.trim()) return;
|
||||
setSection({ items: [...customs, adding] });
|
||||
setAdding(null);
|
||||
}}>添加</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAdding(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{customs.map((c, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded-md border border-line bg-surface p-1.5 text-[12px]">
|
||||
<span className="min-w-0 flex-1 truncate text-ink-1">{c.title}<span className="text-ink-3"> · {c.date ?? ''}</span></span>
|
||||
<button className="text-ink-3 hover:text-danger" aria-label="删除条目"
|
||||
onClick={() => setSection({ items: customs.filter((_, x) => x !== idx) })}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!customs.length && !adding && (
|
||||
<p className="text-[11.5px] text-ink-3">暂无自定义条目</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.map(sec => (
|
||||
<div key={sec.key} className="mb-5">
|
||||
<div className="mb-2.5 border-b-2 border-neutral-800 pb-1 text-[14px] font-bold tracking-wide text-neutral-900">{sec.title}</div>
|
||||
{sec.key === 'info' ? (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-[12.5px]">
|
||||
{([
|
||||
['姓名', P.name], ['性别', P.gender], ['出生年月', P.birth], ['民族', P.nation],
|
||||
['籍贯', P.native], ['政治面貌', P.political], ['班级', P.className], ['学号', P.studentId],
|
||||
['手机', P.phone], ['邮箱', P.email],
|
||||
] as [string, string][]).filter(r => r[1]).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2"><span className="w-16 shrink-0 text-neutral-500">{k}</span><span>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
) : sec.text !== undefined ? (
|
||||
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">{sec.text || '(在「个人信息」中填写自我评价)'}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{sec.items?.map(it => {
|
||||
const r = it.row;
|
||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
||||
return (
|
||||
<div key={r.id}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[13px] font-semibold text-neutral-900">{r.title}</span>
|
||||
<span className="num shrink-0 text-[11.5px] text-neutral-500">{date}{r.end_date ? ' ~ ' + r.end_date.slice(5, 10) : ''}</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-neutral-600">
|
||||
{[r.organizer, r.level, r.award_grade].filter(Boolean).join(' · ')}
|
||||
{r.result && <span className="text-emerald-700"> — {r.result}</span>}
|
||||
</div>
|
||||
{r.description && <p className="mt-0.5 line-clamp-2 text-[12px] text-neutral-500">{r.description}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -164,3 +164,11 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
|
||||
.print-scroll { overflow: visible !important; height: auto !important; }
|
||||
.print-sheet { border: none !important; box-shadow: none !important; margin: 0 !important; max-width: none !important; width: 100% !important; padding: 0 !important; }
|
||||
}
|
||||
|
||||
/* ---------- 简历模板 ---------- */
|
||||
.resume-tpl-tech .r-sec-title { color: var(--accent); border-left: 3px solid var(--accent); padding-left: 8px; }
|
||||
.resume-tpl-tech .r-item-title { color: var(--accent); }
|
||||
.resume-tpl-tech .num { color: var(--accent); }
|
||||
.resume-tpl-minimal .r-sec-title { color: #666; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; font-size: 11px; border-bottom: 1px solid #e2e2e2; padding-bottom: 4px; }
|
||||
.resume-tpl-classic .r-sec-title { border-bottom: 2px solid #222; padding-bottom: 4px; }
|
||||
.resume-tpl-minimal .r-item-title { font-weight: 600; }
|
||||
|
||||
Reference in New Issue
Block a user