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';
|
import type { FootprintRow, ProfileInfo, ResumeConfig } from './types';
|
||||||
|
|
||||||
|
// ---------- 简历数据模型 v2 ----------
|
||||||
|
|
||||||
|
export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number }
|
||||||
|
|
||||||
export interface ResumeSectionDef {
|
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[] = [
|
export const RESUME_SECTION_DEFS: ResumeSectionDef[] = [
|
||||||
{ key: 'info', defaultTitle: '基本信息', fixed: true },
|
{ key: 'info', defaultTitle: '基本信息', kind: 'info', fixed: true },
|
||||||
{ key: 'education', defaultTitle: '教育背景', types: ['学业'] },
|
{ key: 'education', defaultTitle: '教育背景', kind: 'list', types: ['学业'] },
|
||||||
{ key: 'skills', defaultTitle: '技能证书', types: ['证书技能'] },
|
{ key: 'skills', defaultTitle: '技能证书', kind: 'list', types: ['证书技能'] },
|
||||||
{ key: 'awards', defaultTitle: '荣誉奖项', types: ['荣誉惩处'] },
|
{ key: 'awards', defaultTitle: '荣誉奖项', kind: 'list', types: ['荣誉惩处'] },
|
||||||
{ key: 'competition', defaultTitle: '竞赛经历', types: ['竞赛'] },
|
{ key: 'competition', defaultTitle: '竞赛经历', kind: 'list', types: ['竞赛'] },
|
||||||
{ key: 'research', defaultTitle: '科研经历', types: ['科研'] },
|
{ key: 'research', defaultTitle: '科研经历', kind: 'list', types: ['科研'] },
|
||||||
{ key: 'practice', defaultTitle: '实践经历', types: ['志愿公益', '实习实践', '学生工作'] },
|
{ key: 'practice', defaultTitle: '实践经历', kind: 'list', types: ['志愿公益', '实习实践', '学生工作'] },
|
||||||
{ key: 'activity', defaultTitle: '文体活动', types: ['文体活动'] },
|
{ key: 'activity', defaultTitle: '文体活动', kind: 'list', types: ['文体活动'] },
|
||||||
{ key: 'other', defaultTitle: '其他经历' },
|
{ key: 'other', defaultTitle: '其他经历', kind: 'list' }, // 未归类的自定义类型 + 自定义条目
|
||||||
{ key: 'self', defaultTitle: '自我评价', fixed: true },
|
{ key: 'self', defaultTitle: '自我评价', kind: 'text', fixed: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function defaultResumeConfig(): ResumeConfig {
|
export interface ResumeSectionCfg {
|
||||||
return { sections: RESUME_SECTION_DEFS.map(s => ({ key: s.key, enabled: true, title: s.defaultTitle })) };
|
key: string;
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
kind?: 'info' | 'list' | 'text';
|
||||||
|
types?: string[];
|
||||||
|
excluded?: number[]; // 隐藏的记录 id
|
||||||
|
items?: ResumeItemCfg[]; // 自定义条目
|
||||||
|
text?: string; // text 板块内容
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumeItem {
|
export type ResumeTemplate = 'classic' | 'tech' | 'minimal';
|
||||||
row: FootprintRow;
|
|
||||||
|
|
||||||
|
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 {
|
export interface AssembledSection {
|
||||||
key: string; title: string;
|
key: string; title: string; kind: 'info' | 'list' | 'text';
|
||||||
items?: ResumeItem[];
|
items?: AssembledItem[]; text?: string;
|
||||||
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(
|
export function assembleSections(
|
||||||
records: FootprintRow[],
|
records: FootprintRow[],
|
||||||
profile: Partial<ProfileInfo> | undefined,
|
config: ResumeConfig
|
||||||
config: ResumeConfig | undefined
|
|
||||||
): AssembledSection[] {
|
): 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[] = [];
|
const out: AssembledSection[] = [];
|
||||||
for (const s of cfg) {
|
const claimed = claimedTypes();
|
||||||
|
|
||||||
|
for (const s of config.sections) {
|
||||||
if (!s.enabled) continue;
|
if (!s.enabled) continue;
|
||||||
const def = byKey.get(s.key);
|
const def = RESUME_SECTION_DEFS.find(d => d.key === 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; }
|
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;
|
const types = def?.types;
|
||||||
if (!types) continue;
|
if (types) {
|
||||||
const items = records.filter(r => types.includes(r.type_name) && r.status !== 'draft');
|
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;
|
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, kind: 'list', items });
|
||||||
out.push({ key: s.key, title: s.title, items: items.map(row => ({ row })) });
|
|
||||||
}
|
}
|
||||||
return out;
|
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(
|
export function resumeToWordHtml(
|
||||||
sections: AssembledSection[],
|
sections: AssembledSection[],
|
||||||
profile: Partial<ProfileInfo> | undefined,
|
profile: Partial<ProfileInfo>,
|
||||||
|
cfg: ResumeConfig,
|
||||||
photoUrl?: string
|
photoUrl?: string
|
||||||
): string {
|
): string {
|
||||||
const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const scale = cfg.fontScale;
|
||||||
const P = profile ?? {};
|
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 => {
|
const secHtml = sections.map(sec => {
|
||||||
let inner = '';
|
let inner = '';
|
||||||
if (sec.key === 'info') {
|
if (sec.kind === 'info') {
|
||||||
const rows: [string, string][] = ([
|
const rows: [string, string][] = ([
|
||||||
['姓名', P.name ?? ''], ['性别', P.gender ?? ''], ['出生年月', P.birth ?? ''],
|
['姓名', P0(profile).name], ['性别', P0(profile).gender], ['出生年月', P0(profile).birth],
|
||||||
['民族', P.nation ?? ''], ['籍贯', P.native ?? ''], ['政治面貌', P.political ?? ''],
|
['民族', P0(profile).nation], ['籍贯', P0(profile).native], ['政治面貌', P0(profile).political],
|
||||||
['学校', P.school ?? ''], ['学院', P.college ?? ''], ['专业', P.major ?? ''],
|
['学校', P0(profile).school], ['学院', P0(profile).college], ['专业', P0(profile).major],
|
||||||
['班级', P.className ?? ''], ['学号', P.studentId ?? ''], ['学历', P.degree ?? ''],
|
['班级', P0(profile).className], ['学号', P0(profile).studentId], ['学历', P0(profile).degree],
|
||||||
['手机', P.phone ?? ''], ['邮箱', P.email ?? ''],
|
['手机', P0(profile).phone], ['邮箱', P0(profile).email],
|
||||||
] as [string, string][]).filter(r => r[1] !== '');
|
] 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) =>
|
rows.map(([k, v], i) =>
|
||||||
(i % 2 === 0 ? '<tr>' : '') +
|
(i % 2 === 0 ? '<tr>' : '') +
|
||||||
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(String(k)) + '</td>' +
|
'<td style="width:80px;padding:3px 6px;color:#555">' + esc(k) + '</td>' +
|
||||||
'<td style="padding:3px 6px">' + esc(String(v)) + '</td>' +
|
'<td style="padding:3px 6px">' + esc(v) + '</td>' +
|
||||||
(i % 2 === 1 ? '</tr>' : '')
|
(i % 2 === 1 ? '</tr>' : '')
|
||||||
).join('') + '</table>';
|
).join('') + '</table>';
|
||||||
} else if (sec.items) {
|
} else if (sec.kind === 'list') {
|
||||||
inner = '<div style="margin-bottom:6pt">' + sec.items.map(it => {
|
inner = '<div style="margin-bottom:6pt">' + (sec.items ?? []).map(it => {
|
||||||
const r = it.row;
|
const sub = it.subtitle ? '<div style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.subtitle) + '</div>' : '';
|
||||||
const date = (r.start_date ?? r.created_at).slice(0, 10);
|
const desc = it.desc ? '<div style="color:#444;font-size:' + pt(11) + ';line-height:1.5">' + esc(it.desc) + '</div>' : '';
|
||||||
const sub = [r.organizer, r.level, r.award_grade].filter(v => v != null && v !== '').map(v => esc(String(v))).join(' · ');
|
|
||||||
return '<div style="margin-bottom:7pt">' +
|
return '<div style="margin-bottom:7pt">' +
|
||||||
'<div style="display:flex;justify-content:space-between"><b style="font-size:12pt">' + esc(r.title) + '</b>' +
|
'<div style="display:flex;justify-content:space-between"><b style="font-size:' + pt(12) + '">' + esc(it.title) + '</b>' +
|
||||||
'<span style="color:#666;font-size:10.5pt">' + esc(date) + '</span></div>' +
|
'<span style="color:#666;font-size:' + pt(10.5) + '">' + esc(it.date ?? '') + '</span></div>' +
|
||||||
(sub ? '<div style="color:#666;font-size:10.5pt">' + sub + '</div>' : '') +
|
sub + desc + '</div>';
|
||||||
(r.result ? '<div style="color:#177245;font-size:11pt">' + esc(r.result) + '</div>' : '') +
|
|
||||||
'</div>';
|
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
} else if (sec.text) {
|
} else {
|
||||||
inner = '<p style="font-size:12pt;line-height:1.7;margin:0">' + esc(sec.text) + '</p>';
|
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('');
|
}).join('');
|
||||||
|
|
||||||
const photo = photoUrl && P.photoUrl
|
const photo = photoUrl && profile.photoUrl
|
||||||
? '<div style="text-align:right"><img src="' + esc(P.photoUrl) + '" alt="证件照" style="width:90pt;height:120pt;object-fit:cover" /></div>'
|
? '<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">' +
|
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>' +
|
'<head><meta charset="utf-8"><title>' + esc(P0(profile).name || '个人简历') + '</title></head>' +
|
||||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt">' +
|
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
|
||||||
'<div style="display:flex;justify-content:space-between;align-items:flex-start">' +
|
'<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>' +
|
'<h1 style="font-size:' + pt(20) + ';margin:0 0 4pt;color:' + (cfg.template === 'tech' ? accent : '#111') + '">' + esc(P0(profile).name || '个人简历') + '</h1>' + photo + '</div>' +
|
||||||
(P.major || P.school ? '<div style="color:#555;font-size:11pt;margin-bottom:8pt">' +
|
(headSub ? '<div style="color:#555;font-size:' + pt(11) + ';margin-bottom:8pt">' + headSub + '</div>' : '') +
|
||||||
[P.school, P.college, P.major, P.degree].filter(v => !!v).map(v => esc(String(v))).join(' · ') + '</div>' : '') +
|
|
||||||
secHtml +
|
secHtml +
|
||||||
'</body></html>';
|
'</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;
|
phone: string; email: string; homepage: string; photoUrl: string;
|
||||||
selfIntro: string;
|
selfIntro: string;
|
||||||
}
|
}
|
||||||
export interface ResumeSectionCfg { key: string; enabled: boolean; title: string; }
|
export interface ResumeItemCfg { title: string; subtitle?: string; date?: string; desc?: string; fromRecord?: number }
|
||||||
export interface ResumeConfig { sections: ResumeSectionCfg[]; }
|
|
||||||
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { ArrowDown, ArrowUp, Download, Printer } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ChevronRight, Download, GripVertical, PanelRightClose, PanelRightOpen, Plus, Printer, Trash2 } from 'lucide-react';
|
||||||
import { footprintList, profileGet } from '@/lib/api';
|
import { footprintList, profileGet, profileUpsert } from '@/lib/api';
|
||||||
import type { ProfileInfo, ResumeConfig } from '@/lib/types';
|
import type { FootprintRow, ProfileInfo, ResumeConfig, ResumeItemCfg, ResumeSectionCfg } from '@/lib/types';
|
||||||
import { assembleSections, defaultResumeConfig, RESUME_SECTION_DEFS } from '@/lib/resume';
|
import {
|
||||||
import { profileUpsert } from '@/lib/api';
|
ACCENT_CHOICES, assembleSections, defaultResumeConfig, migrateResumeConfig,
|
||||||
import { Button, Card, CardBody, CardHeader, Field, Textarea, useToast } from '@/components/ui';
|
RESUME_SECTION_DEFS, TEMPLATES, type ResumeTemplate,
|
||||||
|
} from '@/lib/resume';
|
||||||
function selfIntroDraftFix(v: string) { return v; }
|
import { Button, Card, CardBody, CardHeader, Field, Input, Select, Textarea, useToast } from '@/components/ui';
|
||||||
|
|
||||||
export function ResumePage() {
|
export function ResumePage() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||||
const listQ = useQuery({ queryKey: ['resume-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
const listQ = useQuery({ queryKey: ['resume-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
||||||
|
|
||||||
const stored = profileQ.data?.content;
|
const [config, setConfig] = useState<ResumeConfig | null>(null);
|
||||||
const profile = { ...(stored?.info ?? {}) } as Partial<ProfileInfo>;
|
const [selfIntro, setSelfIntro] = useState('');
|
||||||
const [config, setConfig] = useState<ResumeConfig>(() => stored?.resume ?? defaultResumeConfig());
|
const [panelOpen, setPanelOpen] = useState(true);
|
||||||
const [selfIntro, setSelfIntro] = useState(profile.selfIntro ?? '');
|
const [openEditors, setOpenEditors] = useState<Set<string>>(new Set());
|
||||||
const introDirty = selfIntro !== (profile.selfIntro ?? '');
|
const [fitZoom, setFitZoom] = useState(1);
|
||||||
const previewProfile: Partial<ProfileInfo> = { ...profile, selfIntro: selfIntroDraftFix(selfIntro) };
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragKey = useRef<string | null>(null);
|
||||||
|
|
||||||
const records = listQ.data?.items ?? [];
|
// 初始化(迁移 v1 配置)
|
||||||
const sections = useMemo(
|
useEffect(() => {
|
||||||
() => assembleSections(records, previewProfile, config),
|
const stored = profileQ.data?.content;
|
||||||
[records, previewProfile, config]
|
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 arr = [...c.sections];
|
||||||
const j = i + dir;
|
const j = i + dir;
|
||||||
if (j < 0 || j >= arr.length) return c;
|
if (j < 0 || j >= arr.length) return c;
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
return { ...c, sections: arr };
|
return { ...c, sections: arr };
|
||||||
});
|
});
|
||||||
const toggle = (i: number) => setConfig(c => {
|
const persist = (next: ResumeConfig) => {
|
||||||
const arr = [...c.sections];
|
setConfig(next);
|
||||||
arr[i] = { ...arr[i], enabled: !arr[i].enabled };
|
void profileUpsert({ info: { ...profile, selfIntro }, resume: next }).catch(e => toast('error', e.message));
|
||||||
return { ...c, sections: arr };
|
};
|
||||||
});
|
|
||||||
const retitle = (i: number, title: string) => setConfig(c => {
|
// 一页限定:内容超高时自动缩放
|
||||||
const arr = [...c.sections];
|
useEffect(() => {
|
||||||
arr[i] = { ...arr[i], title };
|
if (!cfg.onePage) { setFitZoom(1); return; }
|
||||||
return { ...c, sections: arr };
|
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 = () => {
|
const exportWord = () => {
|
||||||
void import('@/lib/resume').then(({ resumeToWordHtml }) => {
|
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 blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -55,138 +71,311 @@ export function ResumePage() {
|
|||||||
a.download = (profile.name || '个人简历') + '.doc';
|
a.download = (profile.name || '个人简历') + '.doc';
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
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 (
|
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">
|
<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>
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
|
<div className={'grid gap-4 ' + (panelOpen ? 'lg:grid-cols-[1fr_340px]' : 'grid-cols-1')}>
|
||||||
{/* 布局面板 */}
|
{/* 简历预览(A4 白卡,打印区) */}
|
||||||
<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>
|
<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">
|
<div
|
||||||
<ResumePreviewBody sections={sections} profile={previewProfile} />
|
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>
|
</div>
|
||||||
|
{cfg.onePage && (
|
||||||
|
<div className="no-print mt-2 text-center text-[11.5px] text-ink-3">
|
||||||
|
一页限定已启用:内容自动缩放 {Math.round(zoom * 100)}% 以适配单页 A4
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResumePreviewBody({ sections, profile: P }: {
|
// ---------- 列表板块编辑器:记录条目显隐 + 自定义条目 ----------
|
||||||
sections: ReturnType<typeof assembleSections>;
|
function ListSectionEditor({ cfg, section, records, persist, sectionTitle }: {
|
||||||
profile: Partial<ProfileInfo>;
|
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 (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2.5">
|
||||||
<div className="mb-6 flex items-start justify-between gap-6">
|
<div className="text-[11px] text-ink-3">
|
||||||
<div>
|
来自记录的条目(勾选 = 显示)· 另可添加自定义条目
|
||||||
<h1 className="text-[26px] font-bold leading-tight tracking-tight text-neutral-900">{P.name || '姓名'}</h1>
|
</div>
|
||||||
<div className="mt-1.5 text-[12.5px] text-neutral-500">
|
{derived.map(r => (
|
||||||
{[P.school, P.college, P.major, P.degree].filter(Boolean).join(' · ')}
|
<label key={r.id} className="flex cursor-pointer items-center gap-2 text-[12px]">
|
||||||
</div>
|
<input type="checkbox" className="size-3.5" checked={!excluded.has(r.id)} onChange={() => toggleRecord(r.id)} />
|
||||||
<div className="mt-1 text-[12px] text-neutral-500">
|
<span className="min-w-0 flex-1 truncate text-ink-1">{r.title}</span>
|
||||||
{[P.phone, P.email, P.homepage].filter(Boolean).join(' · ')}
|
<span className="num shrink-0 text-[11px] text-ink-3">{(r.start_date ?? '').slice(0, 7)}</span>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{sections.map(sec => (
|
<p className="text-[11px] text-ink-3">「{sectionTitle}」板块的记录条目来自你的记录数据,此处可隐藏不需要展示的条目。</p>
|
||||||
<div key={sec.key} className="mb-5">
|
</div>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,3 +164,11 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
|
|||||||
.print-scroll { overflow: visible !important; height: auto !important; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{r as g,f as v,j as t,S as j,t as S}from"./index-DkrEeFZh.js";import{C as c,a as d,b as x}from"./card-DdglV9lU.js";import{u as N,i as u,a as m}from"./useChart-DNtmZa2Q.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.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:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
|
import{r as g,f as v,j as t,S as j,t as S}from"./index-CSVqyJB0.js";import{C as c,a as d,b as x}from"./card-BcMbz03g.js";import{u as N,i as u,a as m}from"./useChart-CUy-j464.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.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:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-DkrEeFZh.js";import{C,a as M,b as _}from"./card-DdglV9lU.js";import{u as X,i as Q,a as K}from"./useChart-DNtmZa2Q.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
|
import{c as R,f as I,j as e,E as O,w as $,u as N,d as f,r as u,g as v,x as H,y as W,B as b,P as q,C as L,z as J,A as T,D as U,m as k,l as F,T as z,I as g,F as Y,H as S,J as E,K as V}from"./index-CSVqyJB0.js";import{C,a as M,b as _}from"./card-BcMbz03g.js";import{u as X,i as Q,a as K}from"./useChart-CUy-j464.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as U,u as C,d as E,r as p,f as F,g as S,j as e,B as f,l as q,a9 as B,F as z,H as k,S as M,I as Q,aa as _,ab as K,ac as v,ad as L}from"./index-DkrEeFZh.js";/**
|
import{c as U,u as C,d as E,r as p,f as F,g as S,j as e,B as f,l as q,a9 as B,F as z,H as k,S as M,I as Q,aa as _,ab as K,ac as v,ad as L}from"./index-CSVqyJB0.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-DkrEeFZh.js";/**
|
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-CSVqyJB0.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-DkrEeFZh.js";import{C as Y}from"./card-DdglV9lU.js";/**
|
import{c as D,u as K,d as F,f as L,r,g as I,j as e,B as p,P as q,M as O,m as _,l as H,T as P,C as R,N as G,F as U,H as i,I as j,O as W,S as z,Q as J,U as V,q as X}from"./index-CSVqyJB0.js";import{C as Y}from"./card-BcMbz03g.js";/**
|
||||||
* @license lucide-react v0.475.0 - ISC
|
* @license lucide-react v0.475.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{j as s,a}from"./index-DkrEeFZh.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
|
import{j as s,a}from"./index-CSVqyJB0.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{r}from"./index-DkrEeFZh.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
|
import{r}from"./index-CSVqyJB0.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
<link rel="icon" href="/icons/pwa-192.png" />
|
<link rel="icon" href="/icons/pwa-192.png" />
|
||||||
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
|
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
|
||||||
<title>足迹 Footprints</title>
|
<title>足迹 Footprints</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DkrEeFZh.js"></script>
|
<script type="module" crossorigin src="/assets/index-CSVqyJB0.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BheMcaGY.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CIs6Yi-t.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user