duqing2026 commited on
Commit
b0fefeb
·
1 Parent(s): d9d3891
Files changed (3) hide show
  1. app.py +56 -11
  2. problems/02_fibonacci.js +0 -24
  3. templates/index.html +367 -11
app.py CHANGED
@@ -19,10 +19,21 @@ def parse_problem_metadata(filename):
19
  if title_match:
20
  title = title_match.group(1).strip()
21
 
 
 
 
 
 
 
 
 
22
  return {
23
  'id': filename,
24
  'title': title,
25
- 'content': content
 
 
 
26
  }
27
 
28
  @app.route('/')
@@ -32,8 +43,8 @@ def index():
32
  @app.route('/api/problems', methods=['GET'])
33
  def get_problems():
34
  files = [f for f in os.listdir(PROBLEMS_DIR) if f.endswith('.js') or f.endswith('.ts')]
35
- # 按文件名排序
36
- files.sort()
37
 
38
  problems = []
39
  for f in files:
@@ -48,6 +59,7 @@ def get_problems():
48
  def create_problem():
49
  data = request.json
50
  filename = data.get('filename')
 
51
 
52
  if not filename:
53
  return jsonify({'error': 'Filename is required'}), 400
@@ -63,6 +75,7 @@ def create_problem():
63
  # 默认模板
64
  content = f"""/**
65
  * @title {filename}
 
66
  * @description 在此处添加题目描述
67
  */
68
 
@@ -91,15 +104,47 @@ def update_problem(filename):
91
  if not os.path.exists(filepath):
92
  return jsonify({'error': 'File not found'}), 404
93
 
94
- content = request.json.get('content')
95
- if content is None:
96
- return jsonify({'error': 'Content is required'}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  try:
99
- with open(filepath, 'w', encoding='utf-8') as f:
100
- f.write(content)
101
- # 返回新的 metadata 以便前端更新标题
102
- return jsonify(parse_problem_metadata(filename))
103
  except Exception as e:
104
  return jsonify({'error': str(e)}), 500
105
 
@@ -107,4 +152,4 @@ if __name__ == '__main__':
107
  # 确保 problems 目录存在
108
  if not os.path.exists(PROBLEMS_DIR):
109
  os.makedirs(PROBLEMS_DIR)
110
- app.run(host='0.0.0.0', port=7860, debug=True)
 
19
  if title_match:
20
  title = title_match.group(1).strip()
21
 
22
+ # 解析 @category
23
+ category_match = re.search(r'@category\s+(.+)', content)
24
+ category = category_match.group(1).strip() if category_match else '练习题'
25
+
26
+ # 解析 @pinned
27
+ pinned_match = re.search(r'@pinned\s+(.+)', content)
28
+ is_pinned = (pinned_match.group(1).strip().lower() == 'true') if pinned_match else False
29
+
30
  return {
31
  'id': filename,
32
  'title': title,
33
+ 'category': category,
34
+ 'pinned': is_pinned,
35
+ 'content': content,
36
+ 'ctime': os.path.getctime(filepath)
37
  }
38
 
39
  @app.route('/')
 
43
  @app.route('/api/problems', methods=['GET'])
44
  def get_problems():
45
  files = [f for f in os.listdir(PROBLEMS_DIR) if f.endswith('.js') or f.endswith('.ts')]
46
+ # 按创建时间倒序排序 (最新的在最前)
47
+ files.sort(key=lambda x: os.path.getctime(os.path.join(PROBLEMS_DIR, x)), reverse=True)
48
 
49
  problems = []
50
  for f in files:
 
59
  def create_problem():
60
  data = request.json
61
  filename = data.get('filename')
62
+ category = data.get('category', '练习题')
63
 
64
  if not filename:
65
  return jsonify({'error': 'Filename is required'}), 400
 
75
  # 默认模板
76
  content = f"""/**
77
  * @title {filename}
78
+ * @category {category}
79
  * @description 在此处添加题目描述
80
  */
81
 
 
104
  if not os.path.exists(filepath):
105
  return jsonify({'error': 'File not found'}), 404
