CoolFace
Apppublic

chemistrymaths-AI/jp-study-cards

sourceHugging Facemitupdated 3mo agoView on Hugging Face
0likes
app.py591 linesDownload Raw Back to root
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, '&quot;')})" 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)