chemistrymaths-AI/jp-study-cards
0
1import os2import json3import csv4import io5import base646import uvicorn7from zhipuai import ZhipuAI # 引入智谱官方SDK8from fastapi import FastAPI, HTTPException, UploadFile, File9from fastapi.responses import HTMLResponse, StreamingResponse10from pydantic import BaseModel11from typing import Optional12from pypdf import PdfReader13 14app = FastAPI(title="情境生词消灭器-精致不溢出完美版")15 16# ==========================================17# 1. 智谱 AI API Key 核心防护层18# ==========================================19RAW_KEY = "f365bebeb4714f7ba2489d0e188042e1.hOxdMA3OKJgwHiqJ"20ZHIPU_API_KEY = RAW_KEY.replace("[", "").replace("]", "").split("(")[0].strip()21client_ai = ZhipuAI(api_key=ZHIPU_API_KEY)22 23# ==========================================24# 2. 本地持久化数据中心25# ==========================================26DB_FILE = "cards_db.json"27 28 29def load_db():30 if os.path.exists(DB_FILE):31 try:32 with open(DB_FILE, "r", encoding="utf-8") as f:33 return json.load(f)34 except:35 pass36 return [37 {38 "id": 1, "word": "相棒", "furigana": "あいぼう",39 "meaning_ja": "一緒に仕事や行動をする大切なパートナーのこと。",40 "meaning_zh": "老搭档、死党、伙伴", "context_source": "日剧",41 "example_sentence": "お前は俺の最高の相棒だ。(你是我最好的搭档。)", "tags": "日剧", "status": "learning"42 },43 {44 "id": 2, "word": "一口", "furigana": "ひとくch",45 "meaning_ja": "食べ物や飲み物を、口の中に一度に入れる量。",46 "meaning_zh": "(吃/喝)一口", "context_source": "日常",47 "example_sentence": "これ、めちゃくちゃ美味しいから一口食べてみて!", "tags": "日常", "status": "learning"48 }49 ]50 51 52def save_db(data):53 with open(DB_FILE, "w", encoding="utf-8") as f:54 json.dump(data, f, ensure_ascii=False, indent=4)55 56 57db_cards = load_db()58 59 60# ==========================================61# 3. 数据交互模型62# ==========================================63class CardItem(BaseModel):64 id: Optional[int] = None65 word: str66 furigana: str67 meaning_ja: Optional[str] = ""68 meaning_zh: str69 context_source: str70 example_sentence: str71 tags: str72 status: Optional[str] = "learning"73 74 75class AIRequest(BaseModel):76 word: str77 hint_sentence: Optional[str] = ""78 79 80class StatusUpdateRequest(BaseModel):81 status: str82 83 84# ==========================================85# 4. 后端路由与大模型交互86# ==========================================87@app.get("/api/cards")88def get_cards(tag: Optional[str] = None, filter_status: Optional[str] = "learning"):89 cards = db_cards90 if filter_status:91 cards = [c for c in cards if c.get("status", "learning") == filter_status]92 if tag and tag != "全部":93 cards = [c for c in cards if tag in c["tags"]]94 return cards[::-1]95 96 97@app.post("/api/cards")98def save_or_update_card(card: CardItem):99 global db_cards100 if not card.word or not card.meaning_zh:101 raise HTTPException(status_code=400, detail="生词和中文释义不能为空")102 103 if "日常" in card.tags or "生活" in card.tags:104 card.tags = "日常"105 elif "剧" in card.tags or "动漫" in card.tags:106 card.tags = "日剧"107 else:108 card.tags = "日常"109 110 if card.id is not None:111 for idx, item in enumerate(db_cards):112 if item["id"] == card.id:113 db_cards[idx] = card.dict()114 save_db(db_cards)115 return {"status": "success", "action": "updated"}116 raise HTTPException(status_code=404, detail="未找到对应的卡片")117 else:118 new_id = max([c["id"] for c in db_cards]) + 1 if db_cards else 1119 card_dict = card.dict()120 card_dict["id"] = new_id121 card_dict["status"] = "learning"122 db_cards.append(card_dict)123 save_db(db_cards)124 return {"status": "success", "action": "created"}125 126 127@app.delete("/api/cards/{card_id}")128def delete_card(card_id: int):129 global db_cards130 db_cards = [c for c in db_cards if c["id"] != card_id]131 save_db(db_cards)132 return {"status": "success"}133 134 135@app.post("/api/cards/{card_id}/status")136def update_card_status(card_id: int, payload: StatusUpdateRequest):137 global db_cards138 for item in db_cards:139 if item["id"] == card_id:140 item["status"] = payload.status141 save_db(db_cards)142 return {"status": "success"}143 raise HTTPException(status_code=404, detail="未找到指定卡片")144 145 146@app.post("/api/ai-generate")147def ai_generate(payload: AIRequest):148 if not payload.word:149 raise HTTPException(status_code=400, detail="生词不能为空")150 151 clean_word = payload.word.strip()152 153 # 🔴 核心升级:高压策略控制 Prompt,彻底杜绝简易日解吐出中文154 prompt = (155 "你是一个精通中日双语的日语教学专家。请为以下日语生词进行解析。\n"156 f"待解析生词:{clean_word}\n"157 f"用户提供的情境提示:{payload.hint_sentence if payload.hint_sentence else '无'}\n\n"158 "请严格按照以下 JSON 格式返回数据,不要包含任何 markdown 标记,不要有任何废话:\n"159 "{\n"160 ' "furigana": "该生词的纯假名发音",\n'161 ' "meaning_zh": "该生词最准确的中文含义",\n'162 ' "meaning_ja": "【绝对只能使用纯日语!】用简单易懂、符合N4水平的日语(可以使用汉字和假名,但绝对禁止包含任何中文字符和中文语法习惯)来解释该词的意思。目的是帮助学习者用日语逻辑思考。",\n'163 ' "context_source": "情境出处,如日剧台词、日常口语",\n'164 ' "example_sentence": "一句高频生活例句并附带括号中文翻译",\n'165 ' "tags": "只能从以下两个标签中选择一个填入:若属于动漫/日剧/台词填\'日剧\',若是通用生活口语则填\'日常\'"\n'166 "}"167 )168 169 try:170 response = client_ai.chat.completions.create(171 model="glm-4-flash",172 messages=[{"role": "user", "content": prompt}],173 temperature=0.3, # 降低温度,确保大模型严格听话不放飞自我174 )175 raw_text = response.choices[0].message.content.strip()176 if raw_text.startswith("```"):177 raw_text = raw_text.split("\n", 1)[1].rsplit("\n", 1)[0]178 return json.loads(raw_text)179 except Exception as e:180 return {"error_type": "SDK_ERROR", "msg": f"大模型通讯失败: {str(e)}"}181 182 183@app.post("/api/洞察词汇/文件扫描")184async def scan_file(file: UploadFile = File(...)):185 filename = file.filename.lower()186 extracted_text = ""187 188 if filename.endswith(".pdf"):189 try:190 pdf_bytes = await file.read()191 reader = PdfReader(io.BytesIO(pdf_bytes))192 for page in reader.pages[:5]:193 extracted_text += page.extract_text() or ""194 except Exception as e:195 raise HTTPException(status_code=400, detail=f"PDF文本解析失败: {str(e)}")196 197 elif filename.endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")):198 try:199 image_bytes = await file.read()200 base64_image = base64.b64encode(image_bytes).decode('utf-8')201 response = client_ai.chat.completions.create(202 model="glm-4v-flash",203 messages=[{204 "role": "user",205 "content": [206 {"type": "text",207 "text": "请仔细辨认并提取出图片里的所有日语文本。不要任何解释说明,直接输出原文。"},208 {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}209 ]210 }],211 temperature=0.1212 )213 extracted_text = response.choices[0].message.content.strip()214 except Exception as e:215 raise HTTPException(status_code=400, detail=f"多模态视觉解析失败: {str(e)}")216 else:217 raise HTTPException(status_code=400, detail="暂不支持此格式文件")218 219 if not extracted_text.strip():220 return {"words": []}221 222 try:223 filter_prompt = (224 "请从以下文本中提取出适合N4-N3级别的核心词汇。\n"225 f"目标文本:\n{extracted_text}\n\n"226 "请直接返回一个纯JSON格式的字符串数组,例:[\"単語1\", \"単語2\"]"227 )228 res = client_ai.chat.completions.create(229 model="glm-4-flash",230 messages=[{"role": "user", "content": filter_prompt}],231 temperature=0.2232 )233 raw_arr = res.choices[0].message.content.strip()234 if raw_arr.startswith("```"):235 raw_arr = raw_arr.split("\n", 1)[1].rsplit("\n", 1)[0]236 return {"words": json.loads(raw_arr)}237 except:238 return {"words": [w.strip() for w in extracted_text.split() if w.strip()][:15]}239 240 241@app.get("/api/cards/export")242def export_excel():243 output = io.StringIO()244 output.write('\ufeff')245 writer = csv.writer(output)246 writer.writerow(["生词", "假名发音", "日文释义", "中文含义", "情境出处", "高频生活例句", "标签", "掌握状态"])247 for c in db_cards:248 status_zh = "已掌握" if c.get("status") == "mastered" else "正在复习"249 writer.writerow([c["word"], c["furigana"], c.get("meaning_ja", ""), c["meaning_zh"], c["context_source"],250 c["example_sentence"], c["tags"], status_zh])251 output.seek(0)252 return StreamingResponse(io.BytesIO(output.getvalue().encode('utf-8')), media_type="text/csv",253 headers={"Content-Disposition": "attachment; filename=my_japanese_cards.csv"})254 255 256# ==========================================257# 5. 前端全景页面 (完美融合右侧禁区防遮挡布局)258# ==========================================259@app.get("/", response_class=HTMLResponse)260def index_page():261 return """262 <!DOCTYPE html>263 <html lang="zh-CN">264 <head>265 <meta charset="UTF-8">266 <meta name="viewport" content="width=device-width, initial-scale=1.0">267 <title>情境生词消灭器 - 终极修复版</title>268 <style>269 :root {270 --forest-dark: #2d4a43; --forest-leaf: #4a7c6c; --forest-light: #f4f7f5;271 --wood-earth: #8ba89e; --text-main: #2c3e35; --text-sub: #60756c;272 --card-bg: #ffffff; --danger: #c96868; --success: #5ba37e;273 }274 body {275 background-color: var(--forest-light); color: var(--text-main);276 font-family: "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 0; height: 100vh; display: flex; flex-direction: column;277 }278 header {279 background: var(--forest-dark); color: #ffffff; padding: 12px 24px; box-shadow: 0 2px 8px rgba(45,74,67,0.1);280 display: flex; justify-content: space-between; align-items: center; height: 36px;281 }282 .app-container { display: flex; flex: 1; overflow: hidden; padding: 16px; gap: 20px; box-sizing: border-box; }283 284 .left-panel {285 width: 480px; flex-shrink: 0; background: var(--card-bg); border-radius: 16px; padding: 16px; 286 box-shadow: 0 4px 14px rgba(45,74,67,0.05); display: flex; flex-direction: column; overflow-y: auto; border: 1px solid #e2e9e6;287 }288 .right-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }289 290 .tree-hollow { background: #fdfaf4; border: 1px dashed var(--wood-earth); border-radius: 8px; padding: 10px; margin-bottom: 14px; }291 .word-pill { display: inline-block; background: #ebdcb9; color: #5a4525; padding: 4px 10px; border-radius: 14px; font-size: 12px; margin: 4px; cursor: pointer; font-weight: bold; transition: all 0.2s; }292 .word-pill:hover { background: var(--forest-leaf); color: white; }293 294 .control-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }295 #tag-bar { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }296 .tag-btn { background: #e9f0ed; color: var(--text-sub); border: none; padding: 6px 12px; border-radius: 20px; font-size: 13px; cursor: pointer; white-space: nowrap; }297 .tag-btn.active { background: var(--forest-leaf); color: white; }298 299 .view-switch { background: #e2e9e6; padding: 4px; border-radius: 8px; display: flex; gap: 4px; }300 .switch-btn { border: none; background: transparent; padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; }301 .switch-btn.active { background: white; color: var(--forest-dark); font-weight: bold; }302 303 .cards-scroll-area { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; padding-right: 4px; align-content: start; }304 305 .card-perspective { perspective: 1000px; width: 100%; min-height: 105px; position: relative; }306 .card-rotator { position: relative; width: 100%; height: 100%; min-height: 105px; transition: transform 0.4s ease; transform-style: preserve-3d; cursor: pointer; }307 .is-flipped { transform: rotateY(180deg); }308 309 /* 🔴 核心改进:添加右侧 55px 的绝缘禁区 padding-right,确保内容打死也碰不到图标 */310 .face-front, .face-back {311 backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: 12px; padding: 12px; padding-right: 55px; box-sizing: border-box; width: 100%; height: 100%;312 background: var(--card-bg); border: 1px solid #e2e9e6; box-shadow: 0 2px 6px rgba(45,74,67,0.03);313 }314 .face-back { transform: rotateY(180deg); position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #fafcfb; border: 1px solid var(--wood-earth); overflow-y: auto; }315 316 h3 { margin: 0 0 12px 0; color: var(--forest-dark); font-size: 15px; border-bottom: 2px solid #e9f0ed; padding-bottom: 4px; }317 .form-group { margin-bottom: 8px; }318 .form-group label { display: block; font-size: 11px; color: var(--text-sub); font-weight: bold; margin-bottom: 2px; }319 .form-group input, .form-group textarea { width: 100%; padding: 6px 8px; background: #fafcfb; border: 1px solid #d3ded9; color: var(--text-main); border-radius: 6px; box-sizing: border-box; font-size: 13px; }320 .form-group input:focus, .form-group textarea:focus { border-color: var(--forest-leaf); outline: none; }321 322 .btn-action { width: 100%; border: none; padding: 8px; border-radius: 6px; font-weight: bold; cursor: pointer; margin-bottom: 8px; }323 .btn-ai { background: var(--forest-leaf); color: white; }324 .btn-submit { background: var(--success); color: white; }325 326 /* 🔴 图标定位绝对锁定,层级置顶,防止文字背景干扰 */327 .card-actions { position: absolute; right: 8px; top: 12px; z-index: 100; display: flex; gap: 4px; justify-content: flex-end; }328 .mini-btn { border: none; background: #e9f0ed; font-size: 11px; padding: 4px 6px; border-radius: 4px; cursor: pointer; color: var(--text-main); }329 .mini-btn:hover { background: var(--wood-earth); color: white; }330 .mini-btn.del { background: #fdf0f0; color: var(--danger); }331 .mini-btn.del:hover { background: var(--danger); color: white; }332 333 @media (max-width: 800px) {334 body { height: auto; overflow-y: auto; }335 .app-container { flex-direction: column; overflow: visible; height: auto; }336 .left-panel { width: 100%; height: auto; overflow: visible; box-sizing: border-box; margin-bottom: 20px; }337 .right-panel { width: 100%; height: auto; overflow: visible; }338 .cards-scroll-area { grid-template-columns: 1fr; overflow-y: visible; height: auto; }339 }340 </style>341 </head>342 <body>343 <header>344 <div style="font-weight: bold; font-size: 15px;">🍃 情境生词消灭器 <span style="font-size:11px; background: rgba(255,255,255,0.2); padding: 2px 6px; border-radius: 4px;">无溢出精简版</span></div>345 <button onclick="exportToExcel()" class="mini-btn" style="background:#5ba37e; color:white; font-weight:bold; padding: 6px 12px;">📊 导出 Excel 表格</button>346 </header>347 348 <div class="app-container">349 <div class="left-panel">350 <div class="tree-hollow">351 <div style="font-size:11px; font-weight:bold; color:#846226; margin-bottom:4px; display:flex; justify-content:space-between;">352 <span>🌲 森林树洞 · 截图/PDF/随手记</span>353 <span id="scan-loading" style="color:var(--forest-leaf); display:none;">AI识别中...</span>354 </div>355 <input type="file" id="file-scanner" onchange="uploadAndScanFile()" style="font-size:11px; width:100%; margin-bottom:6px; color: var(--text-sub);">356 <div id="hollow-pills" style="max-height:80px; overflow-y:auto; border-top:1px dashed #e1d6be; padding-top:4px;">357 <span style="font-size:11px; color:#a49070; font-style:italic;">上传日剧截图或PDF,AI分词。</span>358 </div>359 </div>360 361 <h3 id="panel-title">🌲 生词捕获终端</h3>362 <input type="hidden" id="in-id">363 364 <div class="form-group"><label>日语生词 *</label><input id="in-word" type="text" placeholder="例:遠慮する"></div>365 <div class="form-group"><label>当前情境台词 (选填)</label><textarea id="in-hint" rows="2" placeholder="贴入当前句子..."></textarea></div>366 <button id="btn-ai" onclick="runAIAssistant()" class="btn-action btn-ai">🪄 唤醒 AI 智能解析填表</button>367 368 <div style="font-size: 11px; font-weight: bold; color: var(--forest-leaf); margin-bottom: 6px;">📋 属性校对面板</div>369 <div class="form-group"><label>假名发音</label><input id="in-furi" type="text"></div>370 <div class="form-group"><label>中文释义</label><input id="in-zh" type="text"></div>371 <div class="form-group"><label>简易日解 (独立思维模式)</label><textarea id="in-ja" rows="2"></textarea></div>372 <div class="form-group"><label>情境出处</label><input id="in-source" type="text"></div>373 <div class="form-group"><label>标签分组</label><input id="in-tags" type="text" placeholder="日常 / 日剧"></div>374 <div class="form-group"><label>高频情境例句</label><textarea id="in-sentence" rows="2"></textarea></div>375 376 <div style="display:flex; gap:8px;">377 <button id="btn-cancel-edit" onclick="cancelEditMode()" class="btn-action" style="background:#e9f0ed; display:none; flex:1;">取消</button>378 <button id="btn-save" onclick="submitCard()" class="btn-action btn-submit" style="flex:2;">🌱 确认归档入库</button>379 </div>380 </div>381 382 <div class="right-panel">383 <div class="control-row">384 <div id="tag-bar"></div>385 <div class="view-switch">386 <button id="sw-learning" onclick="switchStatusView('learning')" class="switch-btn active">📥 正在复习</button>387 <button id="sw-mastered" onclick="switchStatusView('mastered')" class="switch-btn">🏆 已斩杀</button>388 </div>389 </div>390 <div id="cards-container" class="cards-scroll-area"></div>391 </div>392 </div>393 394 <script>395 let currentTag = "全部"; let currentStatus = "learning";396 397 document.addEventListener('paste', async (e) => {398 const items = e.clipboardData.items;399 for (let i = 0; i < items.length; i++) {400 if (items[i].type.indexOf("image") !== -1) {401 const file = items[i].getAsFile();402 const formData = new FormData(); formData.append("file", file, "clipboard.png");403 triggerDirectScan(formData);404 }405 }406 });407 408 async function uploadAndScanFile() {409 const fileInput = document.getElementById('file-scanner');410 if (fileInput.files.length === 0) return;411 const formData = new FormData(); formData.append("file", fileInput.files[0]);412 triggerDirectScan(formData);413 }414 415 async function triggerDirectScan(formData) {416 const loading = document.getElementById('scan-loading');417 const hollow = document.getElementById('hollow-pills');418 loading.style.display = 'inline';419 try {420 const res = await fetch('/api/洞察词汇/文件扫描', { method: 'POST', body: formData });421 const data = await res.json();422 if(data.words && data.words.length > 0) {423 hollow.innerHTML = data.words.map(w => `<span class="word-pill" onclick="claimWord('${w}')">${w}</span>`).join('');424 } else { hollow.innerHTML = `<span style="font-size:11px; color:#a49070;">未提取到显著生词。</span>`; }425 } catch (e) { alert('文件扫描网络异常'); } finally { loading.style.display = 'none'; }426 }427 428 function claimWord(word) {429 document.getElementById('in-word').value = word;430 document.getElementById('in-hint').value = "";431 window.scrollTo({ top: 0, behavior: 'smooth' });432 }433 434 async function runAIAssistant() {435 const wordInput = document.getElementById('in-word');436 const hintInput = document.getElementById('in-hint');437 const btn = document.getElementById('btn-ai');438 let cleanWord = wordInput.value.replace(/\[.*?\]\(.*?\)/g, "").replace(/[\[\]\(\)]/g, "").trim();439 wordInput.value = cleanWord;440 441 if(!cleanWord) { alert('请输入生词!'); return; }442 btn.innerText = "🍃 检索中..."; btn.disabled = true;443 444 try {445 const res = await fetch('/api/ai-generate', {446 method: 'POST', headers: {'Content-Type': 'application/json'},447 body: JSON.stringify({ word: cleanWord, hint_sentence: hintInput.value.trim() })448 });449 const data = await res.json();450 if (data.error_type) { alert(data.msg); return; }451 452 document.getElementById('in-furi').value = data.furigana || '';453 document.getElementById('in-zh').value = data.meaning_zh || '';454 document.getElementById('in-ja').value = data.meaning_ja || '';455 document.getElementById('in-source').value = data.context_source || '通用';456 document.getElementById('in-sentence').value = data.example_sentence || '';457 458 let tag = data.tags || '日常';459 if(tag.includes("常") || tag.includes("活")) tag = "日常";460 if(tag.includes("剧") || tag.includes("漫")) tag = "日剧";461 document.getElementById('in-tags').value = tag;462 } catch(e) { alert('网络通信异常'); } finally { btn.innerText = "🪄 唤醒 AI 智能解析填表"; btn.disabled = false; }463 }464 465 async function loadCards() {466 const res = await fetch(`/api/cards?tag=${encodeURIComponent(currentTag)}&filter_status=${currentStatus}`);467 const cards = await res.json();468 renderTagBar(["全部", "日常", "日剧"]); renderCards(cards);469 }470 471 function renderTagBar(tags) {472 document.getElementById('tag-bar').innerHTML = tags.map(tag => `473 <button onclick="switchTag('${tag}')" class="tag-btn ${currentTag === tag ? 'active' : ''}">${tag}</button>474 `).join('');475 }476 function switchTag(tag) { currentTag = tag; loadCards(); }477 function switchStatusView(status) {478 currentStatus = status;479 document.getElementById('sw-learning').classList.toggle('active', status === 'learning');480 document.getElementById('sw-mastered').classList.toggle('active', status === 'mastered');481 loadCards();482 }483 484 // 🔴 完美修复核心:完全删除导致乱码叠影的“翻牌/返回”文字,引入 55px padding-right 强制物理隔离线485 function renderCards(cards) {486 const container = document.getElementById('cards-container');487 if(cards.length === 0) { container.innerHTML = `<div style="text-align:center; padding:40px; color:var(--text-sub);">空空如也。</div>`; return; }488 489 container.innerHTML = cards.map(c => `490 <div class="card-perspective">491 <div class="card-rotator" onclick="toggleCardFlip(event, this)">492 <div class="face-front" style="display:flex; flex-direction:column; justify-content:space-between;">493 <div>494 <span style="font-size:10px; color:var(--forest-leaf); background:#e9f0ed; padding:1px 6px; border-radius:4px; font-weight:bold;">${c.context_source}</span>495 </div>496 <div style="margin: 4px 0; display:flex; align-items:baseline; gap:8px;">497 <div style="font-size: 19px; font-weight: bold; color: var(--forest-dark);">${c.word}</div>498 <div style="font-size: 12px; color: var(--forest-leaf);">[${c.furigana}]</div>499 </div>500 <div style="font-size:11px; color:var(--text-sub); border-top: 1px dotted #e2e9e6; padding-top:4px; line-height:1.4; word-break:break-all;">501 ${c.example_sentence}502 </div>503 </div>504 <div class="face-back" style="display:flex; flex-direction:column; justify-content:space-between;">505 <div>506 <span style="font-size:11px; font-weight:bold; color:var(--forest-leaf);">${c.word}</span>507 </div>508 ${c.meaning_ja ? `<div style="font-size:10px; color:var(--text-main); background:#f0f4f2; padding:3px 6px; border-radius:4px; line-height:1.3; margin: 2px 0;">${c.meaning_ja}</div>` : ''}509 <div style="font-size:12px; color:var(--text-sub); font-weight:bold; border-top: 1px dashed #d3ded9; padding-top:4px; word-break:break-all;">510 中文:<span style="color:#335c4b; font-weight:normal;">${c.meaning_zh}</span>511 </div>512 </div>513 </div>514 <div class="card-actions">515 <button onclick="enterEditMode(${JSON.stringify(c).replace(/"/g, '"')})" class="mini-btn">✏️</button>516 ${c.status === 'learning' ? 517 `<button onclick="changeStatus(${c.id}, 'mastered')" class="mini-btn" style="color:var(--success)">🌱</button>` : 518 `<button onclick="changeStatus(${c.id}, 'learning')" class="mini-btn">🍂</button>`519 }520 <button onclick="deleteCard(${c.id})" class="mini-btn del">🗑️</button>521 </div>522 </div>523 `).join('');524 }525 526 function toggleCardFlip(e, element) {527 if(e.target.tagName.toLowerCase() === 'button' || e.target.classList.contains('mini-btn')) return;528 element.classList.toggle('is-flipped');529 }530 531 function enterEditMode(card) {532 document.getElementById('panel-title').innerText = "✏️ 正在修改生词卡片";533 document.getElementById('in-id').value = card.id;534 document.getElementById('in-word').value = card.word;535 document.getElementById('in-furi').value = card.furigana;536 document.getElementById('in-zh').value = card.meaning_zh;537 document.getElementById('in-ja').value = card.meaning_ja;538 document.getElementById('in-source').value = card.context_source;539 document.getElementById('in-sentence').value = card.example_sentence;540 document.getElementById('in-tags').value = card.tags;541 542 document.getElementById('btn-cancel-edit').style.display = 'block';543 document.getElementById('btn-save').innerText = "💾 保存修改并更新";544 window.scrollTo({ top: 0, behavior: 'smooth' });545 }546 547 function cancelEditMode() {548 document.getElementById('panel-title').innerText = "🌲 生词捕获终端";549 document.getElementById('in-id').value = ""; document.getElementById('in-word').value = "";550 document.getElementById('in-hint').value = ""; document.getElementById('in-furi').value = "";551 document.getElementById('in-zh').value = ""; document.getElementById('in-ja').value = "";552 document.getElementById('in-source').value = ""; document.getElementById('in-sentence').value = ""; document.getElementById('in-tags').value = "";553 document.getElementById('btn-cancel-edit').style.display = 'none';554 document.getElementById('btn-save').innerText = "🌱 确认归档入库";555 }556 557 async function submitCard() {558 const idVal = document.getElementById('in-id').value;559 const card = {560 word: document.getElementById('in-word').value.trim(), furigana: document.getElementById('in-furi').value.trim(),561 meaning_zh: document.getElementById('in-zh').value.trim(), meaning_ja: document.getElementById('in-ja').value.trim(),562 context_source: document.getElementById('in-source').value.trim() || '通用', example_sentence: document.getElementById('in-sentence').value.trim(),563 tags: document.getElementById('in-tags').value.trim() || '日常'564 };565 if(idVal) card.id = parseInt(idVal);566 if(!card.word || !card.meaning_zh || !card.furigana) { alert('必填项不能为空!'); return; }567 const res = await fetch('/api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(card) });568 if(res.ok) { cancelEditMode(); loadCards(); }569 }570 571 async function deleteCard(id) {572 if(!confirm('确定要抹除这张卡片吗?')) return;573 const res = await fetch(`/api/cards/${id}`, { method: 'DELETE' });574 if(res.ok) loadCards();575 }576 577 async function changeStatus(id, newStatus) {578 const res = await fetch(`/api/cards/${id}/status`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ status: newStatus }) });579 if(res.ok) loadCards();580 }581 582 function exportToExcel() { window.location.href = '/api/cards/export'; }583 window.onload = loadCards;584 </script>585 </body>586 </html>587 """588 589 590if __name__ == "__main__":591 uvicorn.run(app, host="127.0.0.1", port=7860)