106
 
107
+ data = request.json
108
+
109
+ # Handle Rename
110
+ new_filename = data.get('new_filename')
111
+ if new_filename:
112
+ if not (new_filename.endswith('.js') or new_filename.endswith('.ts')):
113
+ new_filename += '.js'
114
+
115
+ new_filepath = os.path.join(PROBLEMS_DIR, new_filename)
116
+ if os.path.exists(new_filepath) and new_filename != filename:
117
+ return jsonify({'error': 'Target filename already exists'}), 409
118
+
119
+ try:
120
+ os.rename(filepath, new_filepath)
121
+ return jsonify(parse_problem_metadata(new_filename))
122
+ except Exception as e:
123
+ return jsonify({'error': str(e)}), 500
124
+
125
+ # Handle Content Update
126
+ content = data.get('content')
127
+ if content is not None:
128
+ try:
129
+ with open(filepath, 'w', encoding='utf-8') as f:
130
+ f.write(content)
131
+ # 返回新的 metadata 以便前端更新标题
132
+ return jsonify(parse_problem_metadata(filename))
133
+ except Exception as e:
134
+ return jsonify({'error': str(e)}), 500
135
+
136
+ return jsonify({'error': 'No valid operation specified'}), 400
137
+
138
+ @app.route('/api/problems/<path:filename>', methods=['DELETE'])
139
+ def delete_problem(filename):
140
+ filepath = os.path.join(PROBLEMS_DIR, filename)
141
+
142
+ if not os.path.exists(filepath):
143
+ return jsonify({'error': 'File not found'}), 404
144
 
145
  try:
146
+ os.remove(filepath)
147
+ return jsonify({'message': 'Deleted successfully'})
 
 
148
  except Exception as e:
149
  return jsonify({'error': str(e)}), 500
150
 
 
152
  # 确保 problems 目录存在
153
  if not os.path.exists(PROBLEMS_DIR):
154
  os.makedirs(PROBLEMS_DIR)
155
+ app.run(host='0.0.0.0', port=7870, debug=True)
problems/02_fibonacci.js DELETED
@@ -1,24 +0,0 @@
1
- /**
2
- * @title 2. 斐波那契数列 (Fibonacci)
3
- *
4
- * 题目描述:
5
- * 写一个函数,输入 n,求斐波那契(Fibonacci)数列的第 n 项。
6
- * F(0) = 0, F(1) = 1
7
- * F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
8
- */
9
-
10
- function fib(n) {
11
- if (n < 2) return n;
12
- let p = 0, q = 0, r = 1;
13
- for (let i = 2; i <= n; i++) {
14
- p = q;
15
- q = r;
16
- r = p + q;
17
- }
18
- return r;
19
- }
20
-
21
- // 测试用例
22
- console.log("Fib(2) =", fib(2)); // 1
23
- console.log("Fib(5) =", fib(5)); // 5
24
- console.log("Fib(10) =", fib(10)); // 55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/index.html CHANGED
@@ -135,10 +135,70 @@
135
  overflow: hidden;
136
  text-overflow: ellipsis;
137
  border-left: 3px solid transparent;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
- .problem-item:hover {
 
 
 
 
 
141
  background-color: var(--sidebar-hover);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  }
143
 
144
  .problem-item.active {
@@ -244,6 +304,83 @@
244
  height: 100%;
245
  color: #888;
246
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  </style>
248
  </head>
249
  <body>
@@ -262,7 +399,6 @@
262
  <div id="split-0" class="sidebar">
263
  <div class="sidebar-header">
264
  题目列表
265
- <button class="add-btn" onclick="createProblem()" title="新建题目">+</button>
266
  </div>
267
  <ul class="problem-list" id="problem-list">
268
  <!-- Items will be injected here -->
@@ -370,17 +506,141 @@
370
  }
371
  }
372
 
 
 
