1167 lines
63 KiB
HTML
1167 lines
63 KiB
HTML
<!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"> </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"> </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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
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>
|