Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
2251 lines (2019 loc) · 100 KB
/
Copy pathapp.js
File metadata and controls
2251 lines (2019 loc) · 100 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* ----------------------------------------------------
简历生成器核心逻辑控制器 (app.js)
特点:响应式双向绑定、动态经历项的CRUD排序、自定义板块管理、
JSON源码实时双向修改 (对AI极其友好)、LocalStorage缓存。
已定制:已将高质量程序员的真实高含金量简历数据(骨融合器AI系统、体感温度计、TalkNative等)
作为系统默认数据源,彻底应用STAR原则进行文案深度优化。
已定制:通用简历模版,适配各类开发岗位需求。
---------------------------------------------------- */
// 缓存键名参考声明文档:'easycv_resume_content', 'easycv_resume_settings_a', 'easycv_resume_settings_b'
// 兼容性修补辅助字面量:state.settings.titleSize === undefined , state.settings.titleWeight === undefined
// ====================================================
// 1. 默认的高质量程序员简历样本数据 (通用版)
// ====================================================
const SAMPLE_RESUME_DATA = {
info: {
name: "张三",
title: "高级软件开发工程师 / 技术经理",
email: "zhangsan@example.com",
phone: "138-8888-8888",
wechat: "",
location: "北京 / 上海",
github: "github.com/zhangsan",
blog: "zhangsan.dev",
summary: "具备 5 年以上核心系统开发与团队管理经验,精通多门主流开发语言与高并发分布式架构设计。曾在知名互联网公司主导多项核心业务系统重构,有效降低系统故障率,提升研发团队能效。作为一名热衷开源与自驱的技术人,乐于用工程手段解决实际问题,创造真实业务价值。"
},
skills: [
{ category: "后端与云原生", tags: "Java/Go, Spring Cloud, Docker, Kubernetes, 微服务架构" },
{ category: "前端与全栈", tags: "React/Vue, TypeScript, Next.js, 响应式网页设计" },
{ category: "数据库与中间件", tags: "MySQL 性能优化, Redis 缓存方案, Kafka 消息队列, 搜索引擎 Elasticsearch" },
{ category: "研发工程化", tags: "CI/CD 自动化流水线, Git 工作流, 单元测试与重构, 敏捷项目管理" }
],
work: [
{
company: "北京某知名互联网科技有限公司",
role: "高级软件工程师 / 技术负责人",
time: "2022.06 - 至今",
details: [
"【工作职责】负责公司核心交易系统的架构设计与日常迭代开发,带领 5 人技术小组完成系统微服务化拆分与性能攻坚。",
"【技术攻坚】优化 MySQL 慢查询,引入 Redis 缓存与分布式锁机制,将系统核心接口高并发响应时间缩短了 <strong>40%</strong>,成功应对多次大促活动流量考验。",
"【量化成果】重构核心结算模块,代码冗余度降低 <strong>30%</strong>,在系统承载流量增长 2 倍的情况下,系统稳定性维持在 <strong>99.99%</strong> 以上。"
]
}
],
projects: [
{
name: "企业级高并发日志监控与分析平台",
tech: "Go, Elasticsearch, Kafka, React, Docker",
time: "2023.03 - 2024.01",
details: [
"【项目背景】解决传统服务器日志分析耗时、多机排查不便的问题,自研端到端分布式日志收集系统。",
"【技术攻坚】利用 <strong>Kafka</strong> 作为消息缓冲区降低写入压力,采用 <strong>Elasticsearch</strong> 分片索引策略实现百万级数据秒级检索,前端使用 <strong>React + Chart.js</strong> 提供实时可视化看板。",
"【最终成效】系统日处理日志量突破 <strong>1亿条</strong>,故障定位耗时从 30分钟 <strong>极限缩短至 1分钟内</strong>,大幅提升了运维团队的工作效率。"
]
}
],
education: [
{
school: "上海对外经贸大学",
degree: "工学学士",
major: "数据科学与大数据技术 (数据工程与算法方向)",
time: "2023.09 - 2027.06",
details: [
"核心课程:机器学习方法、深度学习、自然语言处理(NLP)、最优化方法、数据库原理与设计、应用时间序列分析等。荣获中国国际“互联网+”大学生创新创业大赛国赛<strong>铜奖</strong>。"
]
}
],
custom: [
{
title: "GitHub 个人开源项目经历",
items: [
{
title: "Thermal Comfort Meter 室内外双重体感温度分析与全球气候校准系统",
subtitle: "项目发起人与核心开发者 (自主设计物理动力学数学模型,已在 GitHub 完整开源)",
time: "2026.04 - 2026.05",
details: [
"<strong>自主设计并开源全球首创的“室内/室外双重体感温度分态计算架构”</strong>,打破传统单一气象参数局限,精准解决室内闷热感与低碳节能温控决策痛点。",
"<strong>独创物理学“墙体热惯性蓄热模型”与“太阳短波辐射修正算法”</strong>,结合 <strong>ASHRAE Standard 55 / Fanger PMV</strong> 热舒适度原理,实现无传感器下的高精密室内 stuffiness 自适应补偿。",
"基于 <strong>Capacitor 与 Chart.js</strong> 封装移动跨平台 App,适配全球柯本气候分类(Köppen)体系下典型城市气象自校准,具备极强的高能效民用降本增效实用价值。"
]
},
{
title: "TalkNative 智能 AI 外语地道听力与口语训练全栈系统",
subtitle: "项目发起人与独立全栈开发者 (自主设计 AI 大模型应用层系统,已在 GitHub 完整开源)",
time: "2026.05 - 至今",
details: [
"针对传统外语口语学习机械死板的缺陷,<strong>基于 Next.js 与 React 独立研发并完整开源</strong> 高拟真 AI 外语地道口语与听力交互训练平台。",
"深度集成云端 LLM 接口实现动态日常对话情境生成,利用 <strong>Web Audio API</strong> 实现浏览器端极低延迟音频捕获流,开源后已被多位外语学习者高频使用验证。"
]
}
]
}
],
// 默认排版样式设置 (已调整间距与字号,默认实现完美的单页高密度精致排版)
settings: {
...DESIGN_DEFAULTS,
template: "tpl-classic",
font: "font-notosanssc-offline",
accentColor: "#334155", // 默认高级石墨灰,与高密度稳重技术底色呼应
fontSize: 10.5, // 新建简历使用更易读的正文,已有方案沿用缓存设置。
lineHeight: 1.45,
paddingY: 12, // 压缩到 12mm 确保单页不溢出
paddingX: 16, // 优雅的 16mm 页边距
secSpacing: 8, // 压缩板块间距到 8px
itemSpacing: 3, // 经历项列表间距设为 3px
titleSize: 1.15, // 默认板块标题比例系数 1.15
titleWeight: 700, // 默认板块标题粗细 700
listIndent: 15, // 默认经历项细节缩进 15px
// 新增优雅侧边双栏左右分栏的精细度独立滑块默认配置项
sidebarFontSize: 8.5,
mainFontSize: 9.0,
sidebarSecSpacing: 6,
mainSecSpacing: 6,
sidebarItemSpacing: 2,
mainItemSpacing: 2,
sidebarTitleSize: 1.05,
mainTitleSize: 1.12,
sidebarListIndent: 10,
mainListIndent: 15
}
};
// ====================================================
// 2. 全局状态 State 与初始化 (共享内容 + 双设计方案对比系统)
// ====================================================
let currentScheme = resumeStorage.getItem("easycv_resume_current_scheme") || 'A';
if (!['A', 'B'].includes(currentScheme)) currentScheme = 'A';
let state = {};
let isViewingExample = currentScheme === 'B';
let customZoomFactor = 1.0; // 用户手动缩放因子 (Ctrl + 鼠标滚轮)
let lastRenderedTemplate = null; // 黄金焦点保护:用于检测滑块模板是否改变的缓存标识
// 方案 A 默认高端商务蓝单栏
const DEFAULT_SETTINGS_A = {
...SAMPLE_RESUME_DATA.settings,
accentColor: "#1e3a8a",
template: "tpl-classic",
font: "font-notosanssc-offline"
};
// 方案 B 默认石墨灰侧边双栏 (排版紧凑)
const DEFAULT_SETTINGS_B = {
...SAMPLE_RESUME_DATA.settings,
accentColor: "#334155",
template: "tpl-split",
font: "font-glow",
sidebarFontSize: 8.5,
mainFontSize: 9.0,
sidebarSecSpacing: 6,
mainSecSpacing: 6,
sidebarItemSpacing: 2,
mainItemSpacing: 2,
sidebarTitleSize: 1.05,
mainTitleSize: 1.12,
sidebarListIndent: 10,
mainListIndent: 15
};
// 加载共享的简历文字内容数据
function loadSharedContent() {
const cachedContent = resumeStorage.getItem("easycv_resume_content");
if (cachedContent) {
try {
const content = JSON.parse(cachedContent);
if (content.info) return normalizeResume(content);
} catch (e) {
console.error("加载共享内容失败,使用默认值", e);
}
}
// 兼容历史老版本数据,防止用户原本编辑的文字丢失!
const oldState = resumeStorage.getItem("easycv_resume_state");
if (oldState) {
try {
const parsed = JSON.parse(oldState);
if (parsed.info) return normalizeResume(parsed);
} catch (e) {}
}
return normalizeResume(SAMPLE_RESUME_DATA);
}
// 加载指定方案的排版设置
function loadSchemeSettings(schemeName) {
const key = `easycv_resume_settings_${schemeName.toLowerCase()}`;
const cachedSettings = resumeStorage.getItem(key);
if (cachedSettings) {
try {
const settings = JSON.parse(cachedSettings);
if (settings && typeof settings === 'object') {
if (settings.titleSize === undefined) settings.titleSize = 1.15;
if (settings.titleWeight === undefined) settings.titleWeight = 700;
// 字体安全性降级校验:防止历史缓存的已废弃衬线字体导致报错
if (settings.font === "font-garamond" || settings.font === "font-playfair") {
settings.font = "font-inter";
}
const defaultBase = schemeName === 'A' ? DEFAULT_SETTINGS_A : DEFAULT_SETTINGS_B;
return normalizeResume({info:{}, settings}, defaultBase).settings;
}
} catch (e) {}
}
const defaultSettings = schemeName === 'A' ? { ...DEFAULT_SETTINGS_A } : { ...DEFAULT_SETTINGS_B };
if (defaultSettings.font === "font-garamond" || defaultSettings.font === "font-playfair") {
defaultSettings.font = "font-inter";
}
return defaultSettings;
}
// 合成当前的运行 state 数据
function initActiveState() {
const content = loadSharedContent();
const settings = loadSchemeSettings(currentScheme);
state = {
...content,
settings: settings
};
lastRenderedTemplate = null; // 数据重装时重置缓存,强制触发滑块重构与事件重新绑定
}
document.addEventListener("DOMContentLoaded", async () => {
if (EasyCVShare.active) await EasyCVShare.prepare();
currentScheme = resumeStorage.getItem("easycv_resume_current_scheme") || 'A';
if (!['A', 'B'].includes(currentScheme)) currentScheme = 'A';
isViewingExample = currentScheme === 'B';
// 强制完成数据合并与初始化
initActiveState();
// 绑定所有的 DOM 事件监听与初始化
initApp();
// 刷新方案对比 UI 展现
updateSlotUI();
EasyCVShare.initUI();
});
// ====================================================
// 3. 应用核心绑定与控制器初始化
// ====================================================
function initApp() {
// A. 导航 Tab 切换逻辑
const tabBtns = document.querySelectorAll(".tab-btn");
const tabScrollPositions = new Map();
const scroller = document.querySelector('.tab-content-container');
const rememberTabScroll = () => {
const previous = document.querySelector('.tab-btn.active');
if (previous && scroller.clientHeight) tabScrollPositions.set(previous.dataset.tab, scroller.scrollTop);
};
document.addEventListener('workspace-before-preview', rememberTabScroll);
tabBtns.forEach(btn => {
btn.addEventListener("click", () => {
rememberTabScroll();
tabBtns.forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
const targetPane = document.getElementById(btn.dataset.tab);
targetPane.classList.add("active");
document.dispatchEvent(new CustomEvent('workspace-tab-change', { detail: btn.dataset.tab }));
// The mobile editor must be visible before its scroll position can be restored.
scroller.scrollTop = tabScrollPositions.get(btn.dataset.tab) || 0;
});
});
// B. 基本信息与个人简介输入框的双向绑定事件监听
const bindableInputs = document.querySelectorAll("[data-bind]");
bindableInputs.forEach(input => {
const path = input.dataset.bind;
// 渲染初始值到表单中
setNestedValue(input, path, getNestedValue(state, path));
// 监听表单录入
input.addEventListener("input", (e) => {
setNestedValue(null, path, e.target.value);
saveToLocal();
// 实时同步右侧 A4 画布的对应节点,避免触发 renderAll 丢失光标!
const editableNode = document.querySelector(`[data-edit-path="${path}"]`);
if (['info.email', 'info.phone', 'info.location', 'info.wechat', 'info.github', 'info.blog'].includes(path)) {
// 联系方式的图标、链接和文字是一个整体,空值时全部移除。
renderResumeHTML();
} else if (editableNode) {
editableNode.innerHTML = formatResumeText(e.target.value);
} else {
// 如果是复杂的或者页面的,退回 renderResumeHTML
renderResumeHTML();
}
updateJSONCodearea();
});
});
// C. 排版样式调节滑块与按钮初始化
initStyleControls();
// D. 首次全面渲染
renderAll();
// E. 暴露全局 API 接口给 window 供 AI 或外部直接脚本极速读写,满足高级操作要求
window.resumeApp = {
get state() { return state; },
render: renderAll,
getData: () => JSON.parse(JSON.stringify(state)),
setData: (newData) => {
state = normalizeResume(newData);
lastRenderedTemplate = null; // 数据被整体替换时重置缓存,强制滑块重绘与绑定更新
saveToLocal();
renderAll();
},
updateField: (path, value) => {
setNestedValue(null, path, value);
saveToLocal();
renderAll();
}
};
// 绑定右侧 A4 画布鼠标就地编辑与悬浮发光框逻辑
initVisualEditor();
// 监听窗口尺寸变化,实时执行自适应缩放以消灭水平滚动条
window.addEventListener("resize", adjustPreviewScale);
adjustPreviewScale();
// 唤起并初始化白天/暗黑主题切换系统
initThemeToggle();
// 初始化就地文字加粗的悬浮工具气泡
initBoldPopover();
// 初始化侧边栏拖拽调宽功能
initSidebarResize();
// A. 侧边栏所有 textarea 的全局打字自适应拉伸监听 (事件代理)
const sidebarContainer = document.querySelector('.editor-sidebar');
if (sidebarContainer) {
sidebarContainer.addEventListener('input', (e) => {
if (e.target.tagName === 'TEXTAREA') {
autoResizeTextarea(e.target);
}
});
}
// 初始化纯前端免下载 AI Copilot 智能助手
initAICopilot();
// 初始化导出 PDF 设置指引弹窗
initPDFGuideDialog();
}
// 深度路径值提取辅助 (如: getNestedValue(state, "info.name"))
function getNestedValue(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
}
// 深度路径值写入辅助 (如: setNestedValue(null, "info.name", "李四"))
function setNestedValue(domEl, path, val) {
const parts = path.split('.');
if (parts.some(part => ['__proto__', 'prototype', 'constructor'].includes(part))) throw new Error('无效的字段路径');
let current = state;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) current[parts[i]] = {};
current = current[parts[i]];
}
current[parts[parts.length - 1]] = val;
if (domEl) {
domEl.value = val || '';
}
}
// 缓存到浏览器本地 (物理隔离双方案设计)
let saveTimer;
function saveToLocal() {
clearTimeout(saveTimer);
const status = document.getElementById('save-status');
if (status) status.textContent = EasyCVShare.active ? '链接简历 · 本次页面暂存' : '正在保存…';
saveTimer = setTimeout(flushLocalSave, 250);
}
function flushLocalSave() {
clearTimeout(saveTimer);
saveTimer = null;
try {
// 1. 提取并持久化共享内容数据
const content = {
info: state.info,
skills: state.skills,
work: state.work,
projects: state.projects,
education: state.education,
custom: state.custom
};
resumeStorage.setItem("easycv_resume_content", JSON.stringify(content));
// 兼容历史测试用例,同步写入全局大缓存 easycv_resume_state
// 2. 持久化当前方案的排版设置
const settingsKey = `easycv_resume_settings_${currentScheme.toLowerCase()}`;
resumeStorage.setItem(settingsKey, JSON.stringify(state.settings));
if (typeof EasyCVShare !== 'undefined' && typeof EasyCVShare.syncAddressBarURL === 'function') {
EasyCVShare.syncAddressBarURL(state);
}
const status = document.getElementById('save-status');
if (status) status.textContent = EasyCVShare.active ? '链接简历 · 本次页面暂存' : '已保存到本机';
} catch (error) {
const status = document.getElementById('save-status');
if (status) status.textContent = '保存失败,请导出备份';
}
}
window.addEventListener('pagehide', () => { if (saveTimer) flushLocalSave(); });
document.addEventListener('visibilitychange', () => { if (document.hidden && saveTimer) flushLocalSave(); });
// ====================================================
// 4. 排版样式控制器与 CSS 变量实时绑定
// ====================================================
function applyCSSVariables() {
const sheet = document.getElementById("resume-page");
const s = state.settings;
updatePrintSettings();
sheet.style.setProperty("--accent-color", s.accentColor);
sheet.style.setProperty('--name-size', `${s.nameSize}px`);
sheet.style.setProperty('--letter-spacing', `${s.letterSpacing}px`);
sheet.style.setProperty('--column-width', `${s.columnWidth}%`);
sheet.style.setProperty('--column-gap', `${s.columnGap}px`);
const cjkFamily = {system:'"PingFang SC", "Microsoft YaHei", sans-serif',noto:'"Noto Sans SC", sans-serif',harmony:'"HarmonyOS Sans SC Offline", sans-serif'}[s.cjkFont];
sheet.style.setProperty('--cjk-family', cjkFamily);
document.getElementById('font-sample').style.setProperty('--cjk-family', cjkFamily);
sheet.dataset.headerAlign = s.headerAlign;
sheet.dataset.headingStyle = s.headingStyle;
sheet.dataset.contactIcons = s.contactIcons;
sheet.style.setProperty("--base-font-size", `${s.fontSize}px`);
sheet.style.setProperty("--line-height", s.lineHeight);
sheet.style.setProperty("--page-padding-y", `${s.paddingY}mm`);
sheet.style.setProperty("--page-padding-x", `${s.paddingX}mm`);
sheet.style.setProperty("--section-margin", `${s.secSpacing}px`);
sheet.style.setProperty("--item-margin", `${s.itemSpacing}px`);
sheet.style.setProperty("--title-size", `${s.titleSize}em`);
sheet.style.setProperty("--title-weight", s.titleWeight);
sheet.style.setProperty("--list-indent", `${s.listIndent ?? 15}px`); // 全局列表细节缩进
// 推送优雅侧边左右分栏的精细度独立局部 CSS 变量
if (s.template === "tpl-split") {
sheet.style.setProperty("--sidebar-font-size", `${s.sidebarFontSize ?? 8.5}px`);
sheet.style.setProperty("--main-font-size", `${s.mainFontSize ?? 9.0}px`);
sheet.style.setProperty("--sidebar-sec-spacing", `${s.sidebarSecSpacing ?? 6}px`);
sheet.style.setProperty("--main-sec-spacing", `${s.mainSecSpacing ?? 6}px`);
sheet.style.setProperty("--sidebar-item-spacing", `${s.sidebarItemSpacing ?? 2}px`);
sheet.style.setProperty("--main-item-spacing", `${s.mainItemSpacing ?? 2}px`);
sheet.style.setProperty("--sidebar-title-size", `${s.sidebarTitleSize ?? 1.05}em`);
sheet.style.setProperty("--main-title-size", `${s.mainTitleSize ?? 1.12}em`);
sheet.style.setProperty("--sidebar-list-indent", `${s.sidebarListIndent ?? 10}px`); // 侧边栏列表缩进
sheet.style.setProperty("--main-list-indent", `${s.mainListIndent ?? 15}px`); // 主栏列表缩进
}
}
// ====================================================
// 5. 动态列表项渲染 (工作/项目/教育/自定义板块)
// ====================================================
// 主渲染程序包含:表单编辑器列表同步、A4画布渲染同步、JSON代码同步
function renderAll() {
document.querySelectorAll('.template-option').forEach(button => button.classList.toggle('active', button.dataset.tpl === state.settings.template));
document.querySelectorAll('.font-btn').forEach(button => button.classList.toggle('active', button.dataset.font === state.settings.font));
document.querySelectorAll('.color-dot').forEach(button => button.classList.toggle('active', button.dataset.color === state.settings.accentColor));
document.getElementById('custom-accent-color').value = state.settings.accentColor;
syncDesignUI();
// 1. 同步编辑器输入框初始值(针对info内数据)
const bindableInputs = document.querySelectorAll("[data-bind]");
bindableInputs.forEach(input => {
const path = input.dataset.bind;
setNestedValue(input, path, getNestedValue(state, path));
});
// 2. 将 settings 对应的样式类应用到 A4 画布
const sheet = document.getElementById("resume-page");
sheet.className = `resume-sheet ${state.settings.template} ${state.settings.font}`;
// 3. 应用 CSS 变量样式
applyCSSVariables();
// 3.5 渲染/同步动态滑块结构和数值 (黄金焦点保护)
renderStyleSliders();
// 4. 渲染表单里的动态列表组件
renderSkillEditorList();
renderWorkEditorList();
renderProjectEditorList();
renderEducationEditorList();
renderCustomSectionsEditor();
// 5. 渲染 A4 简历 HTML
renderResumeHTML();
// 6. 同步更新 JSON 源码面板代码
updateJSONCodearea();
// 7. 每次重渲染后刷新缩放比例以适配排版变化
adjustPreviewScale();
// 8. 全量渲染后,自动校准已展开手风琴项与自定义列表里的文本域高度 (延时以保证DOM就绪)
setTimeout(() => {
document.querySelectorAll(".accordion-item.expanded textarea").forEach(autoResizeTextarea);
document.querySelectorAll(".custom-section-editor-box textarea").forEach(autoResizeTextarea);
}, 50);
}
// --- 5.1 技能标签组列表同步 ---
function renderSkillEditorList() {
const state = getHTMLState(false);
const container = document.getElementById("skills-list-container");
container.innerHTML = "";
state.skills.forEach((group, index) => {
const itemDiv = document.createElement("div");
itemDiv.className = "dynamic-list-item";
itemDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label">技能分类 #${index + 1}</span>
<div class="item-btns">
<button class="icon-btn" onclick="moveSkill(${index}, -1)" title="上移"><i class="fa-solid fa-arrow-up"></i></button>
<button class="icon-btn" onclick="moveSkill(${index}, 1)" title="下移"><i class="fa-solid fa-arrow-down"></i></button>
<button class="icon-btn delete-icon" onclick="deleteSkill(${index})" title="删除"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="form-group">
<label>技能分类名称 (如:前端技术、后端框架)</label>
<input type="text" value="${group.category || ''}" oninput="updateSkillField(${index}, 'category', this.value)" placeholder="分类名称">
</div>
<div class="form-group">
<label>具体技能标签 (英文逗号分隔)</label>
<input type="text" value="${group.tags || ''}" oninput="updateSkillField(${index}, 'tags', this.value)" placeholder="React, Vue, TypeScript...">
</div>
`;
container.appendChild(itemDiv);
});
}
function addSkillGroup() {
state.skills.push({ category: "新建技能分类", tags: "核心技能, 标签" });
saveToLocal();
renderAll();
}
function updateSkillField(index, field, val) {
state.skills[index][field] = val;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function deleteSkill(index) {
state.skills.splice(index, 1);
saveToLocal();
renderAll();
}
function moveSkill(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= state.skills.length) return;
const temp = state.skills[index];
state.skills[index] = state.skills[targetIndex];
state.skills[targetIndex] = temp;
saveToLocal();
renderAll();
}
// --- 5.2 工作经历列表同步 ---
function renderWorkEditorList() {
const state = getHTMLState(false);
const container = document.getElementById("work-list-container");
container.innerHTML = "";
state.work.forEach((item, index) => {
const itemDiv = document.createElement("div");
itemDiv.className = "dynamic-list-item";
itemDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label">工作经历 #${index + 1}</span>
<div class="item-btns">
<button class="icon-btn" onclick="moveWork(${index}, -1)" title="上移"><i class="fa-solid fa-arrow-up"></i></button>
<button class="icon-btn" onclick="moveWork(${index}, 1)" title="下移"><i class="fa-solid fa-arrow-down"></i></button>
<button class="icon-btn delete-icon" onclick="deleteWork(${index})" title="删除"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>公司名称</label>
<input type="text" value="${item.company || ''}" oninput="updateWorkField(${index}, 'company', this.value)" placeholder="企业名称">
</div>
<div class="form-group">
<label>担任岗位</label>
<input type="text" value="${item.role || ''}" oninput="updateWorkField(${index}, 'role', this.value)" placeholder="岗位名称">
</div>
</div>
<div class="form-group">
<label>起止时间</label>
<input type="text" value="${item.time || ''}" oninput="updateWorkField(${index}, 'time', this.value)" placeholder="2020.05 - 2023.08">
</div>
<div class="form-group">
<label>核心工作内容及产出 (每行一条描述,推荐STAR原则描述)</label>
<textarea rows="6" oninput="updateWorkDetails(${index}, this.value)" placeholder="【项目挑战】...\n【技术攻坚】...\n【量化成果】...">${item.details ? item.details.join('\n') : ''}</textarea>
</div>
`;
container.appendChild(itemDiv);
});
}
function addWorkExperience() {
state.work.push({
company: "某知名科技公司",
role: "核心开发工程师",
time: "2020.01 - 2022.01",
details: [
"【工作内容】负责核心业务线研发,实现高并发场景稳定性提升。",
"【技术手段】使用 Go 并发模型改造消费队列,削峰处理高吞吐流量。",
"【量化成果】业务系统吞吐量提升 30%,接口延迟降低 20%。"
]
});
saveToLocal();
renderAll();
}
function updateWorkField(index, field, val) {
state.work[index][field] = val;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function updateWorkDetails(index, rawText) {
state.work[index].details = rawText.split('\n').filter(line => line.trim() !== '');
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function deleteWork(index) {
state.work.splice(index, 1);
saveToLocal();
renderAll();
}
function moveWork(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= state.work.length) return;
const temp = state.work[index];
state.work[index] = state.work[targetIndex];
state.work[targetIndex] = temp;
saveToLocal();
renderAll();
}
// --- 5.3 项目经历列表同步 ---
function renderProjectEditorList() {
const state = getHTMLState(false);
const container = document.getElementById("project-list-container");
container.innerHTML = "";
state.projects.forEach((item, index) => {
const itemDiv = document.createElement("div");
itemDiv.className = "dynamic-list-item";
itemDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label">项目经历 #${index + 1}</span>
<div class="item-btns">
<button class="icon-btn" onclick="moveProject(${index}, -1)" title="上移"><i class="fa-solid fa-arrow-up"></i></button>
<button class="icon-btn" onclick="moveProject(${index}, 1)" title="下移"><i class="fa-solid fa-arrow-down"></i></button>
<button class="icon-btn delete-icon" onclick="deleteProject(${index})" title="删除"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>项目名称</label>
<input type="text" value="${item.name || ''}" oninput="updateProjectField(${index}, 'name', this.value)" placeholder="项目名称">
</div>
<div class="form-group">
<label>项目起止时间</label>
<input type="text" value="${item.time || ''}" oninput="updateProjectField(${index}, 'time', this.value)" placeholder="2022.03 - 2022.09">
</div>
</div>
<div class="form-group">
<label>使用${state.settings.techLabel || "技术栈"} (英文逗号分隔)</label>
<input type="text" value="${item.tech || ''}" oninput="updateProjectField(${index}, 'tech', this.value)" placeholder="React, Go, Redis...">
</div>
<div class="form-group">
<label>项目技术难点与个人贡献 (每行一条描述,推荐使用STAR核心量化)</label>
<textarea rows="6" oninput="updateProjectDetails(${index}, this.value)" placeholder="【项目挑战】...\n【技术攻坚】...\n【量化成果】...">${item.details ? item.details.join('\n') : ''}</textarea>
</div>
`;
container.appendChild(itemDiv);
});
}
function addProjectExperience() {
state.projects.push({
name: "自主研发核心项目",
tech: "React, Webpack, Node.js",
time: "2023.01 - 2023.06",
details: [
"【项目挑战】原有系统在渲染超大型树形节点时,容易遭遇卡顿 and 内存超限。",
"【技术攻坚】采用 Canvas 重构渲染核心层,实现离屏渲染与滚动虚拟化加载。",
"【量化成果】页面响应帧率稳定运行在 60fps,复杂节点树渲染时间压缩了 80%。"
]
});
saveToLocal();
renderAll();
}
function updateProjectField(index, field, val) {
state.projects[index][field] = val;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function updateProjectDetails(index, rawText) {
state.projects[index].details = rawText.split('\n').filter(line => line.trim() !== '');
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function deleteProject(index) {
state.projects.splice(index, 1);
saveToLocal();
renderAll();
}
function moveProject(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= state.projects.length) return;
const temp = state.projects[index];
state.projects[index] = state.projects[targetIndex];
state.projects[targetIndex] = temp;
saveToLocal();
renderAll();
}
// --- 5.4 教育背景列表同步 ---
function renderEducationEditorList() {
const state = getHTMLState(false);
const container = document.getElementById("education-list-container");
container.innerHTML = "";
state.education.forEach((item, index) => {
const itemDiv = document.createElement("div");
itemDiv.className = "dynamic-list-item";
itemDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label">教育背景 #${index + 1}</span>
<div class="item-btns">
<button class="icon-btn" onclick="moveEducation(${index}, -1)" title="上移"><i class="fa-solid fa-arrow-up"></i></button>
<button class="icon-btn" onclick="moveEducation(${index}, 1)" title="下移"><i class="fa-solid fa-arrow-down"></i></button>
<button class="icon-btn delete-icon" onclick="deleteEducation(${index})" title="删除"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>学校名称</label>
<input type="text" value="${item.school || ''}" oninput="updateEduField(${index}, 'school', this.value)" placeholder="学校名称">
</div>
<div class="form-group">
<label>所获学位</label>
<input type="text" value="${item.degree || ''}" oninput="updateEduField(${index}, 'degree', this.value)" placeholder="学士 / 硕士 / 博士">
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>主修专业</label>
<input type="text" value="${item.major || ''}" oninput="updateEduField(${index}, 'major', this.value)" placeholder="专业名称">
</div>
<div class="form-group">
<label>就读时间</label>
<input type="text" value="${item.time || ''}" oninput="updateEduField(${index}, 'time', this.value)" placeholder="2015.09 - 2019.07">
</div>
</div>
<div class="form-group">
<label>校内表现与相关课程 / 荣誉成果 (每行一条)</label>
<textarea rows="3" oninput="updateEduDetails(${index}, this.value)" placeholder="绩点排名 Top 5%,多次荣获一等奖学金...">${item.details ? item.details.join('\n') : ''}</textarea>
</div>
`;
container.appendChild(itemDiv);
});
}
function addEducation() {
state.education.push({
school: "知名高等学府",
degree: "工学学士",
major: "软件工程",
time: "2016.09 - 2020.07",
details: [
"主要课程:计算机组成原理、数据结构与算法、操作系统、软件工程。",
"在校期间表现优异,曾获校级优秀毕业生荣誉。"
]
});
saveToLocal();
renderAll();
}
function updateEduField(index, field, val) {
state.education[index][field] = val;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function updateEduDetails(index, rawText) {
state.education[index].details = rawText.split('\n').filter(line => line.trim() !== '');
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function deleteEducation(index) {
state.education.splice(index, 1);
saveToLocal();
renderAll();
}
function moveEducation(index, direction) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= state.education.length) return;
const temp = state.education[index];
state.education[index] = state.education[targetIndex];
state.education[targetIndex] = temp;
saveToLocal();
renderAll();
}
// --- 5.5 自定义板块同步与渲染 ---
function renderCustomSectionsEditor() {
const state = getHTMLState(false);
const container = document.getElementById("custom-sections-container");
container.innerHTML = "";
if (!state.custom || state.custom.length === 0) {
container.innerHTML = `<div class="skills-editor-desc">当前无任何自定义板块,可在下方创建。</div>`;
return;
}
state.custom.forEach((section, sIdx) => {
const secDiv = document.createElement("div");
secDiv.className = "custom-section-editor-box";
secDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label"><i class="fa-solid fa-folder-open"></i> 板块:${section.title}</span>
<button class="icon-btn delete-icon" onclick="deleteCustomSection(${sIdx})" title="删除此整个板块"><i class="fa-solid fa-trash-can"></i> 删除板块</button>
</div>
<div class="form-group">
<label>板块标题名称</label>
<input type="text" value="${section.title}" onchange="renameCustomSection(${sIdx}, this.value)">
</div>
<div class="custom-items-list" id="custom-items-${sIdx}">
<!-- 自定义板块内部的项目列表 -->
</div>
<button class="btn btn-secondary btn-sm" onclick="addCustomItem(${sIdx})">
<i class="fa-solid fa-plus-circle"></i> 添加该板块项目项
</button>
`;
container.appendChild(secDiv);
renderCustomItems(sIdx);
});
}
function renderCustomItems(sIdx) {
const state = getHTMLState(false);
const listContainer = document.getElementById(`custom-items-${sIdx}`);
listContainer.innerHTML = "";
const items = state.custom[sIdx].items || [];
items.forEach((item, iIdx) => {
const itemDiv = document.createElement("div");
itemDiv.className = "dynamic-list-item";
itemDiv.innerHTML = `
<div class="item-actions-bar">
<span class="item-index-label">条目 #${iIdx + 1}</span>
<div class="item-btns">
<button class="icon-btn" onclick="moveCustomItem(${sIdx}, ${iIdx}, -1)" title="上移"><i class="fa-solid fa-arrow-up"></i></button>
<button class="icon-btn" onclick="moveCustomItem(${sIdx}, ${iIdx}, 1)" title="下移"><i class="fa-solid fa-arrow-down"></i></button>
<button class="icon-btn delete-icon" onclick="deleteCustomItem(${sIdx}, ${iIdx})" title="删除项"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>主标题</label>
<input type="text" value="${item.title || ''}" oninput="updateCustomField(${sIdx}, ${iIdx}, 'title', this.value)">
</div>
<div class="form-group">
<label>副标题 (选填)</label>
<input type="text" value="${item.subtitle || ''}" oninput="updateCustomField(${sIdx}, ${iIdx}, 'subtitle', this.value)">
</div>
</div>
<div class="form-group-row">
<div class="form-group">
<label>时间轴描述 (选填)</label>
<input type="text" value="${item.time || ''}" oninput="updateCustomField(${sIdx}, ${iIdx}, 'time', this.value)">
</div>
</div>
<div class="form-group">
<label>条目详情内容描述 (每行一条,支持段落)</label>
<textarea rows="4" oninput="updateCustomDetails(${sIdx}, ${iIdx}, this.value)">${item.details ? item.details.join('\n') : ''}</textarea>
</div>
`;
listContainer.appendChild(itemDiv);
});
}
function createCustomSection() {
const input = document.getElementById("new-section-title");
const title = input.value.trim();
if (!title) {
showNotification("请输入自定义板块的标题名称!");
return;
}
if (!state.custom) state.custom = [];
state.custom.push({
title: title,
items: [
{
title: "新建项标题",
subtitle: "子标题",
time: "2024",
details: ["此处写具体描述细则一", "描述细则二"]
}
]
});
input.value = "";
saveToLocal();
renderAll();
}
function renameCustomSection(sIdx, newTitle) {
if (!newTitle.trim()) return;
state.custom[sIdx].title = newTitle;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
async function deleteCustomSection(sIdx) {
if (await confirmAction("删除板块", `删除“${state.custom[sIdx].title}”及其中所有条目?此操作无法撤销。`)) {
state.custom.splice(sIdx, 1);
saveToLocal();
renderAll();
}
}
function addCustomItem(sIdx) {
state.custom[sIdx].items.push({
title: "新条目",
subtitle: "",
time: "",
details: ["描述详情一"]
});
saveToLocal();
renderAll();
}
function deleteCustomItem(sIdx, iIdx) {
state.custom[sIdx].items.splice(iIdx, 1);
saveToLocal();
renderAll();
}
function updateCustomField(sIdx, iIdx, field, val) {
state.custom[sIdx].items[iIdx][field] = val;
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function updateCustomDetails(sIdx, iIdx, rawText) {
state.custom[sIdx].items[iIdx].details = rawText.split('\n').filter(line => line.trim() !== '');
saveToLocal();
renderResumeHTML();
updateJSONCodearea();
}
function moveCustomItem(sIdx, iIdx, direction) {
const items = state.custom[sIdx].items;
const targetIdx = iIdx + direction;
if (targetIdx < 0 || targetIdx >= items.length) return;
const temp = items[iIdx];
items[iIdx] = items[targetIdx];
items[targetIdx] = temp;
saveToLocal();
renderAll();
}
// ====================================================
// 6. A4 纸张页面 HTML 动态生成渲染引擎
// ====================================================
function renderResumeHTML() {
const state = getHTMLState(true);
const target = document.getElementById("resume-render-target");
const tpl = state.settings.template;
// 生成页头个人基本信息模块 of HTML
const contacts = [
state.info.email.trim() ? `<span><i class="fa-regular fa-envelope"></i> <span contenteditable="true" data-edit-path="info.email">${state.info.email}</span></span>` : '',
state.info.phone.trim() ? `<span><i class="fa-solid fa-phone"></i> <span contenteditable="true" data-edit-path="info.phone">${state.info.phone}</span></span>` : '',
(state.info.wechat && state.info.wechat.trim()) ? `<span><i class="fa-brands fa-weixin"></i> <span contenteditable="true" data-edit-path="info.wechat">${state.info.wechat}</span></span>` : '',
state.info.location.trim() ? `<span><i class="fa-solid fa-location-dot"></i> <span contenteditable="true" data-edit-path="info.location">${state.info.location}</span></span>` : '',
state.info.github.trim() ? `<span><i class="fa-brands fa-github"></i> <a href="${safeResumeURL(state.info.github)}" target="_blank" rel="noopener noreferrer"><span contenteditable="true" data-edit-path="info.github">${state.info.github}</span></a></span>` : '',
state.info.blog.trim() ? `<span><i class="fa-solid fa-globe"></i> <a href="${safeResumeURL(state.info.blog)}" target="_blank" rel="noopener noreferrer"><span contenteditable="true" data-edit-path="info.blog">${state.info.blog}</span></a></span>` : ''
].filter(Boolean).join('');
const headerHTML = `
<header class="resume-header selectable-section" data-section-name="基本信息页头">
<div class="header-main">
<h1 class="header-name" contenteditable="true" data-edit-path="info.name">${state.info.name || "姓名"}</h1>
<div class="header-title" contenteditable="true" data-edit-path="info.title">${state.info.title || ""}</div>
</div>
${contacts ? `<div class="header-contact-list">${contacts}</div>` : ''}
</header>
`;
// 个人简介 HTML
const summaryHTML = state.info.summary ? `
<section class="resume-section" id="resume-sec-summary">
<h2 class="sec-title">${state.settings.summaryTitle}</h2>
<div class="summary-text selectable-section" data-section-name="个人简介" contenteditable="true" data-edit-path="info.summary">${state.info.summary}</div>