cold-msg 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cold_msg/__init__.py +7 -0
- cold_msg/cli.py +282 -0
- cold_msg/core/__init__.py +8 -0
- cold_msg/core/config.py +215 -0
- cold_msg/core/generator.py +129 -0
- cold_msg/core/llm.py +78 -0
- cold_msg/core/prompt.py +128 -0
- cold_msg/web/__init__.py +1 -0
- cold_msg/web/app.py +279 -0
- cold_msg/web/templates/config.html +357 -0
- cold_msg/web/templates/index.html +276 -0
- cold_msg/web/templates/jobs.html +518 -0
- cold_msg-0.1.0.dist-info/METADATA +244 -0
- cold_msg-0.1.0.dist-info/RECORD +17 -0
- cold_msg-0.1.0.dist-info/WHEEL +5 -0
- cold_msg-0.1.0.dist-info/entry_points.txt +2 -0
- cold_msg-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ColdMsg - 职位列表管理</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.fade-in { animation: fadeIn 0.2s ease-in; }
|
|
10
|
+
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
11
|
+
.modal-overlay { background: rgba(0,0,0,0.4); }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body class="bg-gray-50 min-h-screen">
|
|
15
|
+
<!-- 导航栏 -->
|
|
16
|
+
<nav class="bg-white border-b border-gray-200 shadow-sm">
|
|
17
|
+
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
18
|
+
<div class="flex items-center space-x-3">
|
|
19
|
+
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
|
20
|
+
<span class="text-white font-bold text-sm">CM</span>
|
|
21
|
+
</div>
|
|
22
|
+
<span class="text-xl font-semibold text-gray-800">ColdMsg</span>
|
|
23
|
+
<span class="text-xs text-gray-400 ml-1">招聘冷邮件生成</span>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex space-x-4">
|
|
26
|
+
<a href="/" class="text-gray-500 hover:text-gray-700 text-sm">生成邮件</a>
|
|
27
|
+
<a href="/jobs" class="text-blue-600 font-medium text-sm">职位管理</a>
|
|
28
|
+
<a href="/config" class="text-gray-500 hover:text-gray-700 text-sm">邮件模板配置</a>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</nav>
|
|
32
|
+
|
|
33
|
+
<div class="max-w-5xl mx-auto px-4 py-6">
|
|
34
|
+
<!-- 顶部操作栏 -->
|
|
35
|
+
<div class="flex items-center justify-between mb-5">
|
|
36
|
+
<div>
|
|
37
|
+
<h1 class="text-lg font-semibold text-gray-800">职位列表管理</h1>
|
|
38
|
+
<p class="text-xs text-gray-400 mt-1">管理用于匹配候选人的职位信息,支持新增、编辑、删除和 YAML 导入</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="flex items-center space-x-3">
|
|
41
|
+
<button id="importYamlBtn"
|
|
42
|
+
class="bg-white hover:bg-gray-50 text-gray-600 border border-gray-300 text-xs font-medium py-2 px-3 rounded-lg transition-colors">
|
|
43
|
+
导入 YAML
|
|
44
|
+
</button>
|
|
45
|
+
<button id="exportYamlBtn"
|
|
46
|
+
class="bg-white hover:bg-gray-50 text-gray-600 border border-gray-300 text-xs font-medium py-2 px-3 rounded-lg transition-colors">
|
|
47
|
+
导出 YAML
|
|
48
|
+
</button>
|
|
49
|
+
<button id="addJobBtn"
|
|
50
|
+
class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium py-2 px-4 rounded-lg transition-colors">
|
|
51
|
+
+ 新增职位
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- 统计栏 -->
|
|
57
|
+
<div class="grid grid-cols-3 gap-4 mb-5">
|
|
58
|
+
<div class="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
|
59
|
+
<div id="totalCount" class="text-2xl font-bold text-gray-800">0</div>
|
|
60
|
+
<div class="text-xs text-gray-400 mt-1">总职位数</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
|
63
|
+
<div id="validCount" class="text-2xl font-bold text-green-600">0</div>
|
|
64
|
+
<div class="text-xs text-gray-400 mt-1">有效职位</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
|
67
|
+
<div id="emptyCount" class="text-2xl font-bold text-gray-300">0</div>
|
|
68
|
+
<div class="text-xs text-gray-400 mt-1">空职位(不计入)</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- 职位列表 -->
|
|
73
|
+
<div id="jobsContainer" class="space-y-3">
|
|
74
|
+
<!-- 动态渲染 -->
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<!-- 空状态 -->
|
|
78
|
+
<div id="emptyState" class="bg-white rounded-lg shadow-sm border border-gray-200 p-5 hidden">
|
|
79
|
+
<div class="text-center py-12">
|
|
80
|
+
<div class="text-5xl mb-4 text-gray-300">💼</div>
|
|
81
|
+
<p class="text-gray-400 text-sm">暂无职位</p>
|
|
82
|
+
<p class="text-gray-400 text-sm">点击「新增职位」或「导入 YAML」开始</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<!-- 编辑/新增弹窗 -->
|
|
88
|
+
<div id="editModal" class="fixed inset-0 z-50 hidden">
|
|
89
|
+
<div class="modal-overlay absolute inset-0" id="modalOverlay"></div>
|
|
90
|
+
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
91
|
+
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 relative fade-in">
|
|
92
|
+
<h3 id="modalTitle" class="text-base font-semibold text-gray-800 mb-4">新增职位</h3>
|
|
93
|
+
<div class="space-y-4">
|
|
94
|
+
<div>
|
|
95
|
+
<label class="block text-sm font-medium text-gray-600 mb-1">职位名称 <span class="text-red-400">*</span></label>
|
|
96
|
+
<input id="editTitle" type="text"
|
|
97
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
98
|
+
placeholder="例如:大模型Agent领域技术VP">
|
|
99
|
+
</div>
|
|
100
|
+
<div>
|
|
101
|
+
<label class="block text-sm font-medium text-gray-600 mb-1">任职要求 <span class="text-red-400">*</span></label>
|
|
102
|
+
<textarea id="editQual" rows="4"
|
|
103
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-y"
|
|
104
|
+
placeholder="例如:Agent,Agentic技术,要求学历博士以上,5年以上工作经验"></textarea>
|
|
105
|
+
</div>
|
|
106
|
+
<div>
|
|
107
|
+
<label class="block text-sm font-medium text-gray-600 mb-1">工作地点 <span class="text-red-400">*</span></label>
|
|
108
|
+
<input id="editLoc" type="text"
|
|
109
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
110
|
+
placeholder="例如:北上深,香港等">
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="flex justify-end space-x-3 mt-6">
|
|
114
|
+
<button id="modalCancelBtn"
|
|
115
|
+
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg transition-colors">
|
|
116
|
+
取消
|
|
117
|
+
</button>
|
|
118
|
+
<button id="modalSaveBtn"
|
|
119
|
+
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
|
120
|
+
保存
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- YAML 导入弹窗 -->
|
|
128
|
+
<div id="yamlModal" class="fixed inset-0 z-50 hidden">
|
|
129
|
+
<div class="modal-overlay absolute inset-0" id="yamlModalOverlay"></div>
|
|
130
|
+
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
131
|
+
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 relative fade-in">
|
|
132
|
+
<h3 class="text-base font-semibold text-gray-800 mb-2">导入 YAML 职位数据</h3>
|
|
133
|
+
<p class="text-xs text-gray-400 mb-4">粘贴 YAML 格式的职位列表,空职位(职位名称为空)将自动过滤</p>
|
|
134
|
+
<textarea id="yamlInput" rows="12"
|
|
135
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-y"
|
|
136
|
+
placeholder="- job_title: 大模型Agent领域技术VP
|
|
137
|
+
qualification: Agent,Agentic技术,博士,5年+经验
|
|
138
|
+
location: 北上深,香港等
|
|
139
|
+
- job_title: 编译器实验室技术负责人
|
|
140
|
+
qualification: 编译器技术精通,MLIR,LLVM,GCC,AI编译等
|
|
141
|
+
location: 北上深杭,香港等"></textarea>
|
|
142
|
+
<div class="flex items-center justify-between mt-4">
|
|
143
|
+
<span id="yamlParseHint" class="text-xs text-gray-400"></span>
|
|
144
|
+
<div class="flex space-x-3">
|
|
145
|
+
<button id="yamlCancelBtn"
|
|
146
|
+
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg transition-colors">
|
|
147
|
+
取消
|
|
148
|
+
</button>
|
|
149
|
+
<button id="yamlImportBtn"
|
|
150
|
+
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
|
151
|
+
导入
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<!-- 删除确认弹窗 -->
|
|
160
|
+
<div id="deleteModal" class="fixed inset-0 z-50 hidden">
|
|
161
|
+
<div class="modal-overlay absolute inset-0" id="deleteModalOverlay"></div>
|
|
162
|
+
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
163
|
+
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 relative fade-in">
|
|
164
|
+
<h3 class="text-base font-semibold text-gray-800 mb-2">确认删除</h3>
|
|
165
|
+
<p id="deleteMsg" class="text-sm text-gray-500 mb-5">确定要删除该职位吗?此操作不可撤销。</p>
|
|
166
|
+
<div class="flex justify-end space-x-3">
|
|
167
|
+
<button id="deleteCancelBtn"
|
|
168
|
+
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg transition-colors">
|
|
169
|
+
取消
|
|
170
|
+
</button>
|
|
171
|
+
<button id="deleteConfirmBtn"
|
|
172
|
+
class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
|
|
173
|
+
删除
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- Toast -->
|
|
181
|
+
<div id="toast" class="fixed top-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm hidden transition-all z-50"></div>
|
|
182
|
+
|
|
183
|
+
<script>
|
|
184
|
+
// ========== 状态 ==========
|
|
185
|
+
let jobs = [];
|
|
186
|
+
let editingIndex = -1; // -1 = 新增模式
|
|
187
|
+
let deletingIndex = -1;
|
|
188
|
+
|
|
189
|
+
// ========== DOM ==========
|
|
190
|
+
const jobsContainer = document.getElementById('jobsContainer');
|
|
191
|
+
const emptyState = document.getElementById('emptyState');
|
|
192
|
+
const totalCount = document.getElementById('totalCount');
|
|
193
|
+
const validCount = document.getElementById('validCount');
|
|
194
|
+
const emptyCount = document.getElementById('emptyCount');
|
|
195
|
+
|
|
196
|
+
const editModal = document.getElementById('editModal');
|
|
197
|
+
const modalTitle = document.getElementById('modalTitle');
|
|
198
|
+
const editTitle = document.getElementById('editTitle');
|
|
199
|
+
const editQual = document.getElementById('editQual');
|
|
200
|
+
const editLoc = document.getElementById('editLoc');
|
|
201
|
+
|
|
202
|
+
const yamlModal = document.getElementById('yamlModal');
|
|
203
|
+
const yamlInput = document.getElementById('yamlInput');
|
|
204
|
+
const yamlParseHint = document.getElementById('yamlParseHint');
|
|
205
|
+
|
|
206
|
+
const deleteModal = document.getElementById('deleteModal');
|
|
207
|
+
const deleteMsg = document.getElementById('deleteMsg');
|
|
208
|
+
|
|
209
|
+
const toast = document.getElementById('toast');
|
|
210
|
+
|
|
211
|
+
// ========== 工具函数 ==========
|
|
212
|
+
function isValidJob(job) {
|
|
213
|
+
return job.job_title && job.job_title.trim() !== '';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function showToast(msg, isError = false) {
|
|
217
|
+
toast.textContent = msg;
|
|
218
|
+
toast.className = `fixed top-4 right-4 ${isError ? 'bg-red-600' : 'bg-green-600'} text-white px-4 py-2 rounded-lg shadow-lg text-sm transition-all z-50`;
|
|
219
|
+
setTimeout(() => { toast.classList.add('hidden'); }, 2000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function updateStats() {
|
|
223
|
+
const valid = jobs.filter(isValidJob);
|
|
224
|
+
const empty = jobs.filter(j => !isValidJob(j));
|
|
225
|
+
totalCount.textContent = jobs.length;
|
|
226
|
+
validCount.textContent = valid.length;
|
|
227
|
+
emptyCount.textContent = empty.length;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ========== 渲染职位列表 ==========
|
|
231
|
+
function renderJobs() {
|
|
232
|
+
updateStats();
|
|
233
|
+
|
|
234
|
+
if (jobs.length === 0) {
|
|
235
|
+
jobsContainer.innerHTML = '';
|
|
236
|
+
emptyState.classList.remove('hidden');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
emptyState.classList.add('hidden');
|
|
241
|
+
jobsContainer.innerHTML = jobs.map((job, i) => {
|
|
242
|
+
const valid = isValidJob(job);
|
|
243
|
+
const borderClass = valid ? 'border-gray-200' : 'border-dashed border-gray-300 bg-gray-50';
|
|
244
|
+
const opacityClass = valid ? '' : 'opacity-50';
|
|
245
|
+
const badge = valid
|
|
246
|
+
? ''
|
|
247
|
+
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-500">空职位</span>';
|
|
248
|
+
|
|
249
|
+
return `
|
|
250
|
+
<div class="bg-white rounded-lg shadow-sm border ${borderClass} p-4 fade-in ${opacityClass}">
|
|
251
|
+
<div class="flex items-start justify-between">
|
|
252
|
+
<div class="flex-1 min-w-0">
|
|
253
|
+
<div class="flex items-center space-x-2 mb-1">
|
|
254
|
+
<span class="text-xs text-gray-400 font-mono">#${i + 1}</span>
|
|
255
|
+
<h3 class="text-sm font-semibold text-gray-800 truncate">${valid ? job.job_title : '(未填写职位名称)'}</h3>
|
|
256
|
+
${badge}
|
|
257
|
+
</div>
|
|
258
|
+
<p class="text-xs text-gray-500 mt-1 line-clamp-2">${job.qualification || '未填写任职要求'}</p>
|
|
259
|
+
<div class="flex items-center mt-2 space-x-1">
|
|
260
|
+
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
261
|
+
<span class="text-xs text-gray-400">${job.location || '未填写'}</span>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="flex items-center space-x-1 ml-3 flex-shrink-0">
|
|
265
|
+
<button onclick="openEdit(${i})"
|
|
266
|
+
class="text-gray-400 hover:text-blue-600 p-1.5 rounded-lg hover:bg-blue-50 transition-colors" title="编辑">
|
|
267
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
268
|
+
</button>
|
|
269
|
+
<button onclick="openDelete(${i})"
|
|
270
|
+
class="text-gray-400 hover:text-red-600 p-1.5 rounded-lg hover:bg-red-50 transition-colors" title="删除">
|
|
271
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>`;
|
|
276
|
+
}).join('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ========== 加载数据 ==========
|
|
280
|
+
async function loadJobs() {
|
|
281
|
+
try {
|
|
282
|
+
const resp = await fetch('/api/jobs');
|
|
283
|
+
const data = await resp.json();
|
|
284
|
+
jobs = data.jobs || [];
|
|
285
|
+
renderJobs();
|
|
286
|
+
} catch (err) {
|
|
287
|
+
showToast('加载职位失败: ' + err.message, true);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ========== 保存数据 ==========
|
|
292
|
+
async function saveJobs() {
|
|
293
|
+
// 过滤掉空职位再保存
|
|
294
|
+
const validJobs = jobs.filter(isValidJob);
|
|
295
|
+
try {
|
|
296
|
+
const resp = await fetch('/api/jobs', {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify({ jobs: validJobs }),
|
|
300
|
+
});
|
|
301
|
+
const data = await resp.json();
|
|
302
|
+
if (resp.ok) {
|
|
303
|
+
jobs = validJobs;
|
|
304
|
+
renderJobs();
|
|
305
|
+
return true;
|
|
306
|
+
} else {
|
|
307
|
+
showToast(data.error || '保存失败', true);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
showToast('保存失败: ' + err.message, true);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ========== 编辑弹窗 ==========
|
|
317
|
+
function openEdit(index) {
|
|
318
|
+
editingIndex = index;
|
|
319
|
+
const job = jobs[index];
|
|
320
|
+
modalTitle.textContent = '编辑职位';
|
|
321
|
+
editTitle.value = job.job_title;
|
|
322
|
+
editQual.value = job.qualification;
|
|
323
|
+
editLoc.value = job.location;
|
|
324
|
+
editModal.classList.remove('hidden');
|
|
325
|
+
editTitle.focus();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function openAdd() {
|
|
329
|
+
editingIndex = -1;
|
|
330
|
+
modalTitle.textContent = '新增职位';
|
|
331
|
+
editTitle.value = '';
|
|
332
|
+
editQual.value = '';
|
|
333
|
+
editLoc.value = '';
|
|
334
|
+
editModal.classList.remove('hidden');
|
|
335
|
+
editTitle.focus();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function closeEditModal() {
|
|
339
|
+
editModal.classList.add('hidden');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
document.getElementById('modalCancelBtn').addEventListener('click', closeEditModal);
|
|
343
|
+
document.getElementById('modalOverlay').addEventListener('click', closeEditModal);
|
|
344
|
+
|
|
345
|
+
document.getElementById('modalSaveBtn').addEventListener('click', async () => {
|
|
346
|
+
const title = editTitle.value.trim();
|
|
347
|
+
const qual = editQual.value.trim();
|
|
348
|
+
const loc = editLoc.value.trim();
|
|
349
|
+
|
|
350
|
+
if (!title) {
|
|
351
|
+
editTitle.classList.add('border-red-400');
|
|
352
|
+
editTitle.focus();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
editTitle.classList.remove('border-red-400');
|
|
356
|
+
|
|
357
|
+
if (editingIndex >= 0) {
|
|
358
|
+
// 编辑
|
|
359
|
+
jobs[editingIndex] = { job_title: title, qualification: qual, location: loc };
|
|
360
|
+
} else {
|
|
361
|
+
// 新增
|
|
362
|
+
jobs.push({ job_title: title, qualification: qual, location: loc });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
closeEditModal();
|
|
366
|
+
const ok = await saveJobs();
|
|
367
|
+
if (ok) showToast(editingIndex >= 0 ? '职位已更新' : '职位已添加');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ========== 删除弹窗 ==========
|
|
371
|
+
function openDelete(index) {
|
|
372
|
+
deletingIndex = index;
|
|
373
|
+
const job = jobs[index];
|
|
374
|
+
deleteMsg.textContent = `确定要删除「${job.job_title || '空职位'}」吗?此操作不可撤销。`;
|
|
375
|
+
deleteModal.classList.remove('hidden');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function closeDeleteModal() {
|
|
379
|
+
deleteModal.classList.add('hidden');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
document.getElementById('deleteCancelBtn').addEventListener('click', closeDeleteModal);
|
|
383
|
+
document.getElementById('deleteModalOverlay').addEventListener('click', closeDeleteModal);
|
|
384
|
+
|
|
385
|
+
document.getElementById('deleteConfirmBtn').addEventListener('click', async () => {
|
|
386
|
+
if (deletingIndex >= 0) {
|
|
387
|
+
jobs.splice(deletingIndex, 1);
|
|
388
|
+
}
|
|
389
|
+
closeDeleteModal();
|
|
390
|
+
const ok = await saveJobs();
|
|
391
|
+
if (ok) showToast('职位已删除');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ========== YAML 导入 ==========
|
|
395
|
+
document.getElementById('importYamlBtn').addEventListener('click', () => {
|
|
396
|
+
yamlInput.value = '';
|
|
397
|
+
yamlParseHint.textContent = '';
|
|
398
|
+
yamlModal.classList.remove('hidden');
|
|
399
|
+
yamlInput.focus();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
document.getElementById('yamlCancelBtn').addEventListener('click', () => {
|
|
403
|
+
yamlModal.classList.add('hidden');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
document.getElementById('yamlModalOverlay').addEventListener('click', () => {
|
|
407
|
+
yamlModal.classList.add('hidden');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// 实时解析预览
|
|
411
|
+
yamlInput.addEventListener('input', () => {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = parseYaml(yamlInput.value);
|
|
414
|
+
const valid = parsed.filter(isValidJob);
|
|
415
|
+
const empty = parsed.filter(j => !isValidJob(j));
|
|
416
|
+
if (parsed.length === 0) {
|
|
417
|
+
yamlParseHint.textContent = '';
|
|
418
|
+
} else {
|
|
419
|
+
yamlParseHint.textContent = `解析到 ${parsed.length} 条,有效 ${valid.length} 条,空 ${empty.length} 条(空职位将自动过滤)`;
|
|
420
|
+
yamlParseHint.className = 'text-xs text-green-600';
|
|
421
|
+
}
|
|
422
|
+
} catch (e) {
|
|
423
|
+
yamlParseHint.textContent = 'YAML 格式错误: ' + e.message;
|
|
424
|
+
yamlParseHint.className = 'text-xs text-red-500';
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// 简易 YAML 解析器(支持 - key: value 格式)
|
|
429
|
+
function parseYaml(text) {
|
|
430
|
+
const lines = text.split('\n');
|
|
431
|
+
const items = [];
|
|
432
|
+
let current = null;
|
|
433
|
+
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
const trimmed = line.trim();
|
|
436
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
437
|
+
|
|
438
|
+
// 新项:以 - 开头
|
|
439
|
+
const listMatch = trimmed.match(/^-\s*(\w+)\s*:\s*(.+)$/);
|
|
440
|
+
if (listMatch) {
|
|
441
|
+
if (current) items.push(current);
|
|
442
|
+
current = {};
|
|
443
|
+
const key = listMatch[1];
|
|
444
|
+
const val = listMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
445
|
+
// 兼容 JobTitle / job_title 两种键名
|
|
446
|
+
if (key.toLowerCase() === 'jobtitle' || key === 'job_title') current.job_title = val;
|
|
447
|
+
else if (key.toLowerCase() === 'qualification' || key === 'qualification') current.qualification = val;
|
|
448
|
+
else if (key.toLowerCase() === 'location' || key === 'location') current.location = val;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 续行:key: value
|
|
453
|
+
const kvMatch = trimmed.match(/^(\w+)\s*:\s*(.+)$/);
|
|
454
|
+
if (kvMatch && current) {
|
|
455
|
+
const key = kvMatch[1];
|
|
456
|
+
const val = kvMatch[2].trim().replace(/^["']|["']$/g, '');
|
|
457
|
+
if (key.toLowerCase() === 'jobtitle' || key === 'job_title') current.job_title = val;
|
|
458
|
+
else if (key.toLowerCase() === 'qualification' || key === 'qualification') current.qualification = val;
|
|
459
|
+
else if (key.toLowerCase() === 'location' || key === 'location') current.location = val;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (current) items.push(current);
|
|
463
|
+
return items;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
document.getElementById('yamlImportBtn').addEventListener('click', async () => {
|
|
467
|
+
try {
|
|
468
|
+
const parsed = parseYaml(yamlInput.value);
|
|
469
|
+
const valid = parsed.filter(isValidJob);
|
|
470
|
+
if (valid.length === 0) {
|
|
471
|
+
showToast('未解析到有效职位,请检查 YAML 格式', true);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// 追加到现有列表
|
|
475
|
+
jobs = jobs.concat(valid);
|
|
476
|
+
yamlModal.classList.add('hidden');
|
|
477
|
+
const ok = await saveJobs();
|
|
478
|
+
if (ok) showToast(`已导入 ${valid.length} 个职位`);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
showToast('YAML 解析失败: ' + e.message, true);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ========== YAML 导出 ==========
|
|
485
|
+
document.getElementById('exportYamlBtn').addEventListener('click', () => {
|
|
486
|
+
const valid = jobs.filter(isValidJob);
|
|
487
|
+
if (valid.length === 0) {
|
|
488
|
+
showToast('没有可导出的有效职位', true);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const yaml = valid.map(j => {
|
|
492
|
+
return `- job_title: ${j.job_title}\n qualification: ${j.qualification}\n location: ${j.location}`;
|
|
493
|
+
}).join('\n');
|
|
494
|
+
|
|
495
|
+
// 复制到剪贴板
|
|
496
|
+
navigator.clipboard.writeText(yaml).then(() => {
|
|
497
|
+
showToast(`已复制 ${valid.length} 个职位的 YAML 到剪贴板`);
|
|
498
|
+
}).catch(() => {
|
|
499
|
+
// fallback: 下载文件
|
|
500
|
+
const blob = new Blob([yaml], { type: 'text/yaml' });
|
|
501
|
+
const url = URL.createObjectURL(blob);
|
|
502
|
+
const a = document.createElement('a');
|
|
503
|
+
a.href = url;
|
|
504
|
+
a.download = 'jobs.yaml';
|
|
505
|
+
a.click();
|
|
506
|
+
URL.revokeObjectURL(url);
|
|
507
|
+
showToast(`已下载 ${valid.length} 个职位的 YAML 文件`);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// ========== 新增按钮 ==========
|
|
512
|
+
document.getElementById('addJobBtn').addEventListener('click', openAdd);
|
|
513
|
+
|
|
514
|
+
// ========== 初始化 ==========
|
|
515
|
+
loadJobs();
|
|
516
|
+
</script>
|
|
517
|
+
</body>
|
|
518
|
+
</html>
|