Files
2026-05-14 21:05:10 +00:00

1167 lines
63 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="CiBird">
<meta name="theme-color" content="#1c1c1e">
<title>CiBird | 词鸟</title>
<!-- 网页 favicon -->
<link rel="icon" type="image/png" href="/Cibird.png">
<!-- 苹果手机「添加到主屏幕」图标 -->
<link rel="apple-touch-icon" href="/Cibird.png">
<link rel="apple-touch-icon" sizes="152x152" href="/Cibird.png">
<link rel="apple-touch-icon" sizes="167x167" href="/Cibird.png">
<link rel="apple-touch-icon" sizes="180x180" href="/Cibird.png">
<style>
/* ── RESET & BASE ─────────────────────────────────────────── */
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;outline:none;-webkit-touch-callout:none;margin:0;padding:0;}
html,body{position:fixed;inset:0;width:100%;height:100%;background:#f5f5f7;}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Arial,sans-serif;background:#f5f5f7;display:flex;justify-content:center;align-items:flex-start;overflow:hidden;overscroll-behavior:none;-webkit-user-select:none;user-select:none;}
/* ── APP FRAME ────────────────────────────────────────────── */
.frame{width:100%;max-width:400px;height:100%;min-height:100%;background:#f5f5f7;position:relative;overflow:hidden;display:none;flex-direction:column;}
.frame.on{display:flex;}
/* ── ANIMATIONS ───────────────────────────────────────────── */
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes spin{100%{transform:rotate(360deg)}}
@keyframes sheetUp{from{transform:translateY(100%)}to{transform:translateY(0)}}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(0,0,0,.18)}70%{box-shadow:0 0 0 5px rgba(0,0,0,0)}}
/* ══════════════════════════════════════════════════════════
LOGIN
══════════════════════════════════════════════════════════ */
#vLogin{overflow-y:auto;background:#f5f5f7;-webkit-overflow-scrolling:touch;}
.lg-head{display:flex;flex-direction:column;align-items:center;padding-top:max(55px,env(safe-area-inset-top));padding-bottom:8px;}
.lg-icon{width:80px;height:80px;background:transparent;border-radius:20px;display:flex;align-items:center;justify-content:center;font-size:38px;margin-bottom:16px;box-shadow:0 8px 24px rgba(0,0,0,.15);overflow:hidden;}
.lg-name{font-size:26px;font-weight:700;color:#111;margin-bottom:6px;}
.lg-tag{font-size:13px;font-weight:500;color:#8e8e93;margin-bottom:5px;}
.lg-ver{font-size:11px;color:#aeaeb2;}
.lg-body{display:flex;flex-direction:column;align-items:center;padding-top:28px;}
.lg-slogan{font-size:13px;font-weight:500;color:#8e8e93;text-align:center;line-height:1.7;margin-bottom:24px;padding:0 40px;}
.ig{width:320px;position:relative;margin-bottom:14px;}
.ig input{width:100%;background:#fff;border:1px solid #d1d1d6;border-radius:14px;padding:14px 16px;font-size:15px;color:#111;font-weight:500;font-family:inherit;outline:none;transition:border-color .2s,box-shadow .2s;-webkit-user-select:text;user-select:text;}
.ig input:focus{border-color:#111;box-shadow:0 0 0 3px rgba(0,0,0,.06);}
.ig input::placeholder{color:#aeaeb2;font-weight:400;}
.eye{position:absolute;right:14px;top:50%;transform:translateY(-50%);cursor:pointer;font-size:16px;padding:4px;}
.lg-btn{width:320px;background:#111;color:#fff;border:none;border-radius:999px;padding:16px;font-size:16px;font-weight:600;cursor:pointer;margin-top:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);font-family:inherit;transition:transform .2s;}
.lg-btn:active{transform:scale(.96);}
.lg-err{font-size:12px;color:#ff3b30;font-weight:500;margin-top:10px;min-height:18px;text-align:center;}
.lg-foot{margin-top:auto;padding:28px 0 max(30px,env(safe-area-inset-bottom));display:flex;justify-content:center;width:100%;}
.lg-foot-tip{font-size:12px;font-weight:500;color:#8e8e93;}
/* ══════════════════════════════════════════════════════════
MAIN APP — 横向三栏
══════════════════════════════════════════════════════════ */
#vApp{background:#f5f5f7;}
/* 横向滑动容器 */
.h-track{width:100%;height:100%;overflow-x:auto;overflow-y:hidden;display:flex;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;-webkit-overflow-scrolling:touch;overscroll-behavior-x:none;}
.h-track::-webkit-scrollbar{display:none;}
.panel{width:100%;flex-shrink:0;height:100%;scroll-snap-align:start;display:flex;flex-direction:column;overflow:hidden;}
/* 底部导航点 */
.nav-dots{position:absolute;bottom:max(10px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);display:flex;gap:6px;z-index:100;pointer-events:none;}
.nav-dot{width:6px;height:6px;border-radius:50%;background:#d1d1d6;transition:all .25s;}
.nav-dot.on{background:#111;width:18px;border-radius:3px;}
/* 桌面左右箭头 */
.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:200;width:36px;height:36px;background:rgba(255,255,255,.92);border:1px solid #e5e5ea;border-radius:50%;display:none;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.1);transition:all .2s;backdrop-filter:blur(8px);}
.nav-arrow:hover{background:#fff;box-shadow:0 4px 16px rgba(0,0,0,.15);transform:translateY(-50%) scale(1.05);}
.nav-arrow:active{transform:translateY(-50%) scale(.95);}
.nav-arrow.left{left:-18px;}
.nav-arrow.right{right:-18px;}
.nav-arrow svg{width:14px;height:14px;stroke:#333;stroke-width:2.5;fill:none;stroke-linecap:round;stroke-linejoin:round;}
@media(hover:hover) and (pointer:fine){.nav-arrow{display:flex;}}
/* 时间/数字工具面板 */
.tool-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0 20px;}
.tool-tabs{display:flex;gap:8px;margin-bottom:16px;flex-shrink:0;}
.tool-tab{flex:1;background:#fff;border:1px solid #e5e5ea;border-radius:12px;padding:10px;text-align:center;font-size:13px;font-weight:600;color:#8e8e93;cursor:pointer;transition:all .2s;}
.tool-tab.on{background:#1c1c1e;color:#fff;border-color:#1c1c1e;}
.tool-tab:active{transform:scale(.97);}
.picker-area{background:#fff;border-radius:20px;border:1px solid #e5e5ea;overflow:hidden;flex-shrink:0;position:relative;}
.picker-row{display:flex;align-items:center;height:200px;position:relative;}
.picker-col{flex:1;height:200px;overflow-y:auto;scroll-snap-type:y mandatory;scrollbar-width:none;position:relative;}
.picker-col::-webkit-scrollbar{display:none;}
.picker-item{height:40px;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:600;color:#c7c7cc;scroll-snap-align:center;transition:color .15s,font-size .15s;-webkit-user-select:none;}
.picker-item.sel{color:#111;font-size:26px;}
.picker-sep{font-size:28px;font-weight:700;color:#ddd;padding:0 4px;flex-shrink:0;}
.picker-area::before,.picker-area::after{content:"";position:absolute;left:0;right:0;height:72px;z-index:10;pointer-events:none;}
.picker-area::before{top:0;background:linear-gradient(to bottom,#fff 0%,rgba(255,255,255,0) 100%);}
.picker-area::after{bottom:0;background:linear-gradient(to top,#fff 0%,rgba(255,255,255,0) 100%);}
.picker-line{position:absolute;left:16px;right:16px;top:50%;transform:translateY(-50%);height:40px;border-top:1px solid #e5e5ea;border-bottom:1px solid #e5e5ea;z-index:5;pointer-events:none;}
.result-card{background:#1c1c1e;border-radius:20px;padding:18px 20px;margin-top:14px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:transform .15s;flex-shrink:0;}
.result-card:active{transform:scale(.98);}
.result-left{flex:1;}
.result-en{font-size:17px;font-weight:700;color:#fff;line-height:1.5;}
.result-zh{font-size:12px;color:#666;margin-top:4px;}
.result-speak{width:40px;height:40px;background:rgba(255,255,255,.08);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;margin-left:12px;}
.num-input-wrap{background:#fff;border:2px solid #e5e5ea;border-radius:16px;display:flex;align-items:center;padding:0 16px;height:64px;transition:border-color .2s,box-shadow .2s;flex-shrink:0;}
.num-input-wrap:focus-within{border-color:#111;box-shadow:0 0 0 3px rgba(0,0,0,.05);}
.num-input{flex:1;border:none;background:transparent;font-size:28px;font-weight:700;color:#111;outline:none;font-family:inherit;-webkit-user-select:text;user-select:text;text-align:center;}
.num-input::placeholder{color:#d1d1d6;font-weight:400;font-size:22px;}
.num-clear{font-size:20px;color:#c7c7cc;cursor:pointer;padding:4px;}
.num-clear:active{color:#111;}
.num-examples{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;flex-shrink:0;}
.num-eg{background:#fff;border:1px solid #e5e5ea;border-radius:999px;padding:6px 14px;font-size:12px;font-weight:600;color:#555;cursor:pointer;transition:all .2s;-webkit-user-select:none;}
.num-eg:active{background:#1c1c1e;color:#fff;border-color:#1c1c1e;}
/* ── 通用面板头 ─────────────────────────────────────────── */
.phead{display:flex;align-items:center;justify-content:space-between;padding:max(18px,env(safe-area-inset-top)) 20px 10px 20px;flex-shrink:0;}
.phead-title{font-size:22px;font-weight:700;color:#111;letter-spacing:.2px;}
.phead-right{display:flex;align-items:center;gap:10px;}
.badge{font-size:11px;font-weight:600;color:#8e8e93;background:#e5e5ea;border-radius:999px;padding:3px 10px;}
.txt-btn{font-size:13px;font-weight:600;color:#8e8e93;background:none;border:none;cursor:pointer;font-family:inherit;}
.txt-btn:active{color:#111;}
/* ══════════════════════════════════════════════════════════
PANEL 1 — 主页 (打卡 + 今日金句)
══════════════════════════════════════════════════════════ */
#pHome{background:#f5f5f7;}
.home-scroll{flex:1;overflow-y:auto;padding:0 20px max(120px,env(safe-area-inset-bottom)) 20px;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
.home-scroll::-webkit-scrollbar{display:none;}
/* 今日金句卡 */
.quote-card{background:#1c1c1e;border-radius:20px;padding:20px;margin-bottom:14px;position:relative;overflow:hidden;cursor:pointer;transition:transform .2s;}
.quote-card:active{transform:scale(.98);}
.quote-label{font-size:10px;font-weight:700;color:#555;letter-spacing:.8px;text-transform:uppercase;margin-bottom:12px;display:flex;align-items:center;gap:6px;}
.quote-label::before{content:"";width:6px;height:6px;border-radius:50%;background:#fff;opacity:.3;flex-shrink:0;}
.quote-word{font-size:13px;font-weight:700;color:#a1a1a6;margin-bottom:6px;font-family:ui-monospace,monospace;}
.quote-en{font-size:16px;font-weight:600;color:#fff;line-height:1.5;margin-bottom:6px;font-style:italic;}
.quote-zh{font-size:12px;color:#666;line-height:1.5;}
.quote-date{position:absolute;bottom:14px;right:16px;font-size:10px;font-weight:600;color:#333;font-family:ui-monospace,monospace;letter-spacing:.5px;}
.quote-bg{position:absolute;right:-10px;bottom:-20px;font-size:100px;opacity:.03;pointer-events:none;line-height:1;}
/* 打卡卡片 */
.checkin-card{background:#fff;border-radius:20px;padding:18px;border:1px solid #e5e5ea;box-shadow:0 2px 8px rgba(0,0,0,.02);}
.ci-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;}
.ci-title{font-size:13px;font-weight:700;color:#111;}
.ci-stat{font-size:12px;font-weight:600;color:#8e8e93;}
.ci-total{font-size:24px;font-weight:700;color:#111;margin-bottom:2px;}
.ci-total-label{font-size:11px;color:#aeaeb2;font-weight:500;margin-bottom:16px;}
/* 打卡点阵 */
.dot-grid{display:grid;grid-template-columns:repeat(13,1fr);gap:4px;}
.dot{width:100%;aspect-ratio:1;border-radius:50%;background:#f0f0f5;transition:all .2s;}
.dot.done{background:#1c1c1e;animation:pulse 2s infinite;}
.dot.today{background:#1c1c1e;box-shadow:0 0 0 2px #fff,0 0 0 3px #1c1c1e;}
.dot.done.today{background:#1c1c1e;}
.ci-footer{margin-top:12px;display:flex;align-items:center;gap:6px;}
.ci-streak{font-size:11px;font-weight:600;color:#8e8e93;}
.ci-streak span{color:#111;}
/* ══════════════════════════════════════════════════════════
PANEL 2 — 词库
══════════════════════════════════════════════════════════ */
#pWords{background:#f5f5f7;}
/* 字母导航 */
.alpha-nav{display:flex;flex-wrap:wrap;gap:3px 1px;padding:6px 20px 8px;flex-shrink:0;}
.alpha-nav span{font-size:11px;font-weight:700;color:#aeaeb2;padding:4px 5px;border-radius:6px;cursor:pointer;transition:all .15s;letter-spacing:.2px;-webkit-user-select:none;}
.alpha-nav span:active{transform:scale(.85);}
.alpha-nav span.on{background:#1c1c1e;color:#fff;}
.alpha-all{font-size:10px!important;letter-spacing:.3px!important;}
/* 搜索 */
.search-wrap{background:#fff;border:1px solid #e5e5ea;border-radius:12px;display:flex;align-items:center;padding:0 12px;height:38px;margin:0 20px 12px;transition:border-color .2s,box-shadow .2s;flex-shrink:0;}
.search-wrap:focus-within{border-color:#111;box-shadow:0 0 0 3px rgba(0,0,0,.05);}
.search-wrap input{flex:1;border:none;background:transparent;outline:none;font-size:13px;color:#111;font-family:inherit;font-weight:500;-webkit-user-select:text;user-select:text;}
.search-wrap input::placeholder{color:#aeaeb2;font-weight:400;}
/* 词条列表 */
.word-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:0 20px max(120px,env(safe-area-inset-bottom)) 20px;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
.word-list::-webkit-scrollbar{display:none;}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;gap:12px;}
.empty-icon{font-size:48px;opacity:.4;}
.empty-t{font-size:16px;font-weight:600;color:#111;}
.empty-s{font-size:13px;color:#8e8e93;text-align:center;line-height:1.7;}
.wc{background:#fff;border-radius:16px;padding:13px 15px;border:1px solid #e5e5ea;cursor:pointer;transition:all .2s cubic-bezier(.25,.8,.25,1);flex-shrink:0;animation:fadeUp .3s ease both;}
.wc:active{transform:scale(.97);}
.wc-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
.wc-word{font-size:16px;font-weight:700;color:#111;display:flex;align-items:center;gap:0;}
.sdot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#333;margin-right:9px;flex-shrink:0;animation:pulse 2.5s infinite;}
.wc-r{display:flex;align-items:center;gap:7px;}
.wc-pos{font-size:10px;font-weight:600;color:#8e8e93;background:#f2f2f7;border-radius:5px;padding:2px 6px;}
.wc-ph{font-size:11px;color:#aeaeb2;font-family:ui-monospace,monospace;}
.wc-zh{font-size:12px;font-weight:500;color:#555;line-height:1.4;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin-bottom:2px;}
.wc-ex{font-size:11px;color:#aeaeb2;font-style:italic;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
/* FAB */
.fab{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:20px;width:52px;height:52px;background:#111;border:none;border-radius:999px;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.18);transition:transform .2s;z-index:50;}
.fab:active{transform:scale(.9);}
.fab svg{width:22px;height:22px;fill:#fff;}
/* ══════════════════════════════════════════════════════════
PANEL 3 — 必学
══════════════════════════════════════════════════════════ */
#pEssential{background:#f5f5f7;}
/* 分类 tabs 横向滑动 */
.cat-track{display:flex;gap:8px;overflow-x:auto;padding:0 20px 12px;scrollbar-width:none;flex-shrink:0;}
.cat-track::-webkit-scrollbar{display:none;}
.cat-tab{flex-shrink:0;background:#fff;border:1px solid #e5e5ea;border-radius:999px;padding:7px 16px;font-size:13px;font-weight:600;color:#8e8e93;cursor:pointer;transition:all .2s;white-space:nowrap;}
.cat-tab:active{transform:scale(.95);}
.cat-tab.on{background:#1c1c1e;color:#fff;border-color:#1c1c1e;}
/* 必学内容列表 */
.ess-list{flex:1;overflow-y:auto;padding:0 20px max(120px,env(safe-area-inset-bottom)) 20px;display:flex;flex-direction:column;gap:8px;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
.ess-list::-webkit-scrollbar{display:none;}
.ess-card{background:#fff;border-radius:16px;padding:14px 16px;border:1px solid #e5e5ea;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:all .2s;animation:fadeUp .3s ease both;}
.ess-card:active{transform:scale(.97);}
.ess-left{flex:1;}
.ess-en{font-size:16px;font-weight:700;color:#111;margin-bottom:3px;}
.ess-zh{font-size:12px;font-weight:500;color:#666;}
.ess-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px;margin-left:12px;}
.ess-note{font-size:10px;font-weight:600;color:#8e8e93;background:#f2f2f7;border-radius:6px;padding:2px 7px;max-width:80px;text-align:right;}
.ess-speak{font-size:18px;cursor:pointer;transition:transform .2s;color:#aeaeb2;}
.ess-speak:active{transform:scale(.85);}
.ess-loading{display:flex;align-items:center;gap:10px;padding:20px;color:#8e8e93;font-size:13px;font-weight:500;}
.mini-spin{width:16px;height:16px;border:2px solid #e5e5ea;border-top-color:#111;border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0;}
/* ══════════════════════════════════════════════════════════
DETAIL (竖向第二楼)
══════════════════════════════════════════════════════════ */
.detail-overlay{position:fixed;inset:0;z-index:300;display:none;flex-direction:column;background:#f5f5f7;max-width:400px;margin:0 auto;}
.detail-overlay.on{display:flex;animation:fadeUp .25s ease;}
.det-back{display:flex;align-items:center;padding:max(14px,env(safe-area-inset-top)) 20px 10px;cursor:pointer;flex-shrink:0;}
.det-back:active{opacity:.6;}
.back-circle{width:30px;height:30px;background:rgba(0,0,0,.07);border-radius:50%;display:flex;align-items:center;justify-content:center;margin-right:10px;}
.back-circle svg{width:12px;height:12px;}
.back-lbl{font-size:14px;font-weight:600;color:#8e8e93;}
.hero{background:#1c1c1e;border-radius:20px;margin:0 20px 14px;padding:22px 20px 18px;color:#f5f5f7;position:relative;overflow:hidden;flex-shrink:0;}
.hero::after{content:attr(data-w);position:absolute;right:-.05em;bottom:-.25em;font-size:5rem;font-weight:700;opacity:.06;letter-spacing:-.03em;pointer-events:none;line-height:1;text-transform:uppercase;color:#fff;}
.hero-word{font-size:36px;font-weight:700;letter-spacing:-.5px;line-height:1.1;margin-bottom:10px;}
.hero-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px;}
.hero-ph{font-size:13px;color:#8e8e93;font-family:ui-monospace,monospace;}
.hero-pos{font-size:10px;font-weight:600;color:#a1a1a6;background:rgba(255,255,255,.07);border:1px solid #333;border-radius:6px;padding:2px 8px;}
.speak-w{display:flex;align-items:center;gap:5px;background:rgba(255,255,255,.07);border:1px solid #333;border-radius:999px;padding:5px 12px;cursor:pointer;font-size:11px;font-weight:600;color:#a1a1a6;margin-left:auto;font-family:inherit;transition:all .2s;}
.speak-w:active{background:rgba(255,255,255,.15);color:#fff;}
.det-scroll{flex:1;overflow-y:auto;padding:0 20px;padding-bottom:max(110px,env(safe-area-inset-bottom));scrollbar-width:none;-webkit-overflow-scrolling:touch;}
.det-scroll::-webkit-scrollbar{display:none;}
.info-card{background:#fff;border-radius:16px;padding:16px;border:1px solid #e5e5ea;margin-bottom:10px;}
.info-lbl{font-size:11px;font-weight:600;color:#8e8e93;margin-bottom:10px;display:flex;align-items:center;letter-spacing:.4px;text-transform:uppercase;}
.info-lbl::before{content:"";width:6px;height:6px;border-radius:50%;background:#111;margin-right:8px;flex-shrink:0;}
.info-txt{font-size:15px;font-weight:500;color:#111;line-height:1.6;}
.ex-item{padding:9px 0;border-bottom:1px solid #f2f2f7;}
.ex-item:first-child{padding-top:0;}
.ex-item:last-child{border-bottom:none;padding-bottom:0;}
.ex-num{font-size:10px;font-weight:600;color:#aeaeb2;margin-bottom:4px;}
.ex-en{font-size:14px;font-weight:500;color:#111;font-style:italic;line-height:1.5;margin-bottom:3px;cursor:pointer;transition:opacity .2s;-webkit-user-select:none;}
.ex-en.speakable::before{content:"▷ ";}
.ex-en:active{opacity:.5;}
.ex-zh{font-size:12px;color:#666;line-height:1.5;}
.note-area{width:100%;border:none;background:transparent;font-size:14px;color:#111;outline:none;resize:none;min-height:70px;line-height:1.7;font-family:inherit;-webkit-user-select:text;user-select:text;}
.note-area::placeholder{color:#c7c7cc;}
.det-bar{position:absolute;bottom:0;left:0;right:0;padding:12px 20px max(34px,env(safe-area-inset-bottom));background:rgba(245,245,247,.96);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;gap:10px;border-top:1px solid rgba(0,0,0,.06);}
.act{flex:1;border-radius:999px;height:44px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;cursor:pointer;border:none;font-family:inherit;transition:all .2s;}
.act:active{transform:scale(.95);}
.act.lt{background:#e5e5ea;color:#111;}
.act.dk{background:#1c1c1e;color:#fff;}
.act.rd{background:#fff0ef;color:#ff3b30;}
/* ══════════════════════════════════════════════════════════
ADD SHEET
══════════════════════════════════════════════════════════ */
.sh-overlay{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:400;display:none;justify-content:flex-end;align-items:flex-end;backdrop-filter:blur(3px);}
.sh-overlay.on{display:flex;}
.sheet{background:#f5f5f7;border-radius:24px 24px 0 0;width:100%;max-width:400px;margin:0 auto;max-height:93vh;display:flex;flex-direction:column;animation:sheetUp .3s cubic-bezier(.34,1.4,.64,1);overflow:hidden;}
.sh-handle{width:36px;height:4px;background:#d1d1d6;border-radius:2px;margin:12px auto 8px;flex-shrink:0;}
.sh-head{display:flex;align-items:center;justify-content:space-between;padding:0 20px 14px;flex-shrink:0;border-bottom:1px solid #e5e5ea;}
.sh-title{font-size:17px;font-weight:700;color:#111;}
.sh-close{width:28px;height:28px;background:#e5e5ea;border-radius:50%;border:none;cursor:pointer;font-size:14px;color:#666;font-family:inherit;display:flex;align-items:center;justify-content:center;}
.sh-close:active{background:#d1d1d6;}
.sh-body{flex:1;overflow-y:auto;padding:16px 20px;scrollbar-width:none;-webkit-overflow-scrolling:touch;}
.sh-body::-webkit-scrollbar{display:none;}
.f-lbl{font-size:11px;font-weight:600;color:#8e8e93;margin-bottom:6px;display:block;letter-spacing:.4px;text-transform:uppercase;}
.f-g{margin-bottom:14px;}
.wrow{display:flex;gap:10px;align-items:flex-end;margin-bottom:14px;}
.wrow .f-g{flex:1;margin-bottom:0;}
.ti{width:100%;background:#fff;border:1px solid #e5e5ea;border-radius:12px;padding:12px 14px;font-size:15px;color:#111;font-weight:500;font-family:inherit;outline:none;transition:border-color .2s,box-shadow .2s;-webkit-user-select:text;user-select:text;}
.ti:focus{border-color:#111;box-shadow:0 0 0 3px rgba(0,0,0,.05);}
.ti::placeholder{color:#c7c7cc;font-weight:400;}
textarea.ti{resize:none;min-height:68px;line-height:1.6;}
.ai-btn{background:#1c1c1e;color:#fff;border:none;border-radius:12px;padding:0 16px;height:46px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;font-family:inherit;display:flex;align-items:center;gap:5px;flex-shrink:0;transition:all .2s;}
.ai-btn:active{transform:scale(.95);}
.ai-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;}
.ld-row{display:flex;align-items:center;gap:10px;padding:8px 0;}
.ld-txt{font-size:12px;font-weight:500;color:#8e8e93;}
.ai-card{background:#1c1c1e;border-radius:16px;padding:16px;margin-bottom:14px;border:1px solid #2c2c2e;display:none;}
.ai-card.on{display:block;animation:fadeUp .3s ease;}
.ai-sec{padding:8px 0;border-bottom:1px solid #2c2c2e;}
.ai-sec:first-child{padding-top:0;}
.ai-sec:last-child{border-bottom:none;padding-bottom:0;}
.ai-lbl{font-size:10px;font-weight:600;color:#555;margin-bottom:5px;letter-spacing:.4px;text-transform:uppercase;}
.ai-val{font-size:13px;font-weight:500;color:#f5f5f7;line-height:1.6;}
.ai-ex-en{font-size:13px;font-weight:500;color:#fff;font-style:italic;line-height:1.5;margin-bottom:3px;}
.ai-ex-zh{font-size:11px;color:#a1a1a6;line-height:1.5;}
.ai-meta{display:flex;gap:16px;}
.ai-mc{flex:1;}
.sh-foot{padding:12px 20px max(24px,env(safe-area-inset-bottom));display:flex;gap:10px;border-top:1px solid #e5e5ea;flex-shrink:0;background:#f5f5f7;}
.save-btn{flex:1;background:#111;color:#fff;border:none;border-radius:999px;padding:15px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;box-shadow:0 4px 16px rgba(0,0,0,.1);transition:all .2s;}
.save-btn:active{transform:scale(.96);}
.save-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
.cancel-btn{background:#e5e5ea;color:#111;border:none;border-radius:999px;padding:15px 20px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;}
.cancel-btn:active{background:#d1d1d6;transform:scale(.96);}
/* ── TOAST ─────────────────────────────────────────────── */
.toast{position:fixed;bottom:max(88px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%) translateY(10px);background:#1c1c1e;color:#f5f5f7;padding:10px 20px;border-radius:999px;font-size:13px;font-weight:600;opacity:0;transition:all .25s;z-index:999;pointer-events:none;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,.15);}
.toast.on{opacity:1;transform:translateX(-50%) translateY(0);}
</style>
</head>
<body>
<!-- ══ LOGIN ══ -->
<div class="frame on" id="vLogin">
<div class="lg-head">
<div class="lg-icon"><img src="/Cibird.png" style="width:100%;height:100%;object-fit:cover;border-radius:20px;"></div>
<div class="lg-name">CiBird</div>
<div class="lg-tag">词鸟 · 你的私人英语词库</div>
<div class="lg-ver">v2.0 · AI造句</div>
</div>
<div class="lg-body">
<div class="lg-slogan">专攻推特 · 游戏 · 日常生存英语<br>住在你自己的服务器,数据永远是你的</div>
<div class="ig"><input type="text" id="lgUser" placeholder="用户名" autocomplete="username"></div>
<div class="ig">
<input type="password" id="lgPass" placeholder="密码" autocomplete="current-password">
<div class="eye" onclick="togglePass()">👁</div>
</div>
<button class="lg-btn" onclick="doLogin()">进入词鸟</button>
<div class="lg-err" id="lgErr"></div>
</div>
<div class="lg-foot"><div class="lg-foot-tip">🔒 数据存储在你自己的 VPS</div></div>
</div>
<!-- ══ APP ══ -->
<div class="frame" id="vApp">
<div class="h-track" id="hTrack">
<!-- ─── PANEL 1: 主页 ─── -->
<div class="panel" id="pHome">
<div class="phead">
<div class="phead-title">🦜 词鸟</div>
<div class="phead-right">
<button class="txt-btn" onclick="doLogout()">退出</button>
</div>
</div>
<div class="home-scroll">
<!-- 今日金句 -->
<div class="quote-card" onclick="refreshQuote()">
<div class="quote-label">今日金句</div>
<div class="quote-word" id="qWord"></div>
<div class="quote-en" id="qEn">加载中…</div>
<div class="quote-zh" id="qZh"></div>
<div class="quote-date" id="qDate"></div>
<div class="quote-bg">🦜</div>
</div>
<!-- 打卡卡片 -->
<div class="checkin-card">
<div class="ci-head">
<div class="ci-title">📅 每日打卡</div>
<div class="ci-stat" id="ciStreak"></div>
</div>
<div class="ci-total" id="ciTotal">0</div>
<div class="ci-total-label">已学单词总数</div>
<div class="dot-grid" id="dotGrid"></div>
<div class="ci-footer">
<div class="ci-streak" id="ciFooter">点击今日金句可刷新</div>
</div>
</div>
</div>
</div>
<!-- ─── PANEL 2: 词库 ─── -->
<div class="panel" id="pWords">
<div class="phead">
<div class="phead-title">词库</div>
<div class="phead-right">
<span class="badge" id="wcBadge">0 词</span>
</div>
</div>
<div class="alpha-nav" id="alphaN">
<span class="alpha-all on" onclick="fAlpha(null,this)">ALL</span>
<span onclick="fAlpha('A',this)">A</span><span onclick="fAlpha('B',this)">B</span><span onclick="fAlpha('C',this)">C</span><span onclick="fAlpha('D',this)">D</span><span onclick="fAlpha('E',this)">E</span><span onclick="fAlpha('F',this)">F</span><span onclick="fAlpha('G',this)">G</span><span onclick="fAlpha('H',this)">H</span><span onclick="fAlpha('I',this)">I</span><span onclick="fAlpha('J',this)">J</span><span onclick="fAlpha('K',this)">K</span><span onclick="fAlpha('L',this)">L</span><span onclick="fAlpha('M',this)">M</span><span onclick="fAlpha('N',this)">N</span><span onclick="fAlpha('O',this)">O</span><span onclick="fAlpha('P',this)">P</span><span onclick="fAlpha('Q',this)">Q</span><span onclick="fAlpha('R',this)">R</span><span onclick="fAlpha('S',this)">S</span><span onclick="fAlpha('T',this)">T</span><span onclick="fAlpha('U',this)">U</span><span onclick="fAlpha('V',this)">V</span><span onclick="fAlpha('W',this)">W</span><span onclick="fAlpha('X',this)">X</span><span onclick="fAlpha('Y',this)">Y</span><span onclick="fAlpha('Z',this)">Z</span>
</div>
<div class="search-wrap">
<input type="text" placeholder="搜索单词或释义…" id="searchIn" oninput="onSearch()">
</div>
<div class="word-list" id="wordList"></div>
<button class="fab" onclick="openAdd()">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
</div>
<!-- ─── PANEL 3: 练习 ─── -->
<div class="panel" id="pEssential">
<div class="phead">
<div class="phead-title">练习</div>
<div class="phead-right"><span class="badge">互动</span></div>
</div>
<!-- Tab 横向滚动条 -->
<div class="cat-track" id="toolTabTrack">
<div class="cat-tab on" id="tabTime" onclick="switchTool('time')">🕐 时间</div>
<div class="cat-tab" id="tabNum" onclick="switchTool('num')">🔢 数字</div>
<div class="cat-tab" id="tabCountry" onclick="switchTool('country')">🌍 国家</div>
<div class="cat-tab" id="tabMonth" onclick="switchTool('month')">📅 月</div>
<div class="cat-tab" id="tabWeek" onclick="switchTool('week')">📆 周</div>
<div class="cat-tab" id="tabAnimal" onclick="switchTool('animal')">🐾 动物</div>
<div class="cat-tab" id="tabFood" onclick="switchTool('food')">🍽️ 食物</div>
<div class="cat-tab" id="tabJob" onclick="switchTool('job')">💼 职业</div>
</div>
<!-- 时间工具 -->
<div class="tool-wrap" id="toolTime">
<div class="picker-area">
<div class="picker-line"></div>
<div class="picker-row">
<div class="picker-col" id="pcHour"></div>
<div class="picker-sep">:</div>
<div class="picker-col" id="pcMin"></div>
<div class="picker-sep" style="font-size:14px;color:#aeaeb2;writing-mode:vertical-lr;transform:rotate(180deg);padding:0 8px;"></div>
<div class="picker-col" id="pcAmpm" style="max-width:70px;"></div>
</div>
</div>
<div class="result-card" onclick="speakResult('time')" id="timeResult">
<div class="result-left">
<div class="result-en" id="timeEn">选择时间后自动生成</div>
<div class="result-zh" id="timeZh"></div>
</div>
<div class="result-speak">🔊</div>
</div>
</div>
<!-- 数字工具 -->
<div class="tool-wrap" id="toolNum" style="display:none">
<div class="num-input-wrap">
<input class="num-input" type="number" id="numIn" placeholder="输入数字…" oninput="onNumInput()" inputmode="numeric">
<div class="num-clear" onclick="clearNum()"></div>
</div>
<div class="num-examples">
<div class="num-eg" onclick="setNum(42)">42</div>
<div class="num-eg" onclick="setNum(100)">100</div>
<div class="num-eg" onclick="setNum(365)">365</div>
<div class="num-eg" onclick="setNum(1024)">1,024</div>
<div class="num-eg" onclick="setNum(99999)">99,999</div>
<div class="num-eg" onclick="setNum(1000000)">1M</div>
</div>
<div class="result-card" onclick="speakResult('num')" id="numResult" style="display:none">
<div class="result-left">
<div class="result-en" id="numEn"></div>
<div class="result-zh" id="numZh"></div>
</div>
<div class="result-speak">🔊</div>
</div>
</div>
<!-- 国家(AI接口)-->
<div class="ess-list" id="toolCountry" style="display:none"></div>
<!-- 静态词库面板(月/周/动物/食物/职业)-->
<div class="ess-list" id="toolMonth" style="display:none"></div>
<div class="ess-list" id="toolWeek" style="display:none"></div>
<div class="ess-list" id="toolAnimal" style="display:none"></div>
<div class="ess-list" id="toolFood" style="display:none"></div>
<div class="ess-list" id="toolJob" style="display:none"></div>
</div>
</div><!-- /h-track -->
<!-- 桌面导航箭头 -->
<button class="nav-arrow left" id="arrowL" onclick="arrowNav(-1)">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="nav-arrow right" id="arrowR" onclick="arrowNav(1)">
<svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg>
</button>
<!-- 底部导航点 -->
<div class="nav-dots">
<div class="nav-dot on" id="dot0"></div>
<div class="nav-dot" id="dot1"></div>
<div class="nav-dot" id="dot2"></div>
</div>
</div><!-- /vApp -->
<!-- ══ DETAIL OVERLAY ══ -->
<div class="detail-overlay" id="detOverlay">
<div class="det-back" onclick="closeDetail()">
<div class="back-circle">
<svg viewBox="0 0 24 24" fill="none" stroke="#666" stroke-width="3" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</div>
<span class="back-lbl">词库</span>
</div>
<div class="hero" id="detHero">
<div class="hero-word" id="dWord"></div>
<div class="hero-meta">
<span class="hero-ph" id="dPh"></span>
<span class="hero-pos" id="dPos"></span>
<button class="speak-w" onclick="speakWord()">▶ 发音</button>
</div>
</div>
<div class="det-scroll">
<div class="info-card">
<div class="info-lbl">中文释义</div>
<div class="info-txt" id="dMeaning"></div>
</div>
<div class="info-card">
<div class="info-lbl">例句</div>
<div id="dExamples"></div>
</div>
<div class="info-card">
<div class="info-lbl">我的笔记</div>
<textarea class="note-area" id="dNote" placeholder="在这里写下你对这个词的理解,在哪遇见的,怎么记住它…" onchange="saveNote()"></textarea>
</div>
</div>
<div class="det-bar">
<button class="act lt" onclick="regenEx()">✦ 重新造句</button>
<button class="act dk" onclick="closeDetail()">返回</button>
<button class="act rd" onclick="deleteWord()">删除</button>
</div>
</div>
<!-- ══ ADD SHEET ══ -->
<div class="sh-overlay" id="addSheet">
<div class="sheet">
<div class="sh-handle"></div>
<div class="sh-head">
<div class="sh-title">✦ 添加单词</div>
<button class="sh-close" onclick="closeAdd()"></button>
</div>
<div class="sh-body">
<div class="wrow">
<div class="f-g">
<label class="f-lbl">英文单词</label>
<input type="text" class="ti" id="inWord" placeholder="例如:savage" oninput="onWordIn()" autocomplete="off">
</div>
<button class="ai-btn" id="btnAI" onclick="generateAI()" disabled>✦ AI</button>
</div>
<div id="aiLoading" style="display:none">
<div class="ld-row"><div class="mini-spin"></div><span class="ld-txt">AI 正在造句,稍等…</span></div>
</div>
<div class="ai-card" id="aiCard">
<div class="ai-sec"><div class="ai-lbl">释义</div><div class="ai-val" id="rMeaning"></div></div>
<div class="ai-sec">
<div class="ai-meta">
<div class="ai-mc"><div class="ai-lbl">音标</div><div class="ai-val" id="rPh" style="font-family:ui-monospace,monospace;font-size:12px;"></div></div>
<div class="ai-mc"><div class="ai-lbl">词性</div><div class="ai-val" id="rPos" style="font-size:12px;color:#a1a1a6;"></div></div>
</div>
</div>
<div class="ai-sec"><div class="ai-lbl">例句①</div><div class="ai-ex-en" id="rE1en"></div><div class="ai-ex-zh" id="rE1zh"></div></div>
<div class="ai-sec"><div class="ai-lbl">例句②</div><div class="ai-ex-en" id="rE2en"></div><div class="ai-ex-zh" id="rE2zh"></div></div>
</div>
<div class="f-g">
<label class="f-lbl">个人笔记(可选)</label>
<textarea class="ti" id="inNote" placeholder="在哪遇见的?怎么记住它?"></textarea>
</div>
</div>
<div class="sh-foot">
<button class="cancel-btn" onclick="closeAdd()">取消</button>
<button class="save-btn" id="btnSave" onclick="saveWord()" disabled>保存到词库</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ═══════════════════════════
// STATE
// ═══════════════════════════
let token=null, words=[], curId=null, aiData=null, curAlpha=null, curCat=null;
// ═══════════════════════════
// AUTH
// ═══════════════════════════
async function doLogin(){
const u=document.getElementById('lgUser').value.trim();
const p=document.getElementById('lgPass').value;
const err=document.getElementById('lgErr');
if(!u||!p){err.textContent='请填写用户名和密码';return;}
try{
const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(!r.ok){err.textContent=d.detail||'用户名或密码错误';return;}
token=d.token; localStorage.setItem('cb_t',token); enterApp();
}catch(e){err.textContent='无法连接服务器';}
}
function doLogout(){
token=null; localStorage.removeItem('cb_t');
document.getElementById('vApp').classList.remove('on');
document.getElementById('vLogin').classList.add('on');
document.getElementById('lgPass').value='';
document.getElementById('lgErr').textContent='';
}
function togglePass(){const e=document.getElementById('lgPass');e.type=e.type==='password'?'text':'password';}
// ═══════════════════════════
// INIT
// ═══════════════════════════
window.addEventListener('DOMContentLoaded',async()=>{
['lgUser','lgPass'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();}));
document.getElementById('inWord').addEventListener('keydown',e=>{if(e.key==='Enter')generateAI();});
// 导航点监听
const track=document.getElementById('hTrack');
track.addEventListener('scroll',()=>{
const idx=Math.round(track.scrollLeft/track.offsetWidth);
[0,1,2].forEach(i=>document.getElementById('dot'+i).classList.toggle('on',i===idx));
},{passive:true});
const saved=localStorage.getItem('cb_t');
if(saved){token=saved;const ok=await loadWords();if(ok){enterApp();return;}token=null;localStorage.removeItem('cb_t');}
});
function enterApp(){
document.getElementById('vLogin').classList.remove('on');
document.getElementById('vApp').classList.add('on');
loadWords(); loadHome(); loadCategories();
// 默认停在中间词库面板
setTimeout(()=>goPanel(1),100);
}
function goPanel(idx){
const track=document.getElementById('hTrack');
track.scrollTo({left:idx*track.offsetWidth,behavior:'smooth'});
}
// ═══════════════════════════
// API HELPER
// ═══════════════════════════
function ah(){return{'Content-Type':'application/json','Authorization':'Bearer '+token};}
// ═══════════════════════════
// HOME: 金句 + 打卡
// ═══════════════════════════
async function loadHome(){
loadQuote(); loadCheckin();
}
async function loadQuote(){
try{
const r=await fetch('/api/daily-quote',{headers:ah()});
if(!r.ok)return;
const d=await r.json();
document.getElementById('qWord').textContent=d.word||'';
document.getElementById('qEn').textContent=d.sentence_en||'Keep learning!';
document.getElementById('qZh').textContent=d.sentence_zh||'';
document.getElementById('qDate').textContent=d.date||'';
}catch(e){}
}
async function refreshQuote(){
showToast('🦜 今日金句已固定,明天再来~');
}
async function loadCheckin(){
try{
const r=await fetch('/api/checkins',{headers:ah()});
if(!r.ok)return;
const d=await r.json();
document.getElementById('ciTotal').textContent=d.total_words||0;
// 生成最近 91 天(13列×7行)的点阵
const records={}; (d.records||[]).forEach(x=>records[x.date]=x.count);
const today=d.today||new Date().toISOString().slice(0,10);
const grid=document.getElementById('dotGrid');
grid.innerHTML='';
// 计算连续打卡天数
let streak=0;
for(let i=0;;i++){
const dt=dateOffset(today,-i);
if(records[dt]){streak++;}else break;
}
document.getElementById('ciStreak').textContent=streak?`🔥 连续 ${streak}`:'';
document.getElementById('ciFooter').textContent=streak?`太棒了,继续保持!`:'今天还没打卡,去学一个单词吧';
// 渲染 91 个点(从91天前到今天)
for(let i=90;i>=0;i--){
const dt=dateOffset(today,-i);
const dot=document.createElement('div');
dot.className='dot';
if(records[dt]) dot.classList.add('done');
if(dt===today) dot.classList.add('today');
dot.title=dt;
grid.appendChild(dot);
}
}catch(e){}
}
function dateOffset(base,offset){
const d=new Date(base+'T00:00:00Z');
d.setUTCDate(d.getUTCDate()+offset);
return d.toISOString().slice(0,10);
}
// ═══════════════════════════
// WORDS
// ═══════════════════════════
async function loadWords(){
try{
const r=await fetch('/api/words',{headers:ah()});
if(r.status===401)return false;
words=await r.json();
// examples字段从数据库出来是JSON字符串,需要parse
words=words.map(w=>{
try{w.examples=typeof w.examples==='string'?JSON.parse(w.examples):w.examples;}catch(e){w.examples=[];}
return w;
});
renderList(words); updateBadge(); return true;
}catch(e){return false;}
}
function renderList(list){
const el=document.getElementById('wordList');
if(!list.length){
el.innerHTML=`<div class="empty"><div class="empty-icon">🦜</div><div class="empty-t">词库还是空的</div><div class="empty-s">点右下角 添加你的第一个单词</div></div>`;
return;
}
el.innerHTML=list.map((w,i)=>`
<div class="wc" style="animation-delay:${Math.min(i*.04,.3)}s" onclick="showDetail(${w.id})">
<div class="wc-top">
<span class="wc-word"><span class="sdot"></span>${esc(w.word)}</span>
<div class="wc-r">
<span class="wc-ph">${esc(w.phonetic||'')}</span>
${w.pos?`<span class="wc-pos">${esc(w.pos)}</span>`:''}
</div>
</div>
<div class="wc-zh">${esc(w.meaning||'')}</div>
${w.examples&&w.examples[0]?(()=>{const e=w.examples[0];const t=typeof e==='string'?e:(e.en||'');return t?`<div class="wc-ex">${esc(t)}</div>`:''})():''}
</div>`).join('');
}
function updateBadge(){document.getElementById('wcBadge').textContent=words.length+' 词';}
let curAlphaEl=null;
function fAlpha(l,el){
curAlpha=l;
document.querySelectorAll('.alpha-nav span').forEach(s=>s.classList.remove('on'));
el.classList.add('on');
document.getElementById('searchIn').value='';
applyFilter();
}
function onSearch(){
curAlpha=null;
document.querySelectorAll('.alpha-nav span').forEach(s=>s.classList.remove('on'));
document.querySelector('.alpha-all').classList.add('on');
applyFilter();
}
function applyFilter(){
const q=document.getElementById('searchIn').value.toLowerCase().trim();
let res=words;
if(curAlpha) res=res.filter(w=>w.word.toUpperCase().startsWith(curAlpha));
if(q) res=res.filter(w=>w.word.toLowerCase().includes(q)||(w.meaning||'').toLowerCase().includes(q)||(w.note||'').toLowerCase().includes(q));
renderList(res);
}
// ═══════════════════════════
// DETAIL
// ═══════════════════════════
function showDetail(id){
const w=words.find(x=>x.id===id); if(!w)return;
curId=id;
document.getElementById('detHero').setAttribute('data-w',w.word.toUpperCase());
document.getElementById('dWord').textContent=w.word;
document.getElementById('dPh').textContent=w.phonetic||'';
document.getElementById('dPos').textContent=w.pos||'';
document.getElementById('dMeaning').textContent=w.meaning||'';
document.getElementById('dNote').value=w.note||'';
let ex=w.examples||[];
if(typeof ex==='string'){try{ex=JSON.parse(ex);}catch(e){ex=[];}}
const exEl=document.getElementById('dExamples');
exEl.innerHTML=!ex.length
?'<div style="font-size:12px;color:#aeaeb2;">暂无例句,点「重新造句」获取</div>'
:ex.map((e,i)=>{
// 兼容旧格式(纯字符串)和新格式({en,zh}对象)
const enTxt=typeof e==='string'?e:(e.en||'');
const zhTxt=typeof e==='string'?'':(e.zh||'');
return `<div class="ex-item"><div class="ex-num">例句 ${i+1}</div><div class="ex-en speakable" onclick="speakText(this)" data-t="${esc(enTxt)}">${esc(enTxt)}</div><div class="ex-zh">${esc(zhTxt)}</div></div>`;
}).join('');
document.querySelector('.det-scroll').scrollTop=0;
document.getElementById('detOverlay').classList.add('on');
}
function closeDetail(){document.getElementById('detOverlay').classList.remove('on');}
function speakWord(){
const w=document.getElementById('dWord').textContent;
if(!w||w==='—'||!window.speechSynthesis)return;
const u=new SpeechSynthesisUtterance(w);u.lang='en-US';u.rate=.82;window.speechSynthesis.speak(u);
}
function speakText(el){
const t=el.getAttribute('data-t'); if(!t||!window.speechSynthesis)return;
window.speechSynthesis.cancel();
const u=new SpeechSynthesisUtterance(t);u.lang='en-US';u.rate=.82;
el.style.opacity='.5';u.onend=()=>{el.style.opacity='1';};window.speechSynthesis.speak(u);
}
async function saveNote(){
const note=document.getElementById('dNote').value; if(!curId)return;
try{
await fetch('/api/words/'+curId+'/note',{method:'PATCH',headers:ah(),body:JSON.stringify({note})});
const w=words.find(x=>x.id===curId);if(w)w.note=note;
showToast('笔记已保存');
}catch(e){}
}
async function regenEx(){
const w=words.find(x=>x.id===curId);if(!w)return;
showToast('✦ AI 重新造句中…');
try{
const r=await fetch('/api/generate',{method:'POST',headers:ah(),body:JSON.stringify({word:w.word})});
const d=await r.json();if(!r.ok){showToast('AI 出错了');return;}
await fetch('/api/words/'+curId+'/examples',{method:'PATCH',headers:ah(),body:JSON.stringify({examples:d.examples||[]})});
w.examples=d.examples||[];showDetail(curId);showToast('✓ 例句已更新');
loadCheckin();
}catch(e){showToast('网络错误');}
}
async function deleteWord(){
const w=words.find(x=>x.id===curId);
if(!confirm('删除「'+(w?w.word:'这个单词')+'」?'))return;
try{
await fetch('/api/words/'+curId,{method:'DELETE',headers:ah()});
closeDetail();await loadWords();showToast('已删除');
}catch(e){showToast('删除失败');}
}
// ═══════════════════════════
// ADD
// ═══════════════════════════
function openAdd(){
aiData=null;
document.getElementById('inWord').value='';
document.getElementById('inNote').value='';
document.getElementById('aiCard').classList.remove('on');
document.getElementById('aiLoading').style.display='none';
document.getElementById('btnAI').disabled=true;
document.getElementById('btnSave').disabled=true;
document.getElementById('addSheet').classList.add('on');
setTimeout(()=>document.getElementById('inWord').focus(),200);
}
function closeAdd(){document.getElementById('addSheet').classList.remove('on');}
function onWordIn(){
const v=document.getElementById('inWord').value.trim();
document.getElementById('btnAI').disabled=!v;
if(!v){document.getElementById('aiCard').classList.remove('on');document.getElementById('btnSave').disabled=true;aiData=null;}
}
async function generateAI(){
const word=document.getElementById('inWord').value.trim();if(!word)return;
document.getElementById('aiLoading').style.display='block';
document.getElementById('aiCard').classList.remove('on');
document.getElementById('btnAI').disabled=true;document.getElementById('btnSave').disabled=true;aiData=null;
try{
const r=await fetch('/api/generate',{method:'POST',headers:ah(),body:JSON.stringify({word})});
const d=await r.json();if(!r.ok){showToast('AI 出错:'+(d.detail||'请重试'));return;}
aiData=d;
document.getElementById('rMeaning').textContent=d.meaning||'';
document.getElementById('rPh').textContent=d.phonetic||'';
document.getElementById('rPos').textContent=d.pos||'';
const e1=d.examples&&d.examples[0]||{},e2=d.examples&&d.examples[1]||{};
document.getElementById('rE1en').textContent=e1.en||'';document.getElementById('rE1zh').textContent=e1.zh||'';
document.getElementById('rE2en').textContent=e2.en||'';document.getElementById('rE2zh').textContent=e2.zh||'';
document.getElementById('aiCard').classList.add('on');document.getElementById('btnSave').disabled=false;
}catch(e){showToast('网络错误,请重试');}
finally{document.getElementById('aiLoading').style.display='none';document.getElementById('btnAI').disabled=false;}
}
async function saveWord(){
const word=document.getElementById('inWord').value.trim();if(!word||!aiData)return;
const note=document.getElementById('inNote').value.trim();
try{
const r=await fetch('/api/words',{method:'POST',headers:ah(),body:JSON.stringify({word,note,meaning:aiData.meaning||'',phonetic:aiData.phonetic||'',pos:aiData.pos||'',examples:aiData.examples||[]})});
if(!r.ok){showToast('保存失败');return;}
closeAdd();await loadWords();loadCheckin();showToast('✓ 已加入词库:'+word);
}catch(e){showToast('网络错误');}
}
document.getElementById('addSheet').addEventListener('click',e=>{if(e.target===document.getElementById('addSheet'))closeAdd();});
// ═══════════════════════════
// PANEL NAV ARROWS (desktop)
// ═══════════════════════════
let curPanel=1;
function arrowNav(dir){
curPanel=Math.max(0,Math.min(2,curPanel+dir));
goPanel(curPanel);
updateArrows();
}
function updateArrows(){
const l=document.getElementById('arrowL');
const r=document.getElementById('arrowR');
if(l){l.style.opacity=curPanel===0?'0.3':'1';l.style.pointerEvents=curPanel===0?'none':'auto';}
if(r){r.style.opacity=curPanel===2?'0.3':'1';r.style.pointerEvents=curPanel===2?'none':'auto';}
}
// 覆盖 enterApp 后调用
const _origGoPanel=goPanel;
// ═══════════════════════════
// TOOL: 时间 & 数字
// ═══════════════════════════
function initTools(){
// 确保时间面板用 flex 布局
const tw=document.getElementById('toolTime');
if(tw){tw.style.display='flex';tw.style.flexDirection='column';}
buildPicker('pcHour', Array.from({length:12},(_,i)=>String(i+1).padStart(2,'0')), 0);
buildPicker('pcMin', Array.from({length:60},(_,i)=>String(i).padStart(2,'0')), 0);
buildPicker('pcAmpm', ['AM','PM'], 0);
updateTimeResult();
}
function buildPicker(colId, items, initIdx){
const col=document.getElementById(colId);
if(!col)return;
// 上下各加3个空白撑高,让选中项能居中
const pad=3;
let html='';
for(let i=0;i<pad;i++) html+=`<div class="picker-item" style="visibility:hidden">&nbsp;</div>`;
items.forEach((v,i)=>{ html+=`<div class="picker-item" data-val="${v}">${v}</div>`; });
for(let i=0;i<pad;i++) html+=`<div class="picker-item" style="visibility:hidden">&nbsp;</div>`;
col.innerHTML=html;
// 滚到初始位置
col.scrollTop=initIdx*40;
highlightPicker(col);
// 滚动时更新高亮 + 结果
col.addEventListener('scroll',()=>{
highlightPicker(col);
if(colId==='pcHour'||colId==='pcMin'||colId==='pcAmpm') updateTimeResult();
},{passive:true});
}
function highlightPicker(col){
const items=[...col.querySelectorAll('.picker-item[data-val]')];
const center=col.scrollTop+col.offsetHeight/2;
let best=null, bestDist=Infinity;
items.forEach(item=>{
const dist=Math.abs(item.offsetTop+20-center);
if(dist<bestDist){bestDist=dist;best=item;}
});
items.forEach(i=>i.classList.remove('sel'));
if(best) best.classList.add('sel');
}
function getPickerVal(colId){
const col=document.getElementById(colId);
if(!col)return null;
return col.querySelector('.picker-item.sel')?.getAttribute('data-val')||null;
}
function updateTimeResult(){
const h=getPickerVal('pcHour');
const m=getPickerVal('pcMin');
const ap=getPickerVal('pcAmpm');
if(!h||!m||!ap)return;
const hNum=parseInt(h);
const mNum=parseInt(m);
const {en,zh}=timeToEnglish(hNum,mNum,ap);
document.getElementById('timeEn').textContent=en;
document.getElementById('timeZh').textContent=zh;
}
function timeToEnglish(h,m,ap){
const ones=['','one','two','three','four','five','six','seven','eight','nine',
'ten','eleven','twelve','thirteen','fourteen','fifteen',
'sixteen','seventeen','eighteen','nineteen'];
const tens=['','','twenty','thirty','forty','fifty'];
function sayNum(n){
if(n===0)return'';
if(n<20)return ones[n];
return tens[Math.floor(n/10)]+(n%10?'-'+ones[n%10]:'');
}
const hWord=ones[h]||sayNum(h);
const apWord=ap.toLowerCase();
let en='',zh='';
if(m===0){
en=`It's ${hWord} o'clock ${apWord}.`;
zh=`现在是${ap==='AM'?'上午':'下午'}${h}点整`;
} else if(m===15){
en=`It's a quarter past ${hWord} ${apWord}.`;
zh=`现在是${ap==='AM'?'上午':'下午'}${h}点一刻`;
} else if(m===30){
en=`It's half past ${hWord} ${apWord}.`;
zh=`现在是${ap==='AM'?'上午':'下午'}${h}点半`;
} else if(m===45){
const nextH=h===12?1:h+1;
en=`It's a quarter to ${ones[nextH]||sayNum(nextH)} ${apWord}.`;
zh=`现在差一刻${ap==='AM'?'上午':'下午'}${nextH}`;
} else if(m<30){
en=`It's ${sayNum(m)} past ${hWord} ${apWord}.`;
zh=`现在是${ap==='AM'?'上午':'下午'}${h}点过${m}`;
} else {
const left=60-m;
const nextH=h===12?1:h+1;
en=`It's ${sayNum(left)} to ${ones[nextH]||sayNum(nextH)} ${apWord}.`;
zh=`现在差${left}${ap==='AM'?'上午':'下午'}${nextH}`;
}
// 首字母大写已有 It's
return {en,zh};
}
function speakResult(type){
let text='';
if(type==='time') text=document.getElementById('timeEn').textContent;
if(type==='num') text=document.getElementById('numEn').textContent;
if(!text||!window.speechSynthesis)return;
window.speechSynthesis.cancel();
const u=new SpeechSynthesisUtterance(text);u.lang='en-US';u.rate=.78;window.speechSynthesis.speak(u);
}
// 所有工具面板 id 列表
const TOOL_IDS = ['time','num','country','month','week','animal','food','job'];
const TOOL_TAB_IDS = {
time:'tabTime', num:'tabNum', country:'tabCountry',
month:'tabMonth', week:'tabWeek', animal:'tabAnimal',
food:'tabFood', job:'tabJob'
};
// 静态词库已加载标记
const staticLoaded = {};
function switchTool(t){
TOOL_IDS.forEach(x=>{
const el=document.getElementById('tool'+x.charAt(0).toUpperCase()+x.slice(1));
if(el) el.style.display=(x===t?'':'none');
const tab=document.getElementById(TOOL_TAB_IDS[x]);
if(tab) tab.classList.toggle('on',x===t);
});
// 时间面板用 flex
if(t==='time'){
const tw=document.getElementById('toolTime');
if(tw) tw.style.display='flex';
tw.style.flexDirection='column';
}
// 按需加载
if(['country','month','week','animal','food','job'].includes(t) && !staticLoaded[t]) loadVocabStatic(t);
}
// 加载静态词库(国家/月/周/动物/食物/职业)
const STATIC_CAT = {country:'国家', month:'月', week:'周', animal:'动物', food:'食物', job:'职业'};
async function loadVocabStatic(key){
const el=document.getElementById('tool'+key.charAt(0).toUpperCase()+key.slice(1));
el.innerHTML='<div class="ess-loading"><div class="mini-spin"></div> 加载中…</div>';
try{
const r=await fetch('/api/static/'+encodeURIComponent(STATIC_CAT[key]),{headers:ah()});
const d=await r.json();
renderVocabList(el, d.items||[]);
staticLoaded[key]=true;
}catch(e){el.innerHTML='<div class="ess-loading">加载失败,请重试</div>';}
}
// 加载 AI 词库(国家)
async function loadVocabAI(key, cat){
const el=document.getElementById('tool'+key.charAt(0).toUpperCase()+key.slice(1));
el.innerHTML='<div class="ess-loading"><div class="mini-spin"></div> AI 加载中,稍等…</div>';
try{
const r=await fetch('/api/essentials/'+encodeURIComponent(cat),{headers:ah()});
const d=await r.json();
renderVocabList(el, d.items||[]);
staticLoaded[key]=true;
}catch(e){el.innerHTML='<div class="ess-loading">加载失败,请重试</div>';}
}
function renderVocabList(el, items){
if(!items.length){el.innerHTML='<div class="ess-loading">暂无数据</div>';return;}
el.innerHTML=items.map(i=>`
<div class="ess-card" onclick="speakVocab('${esc(i.en)}')">
<div class="ess-left">
<div class="ess-en">${esc(i.en)}</div>
<div class="ess-zh">${esc(i.zh)}</div>
</div>
<div class="ess-right">
<div class="ess-note">${esc(i.note||'')}</div>
<div class="ess-speak">🔊</div>
</div>
</div>`).join('');
}
function speakVocab(text){
if(!window.speechSynthesis)return;
window.speechSynthesis.cancel();
const u=new SpeechSynthesisUtterance(text);
u.lang='en-US';u.rate=0.82;
window.speechSynthesis.speak(u);
}
// 数字转英文
function numToWords(n){
if(isNaN(n))return'';
if(n===0)return'zero';
if(n<0)return'negative '+numToWords(-n);
const ones=['','one','two','three','four','five','six','seven','eight','nine',
'ten','eleven','twelve','thirteen','fourteen','fifteen',
'sixteen','seventeen','eighteen','nineteen'];
const tens=['','','twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety'];
function say(num){
if(num===0)return'';
if(num<20)return ones[num]+' ';
if(num<100)return tens[Math.floor(num/10)]+(num%10?'-'+ones[num%10]:'')+' ';
if(num<1000)return ones[Math.floor(num/100)]+' hundred '+(num%100?say(num%100):'');
if(num<1000000)return say(Math.floor(num/1000))+'thousand '+(num%1000?say(num%1000):'');
if(num<1000000000)return say(Math.floor(num/1000000))+'million '+(num%1000000?say(num%1000000):'');
return say(Math.floor(num/1000000000))+'billion '+(num%1000000000?say(num%1000000000):'');
}
return say(Math.floor(Math.abs(n))).trim().replace(/\s+/g,' ');
}
function onNumInput(){
const val=document.getElementById('numIn').value.trim();
const n=parseInt(val);
const card=document.getElementById('numResult');
if(!val||isNaN(n)||n<0||n>999999999){
card.style.display='none';return;
}
const words=numToWords(n);
// 首字母大写
const en=words.charAt(0).toUpperCase()+words.slice(1);
document.getElementById('numEn').textContent=en;
document.getElementById('numZh').textContent='数字 '+n.toLocaleString()+' 的英文读法';
card.style.display='flex';
}
function setNum(n){
document.getElementById('numIn').value=n;
onNumInput();
}
function clearNum(){
document.getElementById('numIn').value='';
document.getElementById('numResult').style.display='none';
}
// 旧接口兼容(enterApp 里调用)
function loadCategories(){ initTools(); }
// ═══════════════════════════
// UTILS
// ═══════════════════════════
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
let tt;
function showToast(msg){const el=document.getElementById('toast');el.textContent=msg;el.classList.add('on');clearTimeout(tt);tt=setTimeout(()=>el.classList.remove('on'),2200);}
// track scroll → 更新箭头方向
document.addEventListener('DOMContentLoaded',()=>{
const track=document.getElementById('hTrack');
if(track){
track.addEventListener('scroll',()=>{
curPanel=Math.round(track.scrollLeft/track.offsetWidth);
updateArrows();
},{passive:true});
}
updateArrows();
});
</script>
</body>
</html>