373
  function renderProblemList(problems) {
374
  const listEl = document.getElementById('problem-list');
375
  listEl.innerHTML = '';
376
 
 
 
 
 
377
  problems.forEach(p => {
378
- const li = document.createElement('li');
379
- li.className = 'problem-item';
380
- li.textContent = p.title;
381
- li.dataset.id = p.id;
382
- li.onclick = () => loadProblem(p);
383
- listEl.appendChild(li);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  });
385
 
386
  if (currentProblemId) {
@@ -388,6 +648,102 @@
388
  if (activeEl) activeEl.classList.add('active');
389
  }
390
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
  async function loadProblem(problem) {
393
  if (currentProblemId === problem.id && editor.getValue() === problem.content) return;
@@ -408,15 +764,15 @@
408
  logToConsole(['Loaded:', problem.title], 'info');
409
  }
410
 
411
- async function createProblem() {
412
- const filename = prompt("请输入新题目文件名 (例如: 03_new_problem):", "new_problem");
413
  if (!filename) return;
414
 
415
  try {
416
  const res = await fetch('/api/problems', {
417
  method: 'POST',
418
  headers: { 'Content-Type': 'application/json' },
419
- body: JSON.stringify({ filename })
420
  });
421
 
422
  if (res.ok) {
 
135
  overflow: hidden;
136
  text-overflow: ellipsis;
137
  border-left: 3px solid transparent;
138
+ display: flex;
139
+ align-items: center;
140
+ position: relative; /* For absolute positioning of actions */
141
+ height: 32px; /* Fixed height for consistency */
142
+ }
143
+
144
+ .problem-title {
145
+ overflow: hidden;
146
+ text-overflow: ellipsis;
147
+ flex: 1;
148
+ margin-right: 5px; /* Spacing from potential actions */
149
+ }
150
+
151
+ .item-actions {
152
+ position: absolute;
153
+ right: 5px;
154
+ top: 50%;
155
+ transform: translateY(-50%);
156
+ display: flex;
157
+ gap: 4px;
158
+ opacity: 0; /* Hidden by default */
159
+ transition: opacity 0.2s ease;
160
+ background-color: var(--sidebar-bg); /* Cover text behind */
161
+ padding-left: 8px; /* Fade effect spacer if using gradient, here just solid bg */
162
+ box-shadow: -10px 0 10px -5px var(--sidebar-bg); /* Soft edge */
163
+ }
164
+
165
+ .problem-item:hover .item-actions {
166
+ opacity: 1;
167
  }
168
 
169
+ .problem-item.active .item-actions {
170
+ background-color: var(--sidebar-active); /* Match active bg */
171
+ box-shadow: -10px 0 10px -5px var(--sidebar-active);
172
+ }
173
+
174
+ .problem-item:hover .item-actions {
175
  background-color: var(--sidebar-hover);
176
+ box-shadow: -10px 0 10px -5px var(--sidebar-hover);
177
+ }
178
+
179
+ .action-btn {
180
+ background: none;
181
+ border: none;
182
+ color: #888;
183
+ cursor: pointer;
184
+ padding: 4px;
185
+ border-radius: 3px;
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ width: 24px;
190
+ height: 24px;
191
+ }
192
+
193
+ .action-btn:hover {
194
+ color: #e0e0e0;
195
+ background-color: rgba(255, 255, 255, 0.1);
196
+ }
197
+
198
+ .action-btn svg {
199
+ width: 14px;
200
+ height: 14px;
201
+ fill: currentColor;
202
  }
203
 
204
  .problem-item.active {
 
304
  height: 100%;
305
  color: #888;
306
  }
307
+
308
+ /* Category Group Styles */
309
+ .category-group {
310
+ margin-bottom: 5px;
311
+ }
312
+ .category-header {
313
+ padding: 0 15px; /* Taller */
314
+ font-size: 12px;
315
+ font-weight: bold;
316
+ color: #aaa;
317
+ cursor: pointer;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: space-between; /* Push add button to right */
321
+ user-select: none;
322
+ text-transform: uppercase;
323
+ border-bottom: 1px solid rgba(255,255,255,0.05); /* Subtle separator */
324
+ }
325
+ .category-header:hover {
326
+ color: #fff;
327
+ background-color: rgba(255,255,255,0.05);
328
+ }
329
+ .category-header svg {
330
+ width: 14px; /* Slightly larger */
331
+ height: 14px;
332
+ margin-right: 8px;
333
+ transition: transform 0.2s;
334
+ fill: currentColor;
335
+ }
336
+ .category-header .add-btn {
337
+ background: none;
338
+ border: none;
339
+ color: #888;
340
+ cursor: pointer;
341
+ font-size: 20px; /* Larger */
342
+ width: 24px;
343
+ height: 24px;
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: center;
347
+ border-radius: 4px;
348
+ transition: all 0.2s;
349
+ line-height: 1;
350
+ padding: 0;
351
+ margin-left: 10px;
352
+ }
353
+ .category-header .add-btn:hover {
354
+ color: #fff;
355
+ background-color: rgba(255, 255, 255, 0.2);
356
+ }
357
+
358
+ .category-header.collapsed svg {
359
+ transform: rotate(-90deg);
360
+ }
361
+ .category-list {
362
+ list-style: none;
363
+ padding-left: 0;
364
+ display: block;
365
+ }
366
+ .category-list.collapsed {
367
+ display: none;
368
+ }
369
+
370
+ /* Pinned Item Styles */
371
+ .pinned-icon {
372
+ color: #d7ba7d; /* Gold-ish for pinned */
373
+ margin-right: 5px;
374
+ width: 12px;
375
+ height: 12px;
376
+ fill: currentColor;
377
+ display: inline-block;
378
+ vertical-align: middle;
379
+ }
380
+
381
+ .action-btn.pinned {
382
+ color: #d7ba7d;
383
+ }
384
  </style>
385
  </head>
386
  <body>
 
399
  <div id="split-0" class="sidebar">
400
  <div class="sidebar-header">
401
  题目列表
 
402
  </div>
403
  <ul class="problem-list" id="problem-list">
404
  <!-- Items will be injected here -->
 
506
  }
507
  }
508
 
509
+ const CATEGORIES = ['练习题', '面试题', '手撕题', '算法题'];
510
+
511
  function renderProblemList(problems) {
512
  const listEl = document.getElementById('problem-list');
513
  listEl.innerHTML = '';
514
 
515
+ // Group problems
516
+ const grouped = {};
517
+ CATEGORIES.forEach(c => grouped[c] = []);
518
+
519
  problems.forEach(p => {
520
+ const targetCat = CATEGORIES.includes(p.category) ? p.category : '练习题';
521
+ grouped[targetCat].push(p);
522
+ });
523
+
524
+ CATEGORIES.forEach(category => {
525
+ const items = grouped[category];
526
+
527
+ // Sort: Pinned first, then by ctime desc (backend sent ctime desc)
528
+ items.sort((a, b) => {
529
+ if (!!a.pinned === !!b.pinned) return 0; // Stable sort for same pin status
530
+ return a.pinned ? -1 : 1;
531
+ });
532
+
533
+ // Create Category Group
534
+ const groupDiv = document.createElement('div');
535
+ groupDiv.className = 'category-group';
536
+
537
+ // Header
538
+ const header = document.createElement('div');
539
+ header.className = 'category-header';
540
+
541
+ // Header Content
542
+ const headerContent = document.createElement('div');
543
+ headerContent.style.display = 'flex';
544
+ headerContent.style.alignItems = 'center';
545
+ headerContent.innerHTML = `
546
+ <svg viewBox="0 0 16 16"><path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/></svg>
547
+ ${category} (${items.length})
548
+ `;
549
+ header.appendChild(headerContent);
550
+
551
+ // Add Button in Header
552
+ const addBtn = document.createElement('button');
553
+ addBtn.className = 'add-btn';
554
+ addBtn.innerHTML = '+';
555
+ addBtn.title = '新建题目';
556
+ // Removed inline styles to let CSS take over
557
+ addBtn.onclick = (e) => {
558
+ e.stopPropagation();
559
+ createProblem(category);
560
+ };
561
+ header.appendChild(addBtn);
562
+
563
+ // List
564
+ const ul = document.createElement('ul');
565
+ ul.className = 'category-list';
566
+
567
+ // Toggle Handler
568
+ header.onclick = (e) => {
569
+ if (e.target === addBtn) return;
570
+ header.classList.toggle('collapsed');
571
+ ul.classList.toggle('collapsed');
572
+ };
573
+ groupDiv.appendChild(header);
574
+
575
+ items.forEach((p, index) => {
576
+ const li = document.createElement('li');
577
+ li.className = 'problem-item';
578
+ li.dataset.id = p.id;
579
+ li.onclick = () => loadProblem(p);
580
+
581
+ // Title Span
582
+ const titleSpan = document.createElement('span');
583
+ titleSpan.className = 'problem-title';
584
+
585
+ // Clean title: remove existing leading numbering (e.g. "1. ", "01 ", "1、")
586
+ const cleanTitle = p.title.replace(/^\d+[\.\s、]+/, '');
587
+ const displayTitle = `${index + 1}. ${cleanTitle}`;
588
+
589
+ // Pinned Icon in Title
590
+ if (p.pinned) {
591
+ titleSpan.innerHTML = `<svg class="pinned-icon" viewBox="0 0 16 16"><path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A5.921 5.921 0 0 1 5 6.708V2.277a2.77 2.77 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354z"/></svg>`;
592
+ titleSpan.appendChild(document.createTextNode(displayTitle));
593
+ } else {
594
+ titleSpan.textContent = displayTitle;
595
+ }
596
+
597
+ li.appendChild(titleSpan);
598
+
599
+ // Actions Div
600
+ const actionsDiv = document.createElement('div');
601
+ actionsDiv.className = 'item-actions';
602
+
603
+ // Pin Button
604
+ const pinBtn = document.createElement('button');
605
+ pinBtn.className = `action-btn ${p.pinned ? 'pinned' : ''}`;
606
+ pinBtn.title = p.pinned ? '取消置顶' : '置顶';
607
+ pinBtn.innerHTML = '<svg viewBox="0 0 16 16"><path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5a.5.5 0 0 1-1 0V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A5.921 5.921 0 0 1 5 6.708V2.277a2.77 2.77 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354z"/></svg>';
608
+ pinBtn.onclick = (e) => {
609
+ e.stopPropagation();
610
+ togglePin(p);
611
+ };
612
+ actionsDiv.appendChild(pinBtn);
613
+
614
+ // Rename Button
615
+ const renameBtn = document.createElement('button');
616
+ renameBtn.className = 'action-btn';
617
+ renameBtn.title = '重命名';
618
+ // Edit Icon (Pencil)
619
+ renameBtn.innerHTML = '<svg viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>';
620
+ renameBtn.onclick = (e) => {
621
+ e.stopPropagation();
622
+ renameProblem(p.id);
623
+ };
624
+ actionsDiv.appendChild(renameBtn);
625
+
626
+ // Delete Button
627
+ const deleteBtn = document.createElement('button');
628
+ deleteBtn.className = 'action-btn';
629
+ deleteBtn.title = '删除';
630
+ // Trash Icon
631
+ deleteBtn.innerHTML = '<svg viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>';
632
+ deleteBtn.onclick = (e) => {
633
+ e.stopPropagation();
634
+ deleteProblem(p.id);
635
+ };
636
+ actionsDiv.appendChild(deleteBtn);
637
+
638
+ li.appendChild(actionsDiv);
639
+ ul.appendChild(li);
640
+ });
641
+
642
+ groupDiv.appendChild(ul);
643
+ listEl.appendChild(groupDiv);
644
  });
645
 
646
  if (currentProblemId) {
 
648
  if (activeEl) activeEl.classList.add('active');
649
  }
650
  }
651
+
652
+ async function togglePin(problem) {
653
+ try {
654
+ const res = await fetch(`/api/problems/${problem.id}`);
655
+ const content = await res.text();
656
+
657
+ let newContent;
658
+ // Check if @pinned exists
659
+ const hasPinned = /@pinned\s+(true|false)/i.test(content);
660
+ const isPinned = problem.pinned;
661
+
662
+ if (hasPinned) {
663
+ // Replace existing
664
+ newContent = content.replace(/@pinned\s+(true|false)/i, `@pinned ${!isPinned}`);
665
+ } else {
666
+ // Add after @title or @category
667
+ if (content.includes('@category')) {
668
+ newContent = content.replace(/(@category\s+.+)/, `$1\n * @pinned ${!isPinned}`);
669
+ } else if (content.includes('@title')) {
670
+ newContent = content.replace(/(@title\s+.+)/, `$1\n * @pinned ${!isPinned}`);
671
+ } else {
672
+ // Just prepend
673
+ newContent = `/**\n * @pinned ${!isPinned}\n */\n` + content;
674
+ }
675
+ }
676
+
677
+ // Save
678
+ const saveRes = await fetch(`/api/problems/${problem.id}`, {
679
+ method: 'PUT',
680
+ headers: { 'Content-Type': 'application/json' },
681
+ body: JSON.stringify({ content: newContent })
682
+ });
683
+
684
+ if (saveRes.ok) {
685
+ // Refresh list
686
+ await fetchProblems(currentProblemId);
687
+ }
688
+ } catch (e) {
689
+ console.error("Toggle pin failed", e);
690
+ }
691
+ }
692
+
693
+ async function renameProblem(id) {
694
+ // Strip extension for display if you want, but keep it simple for now
695
+ const currentName = id.replace(/\.(js|ts)$/, '');
696
+ const newName = prompt("请输入新文件名 (不含后缀):", currentName);
697
+
698
+ if (!newName || newName === currentName) return;
699
+
700
+ try {
701
+ const res = await fetch(`/api/problems/${id}`, {
702
+ method: 'PUT',
703
+ headers: { 'Content-Type': 'application/json' },
704
+ body: JSON.stringify({ new_filename: newName })
705
+ });
706
+
707
+ if (res.ok) {
708
+ const newMeta = await res.json();
709
+ // If we renamed the currently open problem, update the ID
710
+ if (currentProblemId === id) {
711
+ currentProblemId = newMeta.id;
712
+ }
713
+ await fetchProblems(currentProblemId);
714
+ logToConsole(['Renamed to:', newMeta.title], 'info');
715
+ } else {
716
+ const err = await res.json();
717
+ alert(`Error: ${err.error}`);
718
+ }
719
+ } catch (e) {
720
+ alert(`Error: ${e.message}`);
721
+ }
722
+ }
723
+
724
+ async function deleteProblem(id) {
725
+ if (!confirm(`确定要删除 "${id}" 吗? 此操作无法撤销。`)) return;
726
+
727
+ try {
728
+ const res = await fetch(`/api/problems/${id}`, {
729
+ method: 'DELETE'
730
+ });
731
+
732
+ if (res.ok) {
733
+ logToConsole(['Deleted:', id], 'warn');
734
+ if (currentProblemId === id) {
735
+ currentProblemId = null;
736
+ if (editor) editor.setValue('// Select a problem to start coding...');
737
+ }
738
+ await fetchProblems();
739
+ } else {
740
+ const err = await res.json();
741
+ alert(`Error: ${err.error}`);
742
+ }
743
+ } catch (e) {
744
+ alert(`Error: ${e.message}`);
745
+ }
746
+ }
747
 
748
  async function loadProblem(problem) {
749
  if (currentProblemId === problem.id && editor.getValue() === problem.content) return;
 
764
  logToConsole(['Loaded:', problem.title], 'info');
765
  }
766
 
767
+ async function createProblem(category = '练习题') {
768
+ const filename = prompt(`[${category}] 请输入新题目文件名 (例如: 03_new_problem):`, "new_problem");
769
  if (!filename) return;
770
 
771
  try {
772
  const res = await fetch('/api/problems', {
773
  method: 'POST',
774
  headers: { 'Content-Type': 'application/json' },
775
+ body: JSON.stringify({ filename, category })
776
  });
777
 
778
  if (res.ok) {