Compare commits

..

71 Commits

Author SHA1 Message Date
Fam Zheng 0b87085ca9 music(pwa): 自家 icon — 黑底圆角 + 金黄八分音符
deploy music / build-and-deploy (push) Successful in 2m3s
2026-05-27 17:37:24 +01:00
Fam Zheng e99a032852 music(player): '仅看收藏' chip 跟 tag 排一起,不再推到最右
deploy music / build-and-deploy (push) Successful in 2m8s
2026-05-26 12:02:50 +01:00
Fam Zheng ae9c08aa35 music(player): '仅看收藏' 切换移到 filterbar(标签那行)
deploy music / build-and-deploy (push) Successful in 2m7s
2026-05-26 10:37:40 +01:00
Fam Zheng 089de84396 music(player): 侧边栏 ★ 标记 + sort-bar '仅看收藏' 切换
deploy music / build-and-deploy (push) Successful in 1m59s
2026-05-26 10:24:00 +01:00
Fam Zheng 83418c198f music(player): 收藏功能 — title 旁 ★/☆,收藏的曲目置顶
deploy music / build-and-deploy (push) Successful in 1m58s
2026-05-26 10:04:21 +01:00
Fam Zheng 0756362d14 music(player): sidebar 双击切歌并播放(单击只切换不打扰)
deploy music / build-and-deploy (push) Successful in 2m10s
2026-05-26 09:58:22 +01:00
Fam Zheng adbd259a32 music(perf): 切歌延迟修 — getAudioUrl 同步短路 + SW install 并发
deploy music / build-and-deploy (push) Successful in 2m4s
诊断:之前 loadPiece 链上加了 `audio.src = await getAudioUrl(...)`,await IDB
即使 cache disabled 也排队个 microtask;叠加 SW install 串行 23 个 fetch
让首次部署后明显卡。

修法:
- getAudioUrl 改同步:内存 blob 命中 / cache 关 → 立返;启用 cache 时内存没
  → 仍返网络 URL,后台 warm IDB 下次用
- audio.src = getAudioUrl(id) 不再 await,零等待
- SW install 改 cache.addAll 并发(HTTP/2 多路),失败回退串行
2026-05-26 09:37:24 +01:00
Fam Zheng 8991033f70 music(pwa): PWA + 可选离线缓存全库(IndexedDB),默认关
deploy music / build-and-deploy (push) Successful in 2m1s
- vite-plugin-pwa injectManifest 模式,自定义 sw.js precache app shell
- manifest 支持加桌面 + standalone(icon 暂借 werewolf 紫色调,后续换)
- src/lib/cache.js IDB 缓存层:audio + 谱面 PNG 单 attachment id 存放,blob URL 复用
- 启动 initCache 按 localStorage 'music.cache.enabled' 决定是否后台开始下载
- 后台 worker:串行 concurrency=2 + 80ms 间隔,仅 WiFi 时跑(默认)
- audio src 优先走 IDB blob URL,没缓存才走网络
- /settings 配置页:开关 + 仅 WiFi 切换 + 进度条 + 用量/quota + 清空缓存
- topbar 加 ⚙ 按钮

默认关,首次明确 prompt-by-checkbox 才开。整库 ~1.5GB。
2026-05-25 22:09:54 +01:00
Fam Zheng bcf99ec454 werewolf(pwa): 离线 PWA — 自定义 SW 预缓存 + 全屏进度条,牌图 21M→2.8M
deploy werewolf / build-and-deploy (push) Successful in 1m1s
- vite-plugin-pwa(injectManifest)自定义 SW:install 逐个抓取并向页面广播进度,
  cache-first 服务,导航离线回退 index.html,缓存版本随清单哈希自动淘汰旧缓存
- 全屏 modal 进度条(src/pwa.ts),反映首屏预缓存真实下载进度
- 牌图 mozjpeg 压缩 + 限长边 900px,每张 ≤200K(21.2MB→2.8MB)
- 生成 PWA 图标 + manifest + apple-touch meta,index.html 接入
- 新增脚本:npm run gen:icons / compress:images
2026-05-25 18:44:46 +01:00
Fam Zheng 1a62ec6658 music(player): 切歌不打扰 — 暂停状态切别的不自动开播 + tab 保持
deploy music / build-and-deploy (push) Successful in 2m0s
- loadPiece 进来先 snapshot wasPlaying / stickyTab
- 新 piece 也有同样 tab 就保持(chord/notes/简谱…),否则才回第一个
- 只有切歌前 audio 正在播才 .play();暂停 / 第一次进入 → 只 set src 等用户点 ▶
- 整理 notes / 看和弦谱的场景从此不会被切歌打断
2026-05-25 10:39:49 +01:00
Fam Zheng 915b91d986 write(ui): 对话框填满 + 🎙/发送按钮浮在 textarea 框内右下
deploy write / build-and-deploy (push) Successful in 1m26s
- input-row 改 position: relative + flex:1 撑满 input-bar 高度
- textarea width/height 100% 填满,右/下加 padding (130px / 14px) 给按钮腾位
- btn-tray absolute right:8 bottom:8,pointer-events: none 让 textarea 仍可点
- 按钮 height 40→32 更紧凑
2026-05-24 21:20:28 +01:00
Fam Zheng b2bec0406f write(ui): 全套 dark 主题,配色对齐 notes / cube portal
deploy write / build-and-deploy (push) Successful in 1m28s
- 引入 :root --bg/--bg-elev/--bg-card/--border/--text/--accent 等 vars(跟 notes 一致)
- 主要 surface:body / sidebar / editor / preview / input-bar / modal / mobile-bar
- accent 紫 (#c084fc + #7c5cbf) 替代 #5566ee 蓝
- preview markdown:h2-h4 紫,inline code amber,pre block 加 border
- splitter hover 用 accent-strong 紫
- 滚动条暗化
2026-05-24 17:45:26 +01:00
Fam Zheng 85b55f2243 write(ui): input-bar 高度也可拖拽(横向 splitter,row-resize 光标,60-window-200 px)
deploy write / build-and-deploy (push) Successful in 1m28s
2026-05-24 17:44:04 +01:00
Fam Zheng 027921de0c write(css): splitter 放 sidebar 后而不是前;display:none 的 mobile-bar 不占 grid cell 之前算错了顺序
deploy write / build-and-deploy (push) Successful in 1m35s
2026-05-24 17:24:13 +01:00
Fam Zheng b2d70b2491 write(css): editor-pane 加 grid-template-rows: auto 1fr,title-row 不再占 50%
deploy write / build-and-deploy (push) Successful in 1m52s
2026-05-24 17:20:36 +01:00
Fam Zheng 7b868852d2 write(ui): 三栏宽度可拖拽 + localStorage 持久化
deploy articulate / build-and-deploy (push) Successful in 1m15s
deploy cube / build-and-deploy (push) Successful in 1m40s
deploy karaoke / build-and-deploy (push) Successful in 1m6s
deploy llm-proxy / build-and-deploy (push) Successful in 2m3s
deploy music / build-and-deploy (push) Successful in 2m16s
deploy notes / build-and-deploy (push) Successful in 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 1m30s
deploy werewolf / build-and-deploy (push) Successful in 1m12s
deploy write / build-and-deploy (push) Successful in 1m54s
- sidebar | workspace 拖拽条:调整侧栏宽(180-500px)
- editor | preview 拖拽条:调整源码/预览比例(15%-85%)
- CSS var --sidebar-w / --editor-fr / --preview-fr 驱动 grid-template-columns
- 鼠标 down 开始 drag,move 实时算 px/dx,up 落盘 localStorage
- 移动端(<768px)自动隐藏拖拽条,回到 100% 切 tab 模式
2026-05-24 17:18:37 +01:00
Fam Zheng 9328c01c1b write: 进 cube 仓库 + 接 gitea CI 自动部署
deploy write / build-and-deploy (push) Failing after 4s
- 整 apps/write/ 进 git(含 frontend 源码 + Makefile + systemd unit + k8s service/ingress)
- .gitea/workflows/deploy-write.yml: act_runner fam 用户跑 host shell
    cargo build → npm build → install 到 ~/.local/bin/share/config →
    systemctl --user daemon-reload + restart → kubectl apply svc/ingress
- 前端 3 处"麻薯"字样去掉(思考中 / placeholder × 2)

注意 ~/.config/write/env 已有 passphrase,CI placeholder 逻辑会跳过不覆盖。
2026-05-24 17:16:44 +01:00
Fam Zheng f8a7f31427 notes(ui): 补 actions 按钮组 CSS(之前 commit 漏了 .action-btn 样式)
deploy notes / build-and-deploy (push) Successful in 1m49s
2026-05-18 01:51:51 +01:00
Fam Zheng 3f742352e2 notes(ui): 重跑/删除挪到标题右侧 actions 组,跟元数据分开排版
deploy notes / build-and-deploy (push) Successful in 2m4s
2026-05-18 01:51:12 +01:00
Fam Zheng 3e478228dd notes: done 状态也能 ↻ 重跑;有 transcript 自动跳过 ASR 只重跑 LLM
deploy notes / build-and-deploy (push) Successful in 1m40s
- 前端 retry 按钮去掉 status==failed 限制,总显示(中间态 disabled)
- backend process_recording 启动时看 transcript 有没有,有就直接 cleaning 起步,
  省 30 分钟录音那个 2-3 分钟的 ASR 切片串行
2026-05-18 01:44:11 +01:00
Fam Zheng e072109e91 notes: 加「 清理润色」block + 转写原文默认折叠
deploy notes / build-and-deploy (push) Successful in 1m47s
- backend: schema 加 cleaned 列;process_recording 流程
  ASR → cleaning (LLM 分段+去口语+润色+**加粗高亮**) → summarizing → done
- cleanup LLM 失败不阻塞,继续 summary
- 前端三 block 顺序:纪要 → 清理润色 → 原文(details 折叠)
- 新 status 'cleaning' 也加进 statusLabel / progressText
2026-05-18 01:22:33 +01:00
Fam Zheng ca11a9bda7 notes(asr): ffprobe duration=N/A 时回退用 ffmpeg null-muxer 解码统计
deploy notes / build-and-deploy (push) Successful in 1m58s
浏览器内 MediaRecorder 录的 webm/m4a 经常 metadata 没写 duration
(录到一半浏览器关掉 tab 没正常 finalize 文件)。ffprobe format.duration
返回 N/A。回退跑 `ffmpeg -i input -f null -`,从 stderr 最后一行
"time=HH:MM:SS.MS" parse 出实际秒数。慢一点但永远能拿到。
2026-05-18 00:40:23 +01:00
Fam Zheng a8e5100380 llm-proxy(ui): 修 placeholder token 泄漏 + UI 重做 + λ favicon
deploy llm-proxy / build-and-deploy (push) Successful in 1m46s
- 修:token 输入框 placeholder 之前硬编码了真实 token (`e.g.
  famzheng-llm-2026`),等于明文泄露。改成 `your auth token`
- UI 重做 — 100dvh 锁 viewport(处理移动软键盘)+ grid 布局
  让 thread 永远占中间、footer 永远贴底
- textarea autogrow(最高 200px,超出内部滚)
- 复制按钮 / smooth scroll-to-bottom (double rAF) /
  iOS momentum scroll / safe-area padding
- 错误状态独立 row,monospace + 红底
- λ favicon(紫蓝渐变 + 绿色在线点 + glow)— SVG include_str!
  进 binary,`/favicon.svg` + `/favicon.ico` 同源响应
2026-05-18 00:34:49 +01:00
Fam Zheng a5e97adf85 notes(ui): 加紫色渐变麦克风 favicon(含红色录音圆点)
deploy llm-proxy / build-and-deploy (push) Successful in 2m26s
deploy notes / build-and-deploy (push) Successful in 2m19s
2026-05-18 00:33:03 +01:00
Fam Zheng bcc8c3f484 notes: 启动时 resume 卡在 transcribing/summarizing/pending 的录音
deploy notes / build-and-deploy (push) Successful in 3m9s
pod 重启时 spawn 的 worker 进程内存丢,db 状态停留 → 死循环看不到进度。
启动加一段:扫 status IN (pending,transcribing,summarizing),重置成 pending,
逐个 spawn process_recording 重跑(ASR + LLM idempotent)。
2026-05-18 00:30:37 +01:00
Fam Zheng 1859512976 notes(ui): 选中录音同步到 URL ?id=N(可刷新/分享/前进后退)
deploy notes / build-and-deploy (push) Successful in 3m13s
2026-05-18 00:29:55 +01:00
Fam Zheng 857c0d5481 llm-proxy(app): gemma 反向代理 + token 鉴权 + /chat web UI
deploy articulate / build-and-deploy (push) Successful in 1m29s
deploy cube / build-and-deploy (push) Successful in 1m49s
deploy karaoke / build-and-deploy (push) Successful in 1m18s
deploy llm-proxy / build-and-deploy (push) Successful in 2m41s
deploy music / build-and-deploy (push) Successful in 3m6s
deploy notes / build-and-deploy (push) Successful in 2m40s
deploy simpleasm / build-and-deploy (push) Successful in 2m5s
deploy werewolf / build-and-deploy (push) Successful in 1m41s
新 service,ns `llm-proxy`,域 `llm.famzheng.me`。
- POST /v1/chat/completions — OpenAI 兼容透传到 mochi 同款 backend
  gateway (gemma-4-31b-it);一期强制 stream=false,SSE 留二期
- 鉴权: `Authorization: token <PROXY_AUTH_TOKEN>` 或同款 Bearer;
  常时间比较防 timing;空 expected 一律拒
- GET /chat — 自带极简 HTML chat UI(token 走 localStorage,
  附 curl example details);/ 跳转到 /chat
- Secrets `llm-proxy/proxy-credentials` 已 kubectl 手工创建:
  BACKEND_TOKEN (上游) + PROXY_AUTH_TOKEN (对外)
- 13 个 cargo test 覆盖 auth 多个 scheme / 边界 + body
  改写 (stream=false 强制注入)
2026-05-18 00:21:47 +01:00
Fam Zheng 34fa47f95f notes: 加重命名 — title 旁边 ✏️ 按钮 prompt 改名 (PATCH /api/recordings/:id)
deploy notes / build-and-deploy (push) Successful in 1m53s
2026-05-17 23:28:57 +01:00
Fam Zheng 674011ddf3 notes(feishu): 完整带 ~/.local/share/lark-cli/ 加密 token 进 sidecar
deploy notes / build-and-deploy (push) Successful in 2m4s
之前只 cp config.json (365B 索引),user OAuth token 实际加密存在
~/.local/share/lark-cli/{master.key, appsecret_*.enc, cli_*_*.enc}。
secret 改成捎带全部 4 个文件;initContainer cp 到 PVC 两个子目录;
sidecar mount /root/.lark-cli + /root/.local/share/lark-cli 两路。

server.py 撤回 --as user(带上 token 后能调 docs:document:create scope)。
2026-05-17 23:19:21 +01:00
Fam Zheng e7912f3547 notes(asr): LLM 顺手出会议标题,覆盖默认时间戳 title
deploy notes / build-and-deploy (push) Successful in 1m50s
prompt 加要求:第一行 `TITLE: <主题>` + `---` + 正文;backend parse
头两行,覆盖 recordings.title;summary 字段不含 title 行。
失败 fallback 不动 title。前端 sidebar/主视图自带 5s 轮询自动刷新。
2026-05-17 23:01:28 +01:00
Fam Zheng d964b46dbe cube(chat): apps.json 成 SSOT,注进 chatbot system prompt
deploy cube / build-and-deploy (push) Successful in 1m22s
前端 apps.ts 之前是 source of truth,后端 chatbot 只能用硬编码的
"werewolf / articulate / karaoke / music / simpleasm 等" 句式糊
弄 + 靠训练知识猜。改成 apps.json 当 SSOT:
- 前端 apps.ts 改为 import data from './apps.json'
- 后端 include_str! 同一份 → 解析渲染 markdown bullet 列表
  注进 system prompt,附带 slug / status / desc / url
- prompt 显式约束:只能基于列表事实回答,不存在的 app 直说没有
- 兜底:JSON 解析失败把 raw 文本喂 LLM,不让 chatbot 因为
  ssot 坏掉 500
- 10 个 cargo test(多覆盖 render_apps_list / prompt 含 apps)
2026-05-17 22:56:31 +01:00
Fam Zheng 1ee35b4d19 notes(asr): overlap 切片 + LLM 拼接去重
deploy notes / build-and-deploy (push) Successful in 2m7s
- ffmpeg 用 -ss/-t 顺序切 65s 段,stride 55s(10s overlap);单段 ≤70s 整段不切
- 串行喂外部 ASR 后,把全部 chunk_texts 喂一次 LLM 让它去重 + 修边界字
- 单段直接返回 naive,LLM 失败也 fallback naive,不卡流程
- sidecar 注入 LLM_GATEWAY/LLM_MODEL/LLM_TOKEN env
2026-05-17 22:47:06 +01:00
Fam Zheng 688ccdc76f notes(asr): 切片串行 ASR 绕单文件大小限制
deploy notes / build-and-deploy (push) Successful in 3m40s
ASR server 直接 500 拒绝大文件 (15MB / ~15min 4.7s 即返回 500),不是
处理超时。改成:sidecar 装 ffmpeg → /transcribe endpoint 把音频切 60s
段 → 串行调外部 ASR → 拼接 transcript。notes 主容器 call_asr 改成 POST
到 sidecar /transcribe(timeout 1h 给长录音留余地)。

- feishu sidecar Dockerfile + ffmpeg + requests
- server.py 加 TranscribeReq;fallback -c copy 失败时 re-encode AAC
- main.rs 删除 asr_url/asr_token 字段(now sidecar concern)
- k8s manifest: ASR_URL/ASR_TOKEN 从主容器移到 feishu sidecar env
2026-05-17 22:38:05 +01:00
Fam Zheng e5a87cc65f notes(feishu): lark-cli config 从 secret cp 到 PVC 子目录,可读可写 + 重启保留
deploy notes / build-and-deploy (push) Successful in 1m50s
initContainer cp /secrets/lark-cli/config.json → /data/lark-cli/config.json
(已存在不覆盖,保留运行时 refresh 过的 token);feishu sidecar 主容器
subPath mount data PVC 的 lark-cli/ 到 /root/.lark-cli,lark-cli 写 cache、
refresh 都落 PVC。
2026-05-17 22:28:19 +01:00
Fam Zheng e56e2138a8 notes(feishu): Dockerfile 加 curl(lark-cli npm postinstall 依赖)
deploy notes / build-and-deploy (push) Successful in 2m40s
2026-05-17 22:23:57 +01:00
Fam Zheng 68671784f6 notes: 加一键转飞书文档 (sidecar markdown-to-feishu)
deploy notes / build-and-deploy (push) Failing after 2m2s
- backend: POST /api/recordings/:id/feishu → 拼 markdown (总结在最上 + 附件链接到转录/录音 + 转写全文) → 写 /data/feishu-tmp/<id>/ → HTTP POST 到 feishu sidecar
- 复用:已有 feishu_doc_id 时 --update 同一个 doc,前端按钮文案变「↻ 重新生成」
- schema 加 feishu_doc_id + feishu_url 两列(ALTER TABLE 兼容旧 db)
- LLM prompt 改:行动项用 markdown checkbox `- [ ] 谁·做什么·何时`
- sidecar apps/notes/feishu: node:20 + python3 + python3-markdown + @larksuite/cli + COPY 自己的 markdown-to-feishu script + FastAPI /convert
- k8s: deployment 加 feishu container 共享 PVC;lark-cli-creds Secret 挂 /root/.lark-cli/config.json
- CI: 主 image --no-cache(cube 规矩),sidecar 保留 layer cache(chromium-free,但 apt/npm 也大)
- 前端: content 头部加「📤 一键转飞书文档」按钮;已转过显示飞书链接 + 按钮变重生成
2026-05-17 22:16:13 +01:00
Fam Zheng 3a34fbdfd8 notes(ui): polling 静默 refresh + 增量更新 list/selected,不再闪动
deploy notes / build-and-deploy (push) Successful in 1m50s
2026-05-17 22:08:42 +01:00
Fam Zheng eb7cd81395 notes: 回滚讲话人猜测 prompt,保持简单纪要格式
deploy notes / build-and-deploy (push) Successful in 1m58s
2026-05-17 22:08:08 +01:00
Fam Zheng 93039457a7 notes: title 全空时用「录音 YYYY-MM-DD HH:MM」;LLM 加猜讲话人 prompt
deploy notes / build-and-deploy (push) Successful in 1m43s
2026-05-17 22:03:20 +01:00
Fam Zheng 44652eb398 notes(record): 加浏览器内直接录音(绕 iOS 录音机 App 文件不可见)
deploy notes / build-and-deploy (push) Successful in 2m11s
- 「🎙️ 直接录」按钮:navigator.mediaDevices.getUserMedia({audio:true}) → MediaRecorder
- 录音中按钮变红 + 计时器 + 脉冲;点 ⏹ 停止自动上传
- mimeType 探测:Safari 用 audio/mp4,Chrome 优先 audio/webm/opus
- 文件名 录音-YYYY-MM-DD-HH-MM-SS.{m4a|webm}
- 原 + 文件 入口保留小型,作为电脑端兜底
2026-05-17 21:55:39 +01:00
Fam Zheng c2c0c6999d notes(ui): empty 提示箭头反过来
deploy notes / build-and-deploy (push) Successful in 2m19s
2026-05-17 21:53:39 +01:00
Fam Zheng 61abd3f560 notes: 新建 notes.famzheng.me — 录音 → ASR → LLM 会议纪要
deploy articulate / build-and-deploy (push) Successful in 1m21s
deploy cube / build-and-deploy (push) Successful in 1m44s
deploy karaoke / build-and-deploy (push) Successful in 1m13s
deploy music / build-and-deploy (push) Successful in 2m23s
deploy notes / build-and-deploy (push) Successful in 2m16s
deploy simpleasm / build-and-deploy (push) Successful in 1m44s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
- 后端 axum + sqlite (recordings 表):上传 multipart 流式落 PVC;spawn worker pending → transcribing (调 mochi 那边 ASR endpoint, fireredasr2 token, Whisper-style multipart) → summarizing (调 gemma-4-31b-it OpenAI 兼容接口) → done
- 鉴权 middleware:Authorization: token <PASSPHRASE>;audio 流播放 ?token= query 兜底;passphrase 走 k8s Secret 不写死
- 前端 Vue3:首次访问弹 passphrase modal;sidebar 录音列表(带状态 chip)+ content 选中显示音频 + 转写 + markdown 纪要;5s polling 进度
- k8s manifest: ns cube-notes / PVC 30Gi / Ingress notes.famzheng.me / bodylimit 600M;Secret notes-creds = {passphrase, asr_token, llm_token}
- portal apps.ts 加 notes entry
2026-05-17 21:43:44 +01:00
Fam Zheng 802d5beae9 cube(portal): 加 chatbot + create_issue tool
deploy articulate / build-and-deploy (push) Successful in 1m27s
deploy cube / build-and-deploy (push) Successful in 1m52s
deploy karaoke / build-and-deploy (push) Successful in 1m20s
deploy music / build-and-deploy (push) Successful in 2m29s
deploy simpleasm / build-and-deploy (push) Successful in 1m38s
deploy werewolf / build-and-deploy (push) Successful in 58s
入口页右下角浮动 chat — 走 mochi 同款 LLM gateway (gemma-4-31b-it),
单步 tool calling 实现 `create_issue` 调 gitea API 建 fam/cube issue。
LLM_API_TOKEN + GITEA_TOKEN 走 ns 内 secret `chat-credentials`
(kubectl 手工创建,不进 git);gateway URL / model / 仓库地址走 env。
8 个 cargo test 覆盖 prompt / tool schema / tool_call 解析 / 错误。
顺手 git rm --cached 之前漏掉的 tsbuildinfo(已 gitignore)。
2026-05-14 16:46:59 +01:00
Fam Zheng af697ea6d0 cube(portal): werewolf / articulate / karaoke 改 live
deploy cube / build-and-deploy (push) Successful in 58s
2026-05-14 16:11:11 +01:00
Fam Zheng bcdf6c6ba4 cube(portal): list werewolf / articulate / karaoke (pending)
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy articulate / build-and-deploy (push) Successful in 1m26s
deploy karaoke / build-and-deploy (push) Successful in 1m22s
deploy music / build-and-deploy (push) Successful in 2m32s
deploy simpleasm / build-and-deploy (push) Successful in 1m35s
deploy werewolf / build-and-deploy (push) Successful in 59s
三个 partiverse 移植 app 入口,先标 pending — CI 跑过 + k8s rollout
成功后再改 live。
2026-05-14 15:32:28 +01:00
Fam Zheng fbd6e3cb9c karaoke(app): port single-device playlist from partiverse + tests
点歌单本地管理 — 添加/上移/下移/置顶/删除 + 10 秒撤销倒计时 + YouTube 一键
搜,无 room / 无 ws。删掉了 partiverse 那套 yopu 和弦抓取 / LLM 聊天点歌 /
QR 码(依赖后端,对单机无意义)。logic 全 immutable,21 个 vitest 覆盖
边界(首位上移 noop / 末位下移 noop / 缺失 id / 不变性)。
2026-05-14 15:32:22 +01:00
Fam Zheng 78f84d4225 articulate(app): port single-device word game from partiverse + tests
中英猜词派对 — 选主题 / 难度 / 词数 → 大字模式描述给队友猜,无 room / 无 ws。
15 个 preset 主题(wordlists 已在 scaffold 时就位)+ 3 档难度 + 已看词跨场
记忆(localStorage,cap 5000)+ Enter/Space/Esc 键盘。pickWords 优先未看过
再 fallback 见过的。logic 层 24 个 vitest(解析 / 抽词 / 确定性 rng)。
2026-05-14 15:32:15 +01:00
Fam Zheng 0b22691b3d werewolf(app): port single-device dealer from partiverse + tests
单机发牌器 — 一台手机轮流传,无 room / 无 ws。30 个角色 + 4 档默认预设
(8/9/10/12 人) + 配置历史(dedup + cap 50)+ 4x 偏好加权 + swipe-to-reveal
+ tap-to-confirm + 3D card flip + 死亡标记,全部本地 localStorage。
RNG 注入,logic 层 29 个 vitest(含 2000 次蒙特卡洛验证偏好命中率 > 40%、
均匀分布 ±5%)。也把 *.tsbuildinfo 加进 .gitignore。
2026-05-14 15:31:58 +01:00
Fam Zheng cdbf8308d1 music(player): 变速播放 + AB Loop
deploy articulate / build-and-deploy (push) Failing after 1m42s
deploy cube / build-and-deploy (push) Successful in 2m5s
deploy karaoke / build-and-deploy (push) Failing after 2m2s
deploy simpleasm / build-and-deploy (push) Successful in 2m21s
deploy music / build-and-deploy (push) Successful in 4m2s
deploy werewolf / build-and-deploy (push) Failing after 58s
- 变速:底部 1× 圆形按钮循环切 0.5/0.75/1/1.25/1.5;preservesPitch=true(浏览器 native 保音高);localStorage 持久化全局
- AB Loop:A B 两按钮在当前位置打点,🔁 开关;进度条上绿色高亮 A↔B 区段;timeupdate 触发 ≥B 跳回 A;切歌自动清 A/B
2026-05-10 21:40:19 +01:00
Fam Zheng 5674be1cfd music(ui): 简化只留「和弦谱」一个抓取 tab,简谱/字母版废弃
deploy music / build-and-deploy (push) Successful in 1m54s
2026-05-10 21:32:49 +01:00
Fam Zheng e5f3a95aa9 music(ui): 统一命名 — 和弦谱(字母版) / 简谱(级数版)
deploy music / build-and-deploy (push) Successful in 1m54s
2026-05-10 16:09:56 +01:00
Fam Zheng 5c0d860666 cube(portal): guitar → music (live)
deploy cube / build-and-deploy (push) Successful in 53s
2026-05-10 15:54:53 +01:00
Fam Zheng 26b99d7405 fix(inspire): placeholder 里的中文弯引号会被 vue parser 当 attribute 边界
deploy music / build-and-deploy (push) Successful in 1m54s
2026-05-10 15:52:34 +01:00
Fam Zheng ccb5ad05ce music(inspire): 加「💡 今天练什么」灵感推荐 modal
deploy music / build-and-deploy (push) Failing after 1m50s
- 后端 POST /api/inspire 流式 SSE:随机 keyword 池(23 个)+ 用户曲库画像(recent/top/least)+ Tavily 热点搜索 → gemma stream(temperature=1.0)
- Tavily key 走 k8s Secret tavily-creds(复用 mochi config 同一 token)
- 每次按按钮:keyword 随机 + 用户可输 hint("想练快歌" / "陪儿子" / "新东西")
- 输出强制格式:4 首歌('补回来' 2 + '试试新' 2),每首歌名-歌手 + 一句理由
- 前端 topbar 加 💡 按钮,modal 流式渲染(极简 md:**bold** + 列表)
2026-05-10 15:52:00 +01:00
Fam Zheng f7fac352a5 music(player): 加音量条 + 静音按钮(localStorage 持久化)
deploy music / build-and-deploy (push) Successful in 1m48s
2026-05-10 15:39:30 +01:00
Fam Zheng 9ce3b66810 ci(music): docker build --no-cache 主 image,根治 binary stale
deploy music / build-and-deploy (push) Successful in 1m44s
之前 cargo build 有跑出新 musl binary 但 docker build 时
'Step 2/5 : COPY target/.../music — Using cache' 命中旧 layer,
把历史 binary 套进新 sha tag 的 image。结果 main.rs 改动悄悄丢失。

回退之前迂回的 cargo clean -p / rm binary(治不到 docker 那层),
直接给主 image 加 --no-cache。chord-fetcher 那个 sidecar 保留 cache(chromium apt 拉一次 200MB+,每次重 build 太慢)。
2026-05-10 15:23:37 +01:00
Fam Zheng 9640abe102 ci(music): cargo clean -p 强制每次 link,act_runner workdir 复用导致 main.rs 改动不生效
deploy music / build-and-deploy (push) Successful in 1m46s
2026-05-10 15:18:16 +01:00
Fam Zheng fd80116168 music(chord): 拆两个 tab + 抓两种 (letters/functional)
deploy music / build-and-deploy (push) Successful in 1m54s
- yopu 切 /song?title=&artist= 搜索(避免歌手词被搜糊)
- 抓的版本按搜索结果 nier-snippet svg <text> 数区分:
  >0 = 字母谱 (G/Em7/C 弹唱谱);==0 = 功能谱 (1/4/5/6m 数字级数)
- sidecar fetch/status/state/image 都走 (id, mode) 维度,文件落 /data/chord-fetch/{id}-{mode}.png
- backend chord_fetch / chord_status 接 ?mode=letters|functional,import 时 role 分别为 chord_letters / chord_functional
- 前端 chord tab 拆「吉他谱」+「功能谱」,state/error/poll 各自独立;旧 role='chord' 显示在「吉他谱」兼容历史 import
- verified 标记探测:匿名访问 yopu HTML 里 0 hits(要登录可见),暂时只能按 svg_text 区分
2026-05-10 15:10:03 +01:00
Fam Zheng f836c8dab7 music: 乐谱图点击全屏(再点 / ESC 退出)
deploy music / build-and-deploy (push) Successful in 1m48s
2026-05-10 14:59:07 +01:00
Fam Zheng eed5e88dc0 music(chat): 去掉麻薯人格 prompt,只注入曲目 context
deploy music / build-and-deploy (push) Successful in 1m44s
2026-05-10 14:57:36 +01:00
Fam Zheng c0d6e37325 music: 加 LLM chat、笔记 tab 化、歌单/标签
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy music / build-and-deploy (push) Successful in 2m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m25s
chat(右边栏):
- chat_messages 表 per piece,OpenAI 兼容 /v1/chat/completions stream:true
- backend SSE forward delta,结束时落库 user + assistant
- system prompt 注入曲目 (title/artist/category/notes/lyrics 截 4KB)
- 网关同 mochi/config.yaml: gemma-4-31b-it on 3.135.65.204:8848,token 走 k8s Secret chat-creds
- reqwest client 去掉全局 timeout(chat 流可能跑很久),chord sidecar 调用改 per-request timeout

笔记: 从右 sidebar 移到独立 tab "笔记"

歌单 + tag:
- playlists / playlist_pieces / tags / piece_tags 表,CRUD API
- PATCH piece 接 tags 数组(按名字 upsert)
- list pieces 加 ?tag/?playlist 过滤 + 返回 tags 列表
- 顶 bar filterbar:歌单 + 标签 chip 切换;"+ 新歌单" prompt 创建
- EditView 加 tag 编辑(chip + 自动补全)+ 加入/移除歌单
2026-05-10 14:51:53 +01:00
Fam Zheng 9623e298b7 music(chord): 关掉 row 切换的 dump + 噪音 log,搜索阶段选功能谱已经够
deploy music / build-and-deploy (push) Successful in 1m57s
2026-05-09 23:19:16 +01:00
Fam Zheng ceaa2cc839 music(chord): 选搜索结果里的功能谱(数字级数版本),不要字母谱
deploy music / build-and-deploy (push) Successful in 1m50s
yopu 搜索结果同一首歌通常有多个版本,区分方式:
- 字母谱:nier-snippet 里 SVG <text> 渲染 chord 字母(G/Em7/C 等)
- 功能谱:nier-snippet 里没 SVG <text>,直接 HTML/CSS 显示 1/4/5/6m

按 svgTextCount === 0 优先选第一个功能谱,没功能谱才 fallback 到字母谱。
view 页里没有「谱面样式」「和弦样式」row(要登录 APP 才有),所以这是唯一可行路径。

实测 独家记忆/倔强/Casablanca 三首都拿到正确的功能谱截图。
2026-05-09 23:15:41 +01:00
Fam Zheng 05df371435 music(chord): yopu UI 升级修 selector + 加 PVC override 与调试 dump
deploy music / build-and-deploy (push) Successful in 1m59s
- yopu 现在搜索结果默认全是和弦谱(不再标「和弦谱」字样),改成直接取第一个 a.post-main
- chord_server 启动时把 /data/chord-overrides/ 加到 sys.path 优先级最高,方便后续不 rebuild image 直接 cp yopu.py 热修
- 失败路径 dump HTML + 截图到 /data/chord-debug,view 页 selector 失败也能事后看
2026-05-09 23:02:34 +01:00
Fam Zheng e111398157 music(chord): 加 yopu.co 吉他谱自动抓取(sidecar 模式)
deploy cube / build-and-deploy (push) Successful in 1m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m19s
deploy music / build-and-deploy (push) Successful in 4m38s
复刻 ../guitar 的功能:
- 新加 chord-fetcher sidecar(python 3.11 + chromium + selenium),跟 main 同 pod 共享 PVC
- yopu.py v2:搜「和弦谱」→ 进 view → 选 谱面样式=功能谱 + 和弦样式=级数名 → 截 sheet-container → PIL 裁白边
- music backend 加 POST /api/pieces/:id/chord/fetch + GET /chord/status,转发 sidecar 并把 png import 成 image attachment role=chord
- 前端 chord tab 在没图时显示「自动抓取」按钮,点了 polling 状态、完成后刷新
- CI build 两个 image(music + music-chord),rollout 同步切版本
2026-05-09 22:52:09 +01:00
Fam Zheng 1a8f297302 music: 新建 music app,替换 piano-sheet
deploy cube / build-and-deploy (push) Successful in 1m10s
deploy music / build-and-deploy (push) Successful in 1m47s
deploy simpleasm / build-and-deploy (push) Successful in 1m20s
听歌 + 练琴曲目管理:
- 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff)
- 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动)
- 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存
- ns cube-music + music.famzheng.me + bodylimit 5GiB
- scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
2026-05-09 22:36:14 +01:00
Fam Zheng 58f344db85 piano-sheet(upload): mobile/pad first,主入口直调后置摄像头
deploy piano-sheet / build-and-deploy (push) Successful in 1m17s
- 主 CTA「拍下一页」+ capture=environment,副 CTA「从相册」multiple
- 底部 sticky 上传按钮,iOS safe area 兜住刘海/Home indicator
- 客户端压缩 1800px JPEG 0.85(createImageBitmap + EXIF 自动旋转)
- 页码 ↑↓ 移动 + ✕ 删除替代旧拖拽
2026-05-05 10:57:38 +01:00
Fam Zheng 1e04655003 ci: 统一 k8s manifest 为 apps/*/k8s/all.yaml
deploy cube / build-and-deploy (push) Successful in 1m11s
deploy piano-sheet / build-and-deploy (push) Successful in 1m44s
deploy simpleasm / build-and-deploy (push) Successful in 1m22s
- 三个 app 的 5 个独立 yaml 合成单文件 all.yaml,多 doc 内显式排序,apply 不再受目录字母序影响(这是 piano-sheet run #49 NotFound 的根因)
- simpleasm/cube workflow 补 Initialize K8s resources 步骤,跟 piano-sheet 对齐;今后 manifest 改动 CI 自动 apply
- cube 的 _registry-ingress.yaml 不再需要前缀绕排序,去掉 _
2026-05-05 10:38:38 +01:00
fam 1cf53316df Merge pull request 'piano-sheet: 修 CI + ns 改 cube-piano' (#2) from feat/piano-sheet into master
deploy piano-sheet / build-and-deploy (push) Failing after 1m13s
Reviewed-on: https://famzheng.me/gitea/fam/cube/pulls/2
2026-05-05 09:04:40 +00:00
Fam Zheng 538bbb7ecd piano-sheet: ns cube-piano-sheet → cube-piano
ns 跟 app 名解耦,workflow 加 NS env 不再 cube-$APP 拼。
2026-05-05 10:03:38 +01:00
Fam Zheng 09c3236b5b ci(piano-sheet): apply k8s manifests before rollout
CI 第一次跑时 ns / PVC / svc / ingress 还不存在,直接 set image
会失败。加一步 kubectl apply -f apps/piano-sheet/k8s/,让 ns +
PVC + deployment + service + ingress + middleware 都先就位,再
做镜像 set + rollout status。
2026-05-05 09:57:04 +01:00
230 changed files with 46356 additions and 1547 deletions
+60
View File
@@ -0,0 +1,60 @@
name: deploy articulate
# articulate.famzheng.me — 中英猜词派对游戏。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/articulate/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-articulate.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: articulate
IMAGE: registry.famzheng.me/mochi/articulate
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Run frontend tests
run: |
cd "apps/$APP/frontend"
npm test -- --run
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
+5 -1
View File
@@ -45,8 +45,12 @@ jobs:
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f apps/cube/k8s/all.yaml
kubectl apply -f apps/cube/k8s/registry-ingress.yaml
- name: Roll out to k3s
# runner 是 gnoc 用户 host shell 模式,直接用 ~/.kube/config(已配好),无需 secret
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
@@ -1,23 +1,23 @@
name: deploy piano-sheet
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runnerfam 用户)。
name: deploy karaoke
# karaoke.famzheng.me — 卡拉OK 点歌单本地管理。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/piano-sheet/**'
- 'apps/karaoke/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-piano-sheet.yml'
- '.gitea/workflows/deploy-karaoke.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: piano-sheet
IMAGE: registry.famzheng.me/mochi/piano-sheet
APP: karaoke
IMAGE: registry.famzheng.me/mochi/karaoke
steps:
- uses: actions/checkout@v4
@@ -37,14 +37,23 @@ jobs:
npm ci
npm run build
- name: Run frontend tests
run: |
cd "apps/$APP/frontend"
npm test -- --run
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
+52
View File
@@ -0,0 +1,52 @@
name: deploy llm-proxy
# llm.famzheng.me — gemma 反向代理。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/llm-proxy/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-llm-proxy.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: llm-proxy
IMAGE: registry.famzheng.me/mochi/llm-proxy
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Run tests
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo test --release -p "$APP"
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n llm-proxy set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n llm-proxy rollout status "deploy/$APP" --timeout=120s
+68
View File
@@ -0,0 +1,68 @@
name: deploy music
# music.famzheng.me — 听歌 + 练琴 曲目管理。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/music/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-music.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: music
NS: cube-music
IMAGE: registry.famzheng.me/mochi/music
CHORD_IMAGE: registry.famzheng.me/mochi/music-chord
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Build & push images
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
# main app —— 必须 --no-cache,否则 docker layer cache 会把"COPY target/.../music"
# 这一层套用历史 binary(之前几次 deploy 实测过:cargo 生成了新 binary 但
# docker 看缓存 layer 命中直接复用旧 binary,新代码完全没进 image
docker build --no-cache -f "apps/$APP/Dockerfile" \
-t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
# chord-fetcher sidecarlayer cache 这里有用(chromium apt 不变),保留
docker build -f "apps/$APP/chord/Dockerfile" \
-t "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}" \
"apps/$APP/chord"
docker push "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f apps/music/k8s/all.yaml
- name: Roll out to k3s
run: |
kubectl -n "$NS" set image "deploy/$APP" \
"$APP=$IMAGE:${{ steps.tag.outputs.sha }}" \
"chord-fetcher=$CHORD_IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=300s
+63
View File
@@ -0,0 +1,63 @@
name: deploy notes
# notes.famzheng.me — 录音 → ASR → LLM 会议纪要
on:
push:
branches: [master]
paths:
- 'apps/notes/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-notes.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: notes
NS: cube-notes
IMAGE: registry.famzheng.me/mochi/notes
FEISHU_IMAGE: registry.famzheng.me/mochi/notes-feishu
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Build & push images
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
# main app —— FROM scratch + COPY musl binary,必须 --no-cachecube docker cache 坑)
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
# feishu sidecar —— node+python+chromium-freelayer cache 帮助大不用 --no-cache
docker build -f "apps/$APP/feishu/Dockerfile" \
-t "$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}" \
"apps/$APP/feishu"
docker push "$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: kubectl apply -f apps/notes/k8s/all.yaml
- name: Roll out to k3s
run: |
kubectl -n "$NS" set image "deploy/$APP" \
"$APP=$IMAGE:${{ steps.tag.outputs.sha }}" \
"feishu=$FEISHU_IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=300s
+4
View File
@@ -45,6 +45,10 @@ jobs:
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f apps/simpleasm/k8s/all.yaml
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
+60
View File
@@ -0,0 +1,60 @@
name: deploy werewolf
# werewolf.famzheng.me — 狼人杀单机发牌器。host shell runnerfam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/werewolf/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-werewolf.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: werewolf
IMAGE: registry.famzheng.me/mochi/werewolf
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Run frontend tests
run: |
cd "apps/$APP/frontend"
npm test -- --run
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
+63
View File
@@ -0,0 +1,63 @@
name: deploy write
# write.famzheng.me — host systemd service(不是 k8s pod),act_runner fam 用户直接 cp 本地
on:
push:
branches: [master]
paths:
- 'apps/write/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-write.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: write
# systemctl --user 需要 runtime dirfam 已 enable linger
XDG_RUNTIME_DIR: /run/user/1001
steps:
- uses: actions/checkout@v4
- name: Build backend
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Install binary + dist + systemd unit
run: |
mkdir -p \
"$HOME/.local/bin" \
"$HOME/.local/share/$APP/dist" \
"$HOME/.local/state/$APP" \
"$HOME/.config/$APP" \
"$HOME/.config/systemd/user"
install -m 755 "target/release/$APP" "$HOME/.local/bin/$APP"
rsync -a --delete "apps/$APP/frontend/dist/" "$HOME/.local/share/$APP/dist/"
install -m 644 "apps/$APP/systemd/$APP.service" "$HOME/.config/systemd/user/$APP.service"
# 首次部署占位 env(已有则不动,避免覆盖 passphrase
if [ ! -f "$HOME/.config/$APP/env" ]; then
echo "WRITE_PASSPHRASE=CHANGE-ME" > "$HOME/.config/$APP/env"
chmod 600 "$HOME/.config/$APP/env"
echo "⚠ created placeholder ~/.config/$APP/env, edit + restart"
fi
- name: Reload + restart write.service
run: |
systemctl --user daemon-reload
systemctl --user enable "$APP.service"
systemctl --user restart "$APP.service"
sleep 1
systemctl --user --no-pager status "$APP.service" | head -15
- name: Apply k8s service/ingress
run: kubectl apply -f "apps/$APP/k8s/all.yaml"
+1
View File
@@ -1,4 +1,5 @@
/target
**/node_modules
**/dist
**/tsconfig.tsbuildinfo
.DS_Store
Generated
+1230 -19
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -4,7 +4,13 @@ members = [
"crates/cube-core",
"apps/cube",
"apps/simpleasm",
"apps/piano-sheet",
"apps/music",
"apps/werewolf",
"apps/articulate",
"apps/karaoke",
"apps/notes",
"apps/llm-proxy",
"apps/write",
]
[workspace.package]
@@ -22,6 +28,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
futures = "0.3"
tokio-stream = "0.1"
[profile.release]
opt-level = "z"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "articulate"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "articulate.famzheng.me — 中英猜词派对游戏(一台手机),从 partiverse 移植"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
tokio = { workspace = true }
+6
View File
@@ -0,0 +1,6 @@
# articulate — articulate.famzheng.me
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/articulate /articulate
COPY apps/articulate/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/articulate"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#4CAF50" />
<title>Articulate 猜词</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "articulate",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vitest": "^2.1.8",
"vue-tsc": "^2.2.0"
}
}
@@ -0,0 +1,262 @@
easy - run - 跑
easy - walk - 走
easy - jump - 跳
easy - sit - 坐
easy - stand - 站
easy - sleep - 睡觉
easy - eat - 吃
easy - drink - 喝
easy - swim - 游泳
easy - fly - 飞
easy - dance - 跳舞
easy - sing - 唱歌
easy - read - 读
easy - write - 写
easy - draw - 画
easy - play - 玩
easy - laugh - 笑
easy - cry - 哭
easy - smile - 微笑
easy - talk - 说话
easy - listen - 听
easy - look - 看
easy - watch - 观看
easy - think - 思考
easy - open - 打开
easy - close - 关闭
easy - push - 推
easy - pull - 拉
easy - throw - 扔
easy - catch - 接
easy - kick - 踢
easy - hit - 打
easy - climb - 爬
easy - fall - 掉落
easy - drive - 开车
easy - ride - 骑
easy - cook - 做饭
easy - wash - 洗
easy - clean - 清洁
easy - cut - 切
easy - break - 打破
easy - fix - 修理
easy - build - 建造
easy - paint - 刷漆
easy - dig - 挖
easy - plant - 种植
easy - water - 浇水
easy - pick - 摘
easy - carry - 携带
easy - lift - 举起
easy - drop - 掉落
easy - pour - 倒
easy - mix - 混合
easy - stir - 搅拌
easy - fold - 折叠
easy - roll - 滚动
easy - slide - 滑动
easy - crawl - 爬行
easy - kneel - 跪
easy - bow - 鞠躬
easy - wave - 挥手
easy - clap - 拍手
easy - point - 指
easy - touch - 触摸
easy - hug - 拥抱
easy - kiss - 亲吻
easy - shake - 摇动
medium - whisper - 低语
medium - shout - 喊叫
medium - scream - 尖叫
medium - yell - 大喊
medium - murmur - 低语
medium - mumble - 咕哝
medium - stutter - 结巴
medium - sneeze - 打喷嚏
medium - cough - 咳嗽
medium - yawn - 打哈欠
medium - hiccup - 打嗝
medium - burp - 打刔
medium - snore - 打鼾
medium - breathe - 呼吸
medium - inhale - 吸气
medium - exhale - 呼气
medium - sigh - 叹气
medium - gasp - 喘气
medium - pant - 喘息
medium - choke - 窒息
medium - swallow - 吞咽
medium - chew - 咀嚼
medium - bite - 咬
medium - lick - 舔
medium - sip - 啜饮
medium - gulp - 大口喝
medium - taste - 品尝
medium - smell - 闻
medium - sniff - 嗅
medium - stare - 凝视
medium - glance - 瞥见
medium - peek - 偷看
medium - blink - 眨眼
medium - wink - 眨眼
medium - squint - 眯眼
medium - frown - 皱眉
medium - grin - 咧嘴笑
medium - giggle - 咯咯笑
medium - chuckle - 轻笑
medium - snicker - 窃笑
medium - sob - 抽泣
medium - weep - 哭泣
medium - wail - 哀号
medium - moan - 呻吟
medium - groan - 呻吟
medium - grunt - 咕哝
medium - howl - 嚎叫
medium - roar - 咆哮
medium - bark - 吠叫
medium - meow - 喵叫
medium - chirp - 啁啾
medium - tweet - 鸟叫
medium - buzz - 嗡嗡叫
medium - hiss - 嘶嘶声
medium - growl - 咆哮
medium - purr - 呼噜声
medium - squeak - 吱吱叫
medium - croak - 呱呱叫
medium - hop - 单脚跳
medium - skip - 蹦跳
medium - leap - 跳跃
medium - bounce - 弹跳
medium - sprint - 冲刺
medium - jog - 慢跑
medium - march - 行军
medium - stroll - 漫步
medium - wander - 闲逛
medium - stride - 大步走
medium - tiptoe - 踮脚走
medium - stumble - 绊倒
medium - trip - 绊倒
medium - slip - 滑倒
medium - tumble - 翻滚
medium - somersault - 翻筋斗
medium - cartwheel - 侧手翻
medium - flip - 翻转
medium - spin - 旋转
medium - twirl - 旋转
medium - rotate - 旋转
medium - revolve - 旋转
medium - swing - 摇摆
medium - sway - 摇摆
medium - wobble - 摇晃
medium - wiggle - 扭动
medium - squirm - 蠕动
medium - wriggle - 扭动
medium - stretch - 伸展
medium - bend - 弯曲
medium - crouch - 蹲下
medium - squat - 蹲
medium - lean - 倾斜
medium - recline - 斜躺
medium - lounge - 懒散地躺
medium - sprawl - 伸开四肢躺
hard - tiptoe - 蹑手蹑脚
hard - trudge - 跋涉
hard - plod - 沉重地走
hard - lumber - 笨重地移动
hard - shuffle - 拖着脚走
hard - limp - 跛行
hard - hobble - 蹒跚
hard - stagger - 摇晃
hard - saunter - 闲逛
hard - meander - 漫步
hard - amble - 缓行
hard - traipse - 闲逛
hard - gallivant - 闲逛
hard - frolic - 嬉戏
hard - gambol - 跳跃
hard - caper - 雀跃
hard - prance - 腾跃
hard - strut - 昂首阔步
hard - swagger - 大摇大摆
hard - slink - 潜行
hard - sneak - 偷偷摸摸
hard - prowl - 潜行
hard - lurk - 潜伏
hard - skulk - 鬼鬼祟祟
hard - creep - 爬行
hard - slither - 滑行
hard - scuttle - 急跑
hard - scurry - 急跑
hard - dart - 飞奔
hard - dash - 猛冲
hard - bolt - 飞奔
hard - flee - 逃跑
hard - escape - 逃脱
hard - evade - 逃避
hard - dodge - 躲避
hard - duck - 躲避
hard - swerve - 突然转向
hard - veer - 转向
hard - pivot - 旋转
hard - whirl - 旋转
hard - gyrate - 旋转
hard - oscillate - 摆动
hard - vibrate - 振动
hard - quiver - 颤抖
hard - tremble - 颤抖
hard - shiver - 发抖
hard - shudder - 战栗
hard - quake - 震动
hard - convulse - 抽搐
hard - spasm - 痉挛
hard - twitch - 抽搐
hard - flinch - 退缩
hard - wince - 畏缩
hard - recoil - 退缩
hard - shrink - 退缩
hard - cower - 畏缩
hard - cringe - 畏缩
hard - grovel - 卑躬屈膝
hard - kneel - 跪下
hard - prostrate - 俯卧
hard - genuflect - 屈膝
hard - curtsy - 屈膝礼
hard - salute - 敬礼
hard - beckon - 招手
hard - gesture - 做手势
hard - motion - 示意
hard - signal - 发信号
hard - indicate - 指示
hard - demonstrate - 演示
hard - exhibit - 展示
hard - display - 展示
hard - showcase - 展示
hard - flaunt - 炫耀
hard - brandish - 挥舞
hard - flourish - 挥舞
hard - wield - 挥舞
hard - manipulate - 操纵
hard - maneuver - 操纵
hard - navigate - 导航
hard - steer - 驾驶
hard - pilot - 驾驶
hard - helm - 掌舵
hard - propel - 推进
hard - thrust - 推进
hard - shove - 猛推
hard - nudge - 轻推
hard - jostle - 推挤
hard - elbow - 用肘推
hard - shoulder - 用肩推
hard - barge - 猛撞
hard - collide - 碰撞
hard - crash - 碰撞
hard - smash - 粉碎
hard - shatter - 粉碎
hard - splinter - 裂成碎片
hard - fracture - 断裂
hard - rupture - 破裂
hard - burst - 爆裂
hard - explode - 爆炸
hard - detonate - 引爆
@@ -0,0 +1,243 @@
easy - cat - 猫
easy - dog - 狗
easy - fish - 鱼
easy - bird - 鸟
easy - pig - 猪
easy - cow - 牛
easy - horse - 马
easy - chicken - 鸡
easy - duck - 鸭子
easy - rabbit - 兔子
easy - mouse - 老鼠
easy - elephant - 大象
easy - lion - 狮子
easy - tiger - 老虎
easy - bear - 熊
easy - monkey - 猴子
easy - panda - 熊猫
easy - sheep - 羊
easy - goat - 山羊
easy - frog - 青蛙
easy - snake - 蛇
easy - turtle - 乌龟
easy - bee - 蜜蜂
easy - butterfly - 蝴蝶
easy - ant - 蚂蚁
easy - spider - 蜘蛛
easy - wolf - 狼
easy - fox - 狐狸
easy - deer - 鹿
easy - giraffe - 长颈鹿
easy - zebra - 斑马
easy - penguin - 企鹅
easy - dolphin - 海豚
easy - whale - 鲸鱼
easy - shark - 鲨鱼
easy - crab - 螃蟹
easy - octopus - 章鱼
easy - starfish - 海星
easy - goldfish - 金鱼
easy - parrot - 鹦鹉
easy - owl - 猫头鹰
easy - eagle - 老鹰
easy - swan - 天鹅
easy - peacock - 孔雀
easy - rooster - 公鸡
easy - turkey - 火鸡
easy - goose - 鹅
easy - camel - 骆驼
easy - kangaroo - 袋鼠
easy - koala - 考拉
easy - squirrel - 松鼠
easy - hedgehog - 刺猬
easy - bat - 蝙蝠
easy - seal - 海豹
easy - otter - 水獭
easy - beaver - 海狸
easy - raccoon - 浣熊
easy - hamster - 仓鼠
easy - guinea pig - 豚鼠
easy - donkey - 驴
easy - mule - 骡子
easy - buffalo - 水牛
easy - ox - 公牛
easy - bull - 公牛
easy - cheetah - 猎豹
easy - leopard - 豹子
easy - jaguar - 美洲豹
easy - hyena - 鬣狗
medium - rhinoceros - 犀牛
medium - hippopotamus - 河马
medium - crocodile - 鳄鱼
medium - alligator - 短吻鳄
medium - lizard - 蜥蜴
medium - iguana - 鬣蜥
medium - chameleon - 变色龙
medium - salamander - 蝾螈
medium - toad - 蟾蜍
medium - jellyfish - 水母
medium - seahorse - 海马
medium - squid - 鱿鱼
medium - lobster - 龙虾
medium - shrimp - 虾
medium - clam - 蛤蜊
medium - oyster - 牡蛎
medium - mussel - 贻贝
medium - snail - 蜗牛
medium - slug - 鼻涕虫
medium - earthworm - 蚯蚓
medium - centipede - 蜈蚣
medium - millipede - 马陆
medium - scorpion - 蝎子
medium - dragonfly - 蜻蜓
medium - grasshopper - 蚱蜢
medium - cricket - 蟋蟀
medium - mantis - 螳螂
medium - ladybug - 瓢虫
medium - firefly - 萤火虫
medium - mosquito - 蚊子
medium - fly - 苍蝇
medium - wasp - 黄蜂
medium - hornet - 大黄蜂
medium - termite - 白蚁
medium - cockroach - 蟑螂
medium - beetle - 甲虫
medium - moth - 飞蛾
medium - caterpillar - 毛毛虫
medium - cocoon - 茧
medium - pupa - 蛹
medium - larva - 幼虫
medium - tadpole - 蝌蚪
medium - flamingo - 火烈鸟
medium - pelican - 鹈鹕
medium - heron - 苍鹭
medium - crane - 鹤
medium - stork - 鹳
medium - seagull - 海鸥
medium - albatross - 信天翁
medium - sparrow - 麻雀
medium - swallow - 燕子
medium - robin - 知更鸟
medium - crow - 乌鸦
medium - raven - 渡鸦
medium - magpie - 喜鹊
medium - woodpecker - 啄木鸟
medium - hummingbird - 蜂鸟
medium - kingfisher - 翠鸟
medium - pigeon - 鸽子
medium - dove - 鸽子
medium - quail - 鹌鹑
medium - pheasant - 野鸡
medium - ostrich - 鸵鸟
medium - emu - 鸸鹋
medium - kiwi - 几维鸟
medium - porcupine - 豪猪
medium - armadillo - 犰狳
medium - anteater - 食蚁兽
medium - sloth - 树懒
medium - lemur - 狐猴
medium - baboon - 狒狒
medium - gorilla - 大猩猩
medium - chimpanzee - 黑猩猩
medium - orangutan - 猩猩
medium - gibbon - 长臂猿
hard - platypus - 鸭嘴兽
hard - echidna - 针鼹
hard - wombat - 袋熊
hard - wallaby - 小袋鼠
hard - tasmanian devil - 袋獾
hard - dingo - 澳洲野犬
hard - meerkat - 狐獴
hard - mongoose - 獴
hard - ferret - 雪貂
hard - weasel - 黄鼠狼
hard - mink - 貂
hard - badger - 獾
hard - wolverine - 狼獾
hard - lynx - 猞猁
hard - bobcat - 短尾猫
hard - ocelot - 豹猫
hard - serval - 薮猫
hard - caracal - 狞猫
hard - puma - 美洲狮
hard - cougar - 美洲狮
hard - panther - 黑豹
hard - snow leopard - 雪豹
hard - clouded leopard - 云豹
hard - okapi - 霍加狓
hard - tapir - 貘
hard - aardvark - 土豚
hard - pangolin - 穿山甲
hard - manatee - 海牛
hard - dugong - 儒艮
hard - walrus - 海象
hard - narwhal - 独角鲸
hard - beluga - 白鲸
hard - orca - 虎鲸
hard - porpoise - 鼠海豚
hard - barracuda - 梭鱼
hard - stingray - 黄貂鱼
hard - manta ray - 蝠鲼
hard - moray eel - 海鳗
hard - pike - 梭子鱼
hard - sturgeon - 鲟鱼
hard - salmon - 三文鱼
hard - trout - 鳟鱼
hard - bass - 鲈鱼
hard - perch - 鲈鱼
hard - carp - 鲤鱼
hard - catfish - 鲶鱼
hard - anchovy - 凤尾鱼
hard - sardine - 沙丁鱼
hard - herring - 鲱鱼
hard - mackerel - 鲭鱼
hard - tuna - 金枪鱼
hard - swordfish - 剑鱼
hard - marlin - 枪鱼
hard - sailfish - 旗鱼
hard - pufferfish - 河豚
hard - angelfish - 神仙鱼
hard - clownfish - 小丑鱼
hard - grouper - 石斑鱼
hard - snapper - 笛鲷
hard - flounder - 比目鱼
hard - halibut - 大比目鱼
hard - sole - 鳎鱼
hard - turbot - 大菱鲆
hard - vulture - 秃鹫
hard - condor - 秃鹰
hard - falcon - 猎鹰
hard - hawk - 鹰
hard - kite - 鸢
hard - buzzard - 秃鹰
hard - osprey - 鱼鹰
hard - kestrel - 茶隼
hard - merlin - 灰背隼
hard - harrier - 鹞
hard - goshawk - 苍鹰
hard - sparrowhawk - 雀鹰
hard - kookaburra - 笑翠鸟
hard - toucan - 巨嘴鸟
hard - hornbill - 犀鸟
hard - hoopoe - 戴胜
hard - cockatoo - 凤头鹦鹉
hard - macaw - 金刚鹦鹉
hard - parakeet - 长尾小鹦鹉
hard - budgerigar - 虎皮鹦鹉
hard - lovebird - 情侣鹦鹉
hard - canary - 金丝雀
hard - finch - 雀
hard - warbler - 莺
hard - thrush - 鸫
hard - nightingale - 夜莺
hard - lark - 云雀
hard - starling - 椋鸟
hard - mynah - 八哥
hard - oriole - 黄鹂
hard - tanager - 唐纳雀
hard - cardinal - 红雀
hard - bluejay - 蓝松鸦
hard - nuthatch - 五子雀
hard - chickadee - 山雀
hard - titmouse - 山雀
@@ -0,0 +1,257 @@
easy - head - 头
easy - face - 脸
easy - eye - 眼睛
easy - nose - 鼻子
easy - mouth - 嘴巴
easy - ear - 耳朵
easy - hair - 头发
easy - neck - 脖子
easy - shoulder - 肩膀
easy - arm - 手臂
easy - hand - 手
easy - finger - 手指
easy - thumb - 大拇指
easy - leg - 腿
easy - knee - 膝盖
easy - foot - 脚
easy - toe - 脚趾
easy - back - 背部
easy - chest - 胸部
easy - stomach - 肚子
easy - belly - 腹部
easy - heart - 心脏
easy - brain - 大脑
easy - bone - 骨头
easy - skin - 皮肤
easy - blood - 血液
easy - tooth - 牙齿
easy - tongue - 舌头
easy - lip - 嘴唇
easy - chin - 下巴
easy - cheek - 脸颊
easy - forehead - 额头
easy - eyebrow - 眉毛
easy - eyelash - 睫毛
easy - eyelid - 眼皮
easy - pupil - 瞳孔
easy - iris - 虹膜
easy - nostril - 鼻孔
easy - jaw - 下巴
easy - gum - 牙龈
easy - throat - 喉咙
easy - voice - 声音
easy - breath - 呼吸
easy - lung - 肺
easy - rib - 肋骨
easy - spine - 脊柱
easy - hip - 臀部
easy - waist - 腰
easy - elbow - 肘
easy - wrist - 手腕
easy - palm - 手掌
easy - fist - 拳头
easy - knuckle - 指关节
easy - nail - 指甲
easy - ankle - 脚踝
easy - heel - 脚后跟
easy - sole - 脚底
easy - muscle - 肌肉
easy - vein - 静脉
easy - artery - 动脉
easy - nerve - 神经
easy - liver - 肝脏
easy - kidney - 肾脏
easy - stomach - 胃
easy - intestine - 肠
easy - bladder - 膀胱
easy - spleen - 脾脏
easy - pancreas - 胰腺
medium - skull - 头骨
medium - temple - 太阳穴
medium - scalp - 头皮
medium - nape - 后颈
medium - collarbone - 锁骨
medium - breastbone - 胸骨
medium - ribcage - 胸腔
medium - abdomen - 腹部
medium - navel - 肚脐
medium - groin - 腹股沟
medium - buttock - 臀部
medium - thigh - 大腿
medium - calf - 小腿
medium - shin - 胫骨
medium - kneecap - 膝盖骨
medium - hamstring - 腿筋
medium - quadriceps - 股四头肌
medium - biceps - 二头肌
medium - triceps - 三头肌
medium - forearm - 前臂
medium - upper arm - 上臂
medium - armpit - 腋窝
medium - shoulder blade - 肩胛骨
medium - backbone - 脊梁骨
medium - tailbone - 尾骨
medium - pelvis - 骨盆
medium - femur - 股骨
medium - tibia - 胫骨
medium - fibula - 腓骨
medium - humerus - 肱骨
medium - radius - 桡骨
medium - ulna - 尺骨
medium - carpal - 腕骨
medium - metacarpal - 掌骨
medium - phalanx - 指骨
medium - tarsal - 跗骨
medium - metatarsal - 跖骨
medium - cartilage - 软骨
medium - ligament - 韧带
medium - tendon - 肌腱
medium - joint - 关节
medium - socket - 关节窝
medium - marrow - 骨髓
medium - membrane - 膜
medium - tissue - 组织
medium - organ - 器官
medium - gland - 腺体
medium - hormone - 荷尔蒙
medium - enzyme - 酶
medium - protein - 蛋白质
medium - cell - 细胞
medium - nucleus - 细胞核
medium - chromosome - 染色体
medium - gene - 基因
medium - DNA - DNA
medium - RNA - RNA
medium - plasma - 血浆
medium - platelet - 血小板
medium - red blood cell - 红细胞
medium - white blood cell - 白细胞
medium - antibody - 抗体
medium - antigen - 抗原
medium - immune system - 免疫系统
medium - lymph - 淋巴
medium - lymph node - 淋巴结
medium - tonsil - 扁桃体
medium - thymus - 胸腺
medium - thyroid - 甲状腺
medium - parathyroid - 甲状旁腺
medium - adrenal gland - 肾上腺
medium - pituitary gland - 垂体
medium - pineal gland - 松果体
medium - hypothalamus - 下丘脑
medium - cerebrum - 大脑
medium - cerebellum - 小脑
medium - brainstem - 脑干
medium - cortex - 皮层
medium - hippocampus - 海马体
medium - amygdala - 杏仁核
medium - thalamus - 丘脑
medium - medulla - 髓质
medium - spinal cord - 脊髓
medium - neuron - 神经元
medium - synapse - 突触
medium - axon - 轴突
medium - dendrite - 树突
medium - myelin - 髓鞘
medium - reflex - 反射
medium - sensation - 感觉
medium - perception - 知觉
hard - cerebral cortex - 大脑皮层
hard - frontal lobe - 额叶
hard - parietal lobe - 顶叶
hard - temporal lobe - 颞叶
hard - occipital lobe - 枕叶
hard - corpus callosum - 胼胝体
hard - basal ganglia - 基底神经节
hard - substantia nigra - 黑质
hard - ventricle - 脑室
hard - meninges - 脑膜
hard - dura mater - 硬脑膜
hard - arachnoid - 蛛网膜
hard - pia mater - 软脑膜
hard - cerebrospinal fluid - 脑脊液
hard - optic nerve - 视神经
hard - auditory nerve - 听神经
hard - olfactory nerve - 嗅神经
hard - vagus nerve - 迷走神经
hard - sciatic nerve - 坐骨神经
hard - ulnar nerve - 尺神经
hard - radial nerve - 桡神经
hard - median nerve - 正中神经
hard - femoral nerve - 股神经
hard - tibial nerve - 胫神经
hard - autonomic nervous system - 自主神经系统
hard - sympathetic - 交感神经
hard - parasympathetic - 副交感神经
hard - somatic - 躯体神经
hard - sensory neuron - 感觉神经元
hard - motor neuron - 运动神经元
hard - interneuron - 中间神经元
hard - ganglion - 神经节
hard - plexus - 神经丛
hard - esophagus - 食道
hard - duodenum - 十二指肠
hard - jejunum - 空肠
hard - ileum - 回肠
hard - colon - 结肠
hard - cecum - 盲肠
hard - appendix - 阑尾
hard - rectum - 直肠
hard - anus - 肛门
hard - bile duct - 胆管
hard - gallbladder - 胆囊
hard - bile - 胆汁
hard - gastric juice - 胃液
hard - saliva - 唾液
hard - salivary gland - 唾液腺
hard - parotid gland - 腮腺
hard - sublingual gland - 舌下腺
hard - submandibular gland - 颌下腺
hard - pharynx - 咽
hard - larynx - 喉
hard - trachea - 气管
hard - bronchus - 支气管
hard - bronchiole - 细支气管
hard - alveolus - 肺泡
hard - diaphragm - 横膈膜
hard - pleura - 胸膜
hard - pericardium - 心包
hard - myocardium - 心肌
hard - endocardium - 内心膜
hard - atrium - 心房
hard - ventricle - 心室
hard - valve - 瓣膜
hard - aorta - 主动脉
hard - vena cava - 腔静脉
hard - pulmonary artery - 肺动脉
hard - pulmonary vein - 肺静脉
hard - coronary artery - 冠状动脉
hard - carotid artery - 颈动脉
hard - jugular vein - 颈静脉
hard - capillary - 毛细血管
hard - endothelium - 内皮
hard - epithelium - 上皮
hard - connective tissue - 结缔组织
hard - adipose tissue - 脂肪组织
hard - mucous membrane - 粘膜
hard - serous membrane - 浆膜
hard - synovial membrane - 滑膜
hard - peritoneum - 腹膜
hard - mesentery - 肠系膜
hard - omentum - 网膜
hard - fascia - 筋膜
hard - aponeurosis - 腱膜
hard - bursa - 滑囊
hard - meniscus - 半月板
hard - intervertebral disc - 椎间盘
hard - vertebra - 椎骨
hard - sacrum - 骶骨
hard - coccyx - 尾骨
hard - sternum - 胸骨
hard - scapula - 肩胛骨
hard - clavicle - 锁骨
hard - patella - 髌骨
hard - mandible - 下颌骨
hard - maxilla - 上颌骨
hard - zygomatic bone - 颧骨
@@ -0,0 +1,316 @@
easy - shirt - 衬衫
easy - pants - 裤子
easy - dress - 连衣裙
easy - skirt - 裙子
easy - shoes - 鞋子
easy - socks - 袜子
easy - hat - 帽子
easy - coat - 外套
easy - jacket - 夹克
easy - sweater - 毛衣
easy - jeans - 牛仔裤
easy - shorts - 短裤
easy - tie - 领带
easy - belt - 腰带
easy - scarf - 围巾
easy - gloves - 手套
easy - boots - 靴子
easy - sandals - 凉鞋
easy - slippers - 拖鞋
easy - sneakers - 运动鞋
easy - underwear - 内衣
easy - bra - 胸罩
easy - pajamas - 睡衣
easy - robe - 浴袍
easy - swimsuit - 泳衣
easy - bikini - 比基尼
easy - uniform - 制服
easy - suit - 西装
easy - tuxedo - 燕尾服
easy - gown - 礼服
easy - blouse - 女式衬衫
easy - t-shirt - T恤
easy - tank top - 背心
easy - vest - 马甲
easy - cardigan - 开襟毛衣
easy - hoodie - 连帽衫
easy - sweatshirt - 运动衫
easy - sweatpants - 运动裤
easy - leggings - 紧身裤
easy - tights - 连裤袜
easy - stockings - 长筒袜
easy - cap - 帽子
easy - beanie - 无檐小便帽
easy - helmet - 头盔
easy - crown - 皇冠
easy - veil - 面纱
easy - mask - 面具
easy - glasses - 眼镜
easy - sunglasses - 太阳镜
easy - watch - 手表
easy - bracelet - 手镯
easy - necklace - 项链
easy - earrings - 耳环
easy - ring - 戒指
easy - brooch - 胸针
easy - pin - 别针
easy - badge - 徽章
easy - button - 纽扣
easy - zipper - 拉链
easy - pocket - 口袋
easy - collar - 衣领
easy - sleeve - 袖子
easy - cuff - 袖口
easy - hem - 下摆
easy - seam - 接缝
easy - lace - 花边
easy - ribbon - 丝带
medium - blazer - 西装外套
medium - trench coat - 风衣
medium - overcoat - 大衣
medium - parka - 派克大衣
medium - poncho - 斗篷
medium - cape - 披肩
medium - shawl - 披肩
medium - stole - 女用披肩
medium - wrap - 围巾
medium - muffler - 围巾
medium - bandana - 头巾
medium - turban - 头巾
medium - beret - 贝雷帽
medium - fedora - 软呢帽
medium - sombrero - 宽边帽
medium - bonnet - 软帽
medium - bowler - 圆顶礼帽
medium - derby - 圆顶礼帽
medium - trilby - 软呢帽
medium - panama - 巴拿马帽
medium - boater - 平顶草帽
medium - cloche - 钟形帽
medium - toque - 无边帽
medium - pillbox - 筒形无边女帽
medium - fascinator - 头饰
medium - tiara - 头冠
medium - diadem - 王冠
medium - circlet - 头环
medium - headband - 发带
medium - hairpin - 发夹
medium - barrette - 发夹
medium - scrunchie - 发圈
medium - wig - 假发
medium - toupee - 男用假发
medium - goggles - 护目镜
medium - monocle - 单片眼镜
medium - lorgnette - 长柄眼镜
medium - pince-nez - 夹鼻眼镜
medium - contact lenses - 隐形眼镜
medium - spectacles - 眼镜
medium - bifocals - 双光眼镜
medium - reading glasses - 老花镜
medium - safety glasses - 安全眼镜
medium - visor - 遮阳帽
medium - eyepatch - 眼罩
medium - blindfold - 眼罩
medium - earmuffs - 耳罩
medium - earphones - 耳机
medium - headphones - 耳机
medium - pendant - 吊坠
medium - locket - 小盒坠
medium - charm - 吊坠
medium - amulet - 护身符
medium - talisman - 护身符
medium - medallion - 大奖章
medium - cameo - 浮雕宝石
medium - choker - 项圈
medium - collar - 项圈
medium - torque - 项圈
medium - chain - 链子
medium - beads - 珠子
medium - pearls - 珍珠
medium - anklet - 脚链
medium - bangle - 手镯
medium - cuff - 袖口
medium - armband - 臂章
medium - wristband - 腕带
medium - friendship bracelet - 友谊手链
medium - cufflinks - 袖扣
medium - tie clip - 领带夹
medium - tie pin - 领带别针
medium - stud - 耳钉
medium - hoop - 圈形耳环
medium - drop earrings - 吊坠耳环
medium - chandelier earrings - 吊灯耳环
medium - nose ring - 鼻环
medium - septum ring - 鼻中隔环
medium - lip ring - 唇环
medium - tongue ring - 舌环
medium - belly ring - 肚脐环
medium - engagement ring - 订婚戒指
medium - wedding ring - 结婚戒指
medium - signet ring - 图章戒指
medium - class ring - 班级戒指
medium - mood ring - 心情戒指
medium - cocktail ring - 鸡尾酒戒指
medium - eternity ring - 永恒戒指
medium - promise ring - 承诺戒指
medium - claddagh ring - 克拉达戒指
medium - cameo ring - 浮雕戒指
medium - corsage - 胸花
medium - boutonniere - 胸花
medium - lapel pin - 翻领别针
medium - safety pin - 安全别针
medium - broach - 胸针
medium - fibula - 别针
medium - clasp - 扣子
medium - buckle - 皮带扣
medium - snap - 按扣
medium - hook and eye - 钩扣
medium - velcro - 魔术贴
medium - drawstring - 束带
medium - elastic - 松紧带
medium - garter - 吊袜带
medium - suspenders - 吊带
medium - braces - 吊带
medium - cummerbund - 腰带
medium - sash - 腰带
medium - girdle - 束腰
medium - corset - 紧身胸衣
medium - bustier - 胸衣
medium - bodice - 紧身上衣
medium - camisole - 吊带背心
medium - chemise - 宽松内衣
medium - slip - 衬裙
medium - petticoat - 衬裙
medium - underskirt - 衬裙
medium - half-slip - 半身衬裙
medium - crinoline - 衬裙
medium - hoop skirt - 箍裙
medium - bustle - 裙撑
medium - pannier - 裙撑
hard - doublet - 紧身上衣
hard - jerkin - 无袖短上衣
hard - tunic - 束腰外衣
hard - surcoat - 外衣
hard - tabard - 无袖外衣
hard - cassock - 长袍
hard - soutane - 长袍
hard - habit - 修道服
hard - cowl - 兜帽
hard - wimple - 头巾
hard - coif - 紧身帽
hard - snood - 发网
hard - mantilla - 披肩
hard - yashmak - 面纱
hard - burqa - 罩袍
hard - niqab - 面纱
hard - hijab - 头巾
hard - chador - 黑袍
hard - abaya - 长袍
hard - kaftan - 长袍
hard - djellaba - 长袍
hard - thobe - 长袍
hard - dishdasha - 长袍
hard - kandura - 长袍
hard - jubba - 长袍
hard - kurta - 长衫
hard - sherwani - 长外套
hard - achkan - 长外套
hard - nehru jacket - 尼赫鲁夹克
hard - bandhgala - 立领外套
hard - jodhpurs - 马裤
hard - breeches - 马裤
hard - knickerbockers - 灯笼裤
hard - plus fours - 高尔夫裤
hard - culottes - 裙裤
hard - palazzo pants - 阔腿裤
hard - capris - 七分裤
hard - pedal pushers - 七分裤
hard - bermuda shorts - 百慕大短裤
hard - cargo shorts - 工装短裤
hard - board shorts - 冲浪短裤
hard - hot pants - 热裤
hard - booty shorts - 超短裤
hard - boy shorts - 平角内裤
hard - briefs - 三角裤
hard - boxers - 平角裤
hard - boxer briefs - 四角裤
hard - thong - 丁字裤
hard - g-string - 丁字裤
hard - bikini briefs - 比基尼内裤
hard - hipsters - 低腰内裤
hard - boyshorts - 平角内裤
hard - granny panties - 高腰内裤
hard - bloomers - 灯笼裤
hard - knickers - 短裤
hard - pantaloons - 灯笼裤
hard - drawers - 内裤
hard - combinations - 连身内衣
hard - long johns - 秋裤
hard - thermal underwear - 保暖内衣
hard - union suit - 连身内衣
hard - bodysuit - 连体衣
hard - leotard - 紧身衣
hard - unitard - 连体紧身衣
hard - catsuit - 连体衣
hard - jumpsuit - 连身裤
hard - romper - 连身衣
hard - playsuit - 连身衣
hard - overalls - 工装裤
hard - dungarees - 工装裤
hard - coveralls - 连身工作服
hard - boilersuit - 连身工作服
hard - smock - 工作服
hard - apron - 围裙
hard - pinafore - 围裙
hard - bib - 围兜
hard - dickey - 假领
hard - jabot - 褶边领饰
hard - ruff - 轮状皱领
hard - gorget - 护喉
hard - rabat - 领饰
hard - stock - 硬领
hard - cravat - 领巾
hard - ascot - 宽领带
hard - bolo tie - 波洛领带
hard - bow tie - 蝴蝶结领带
hard - string tie - 细绳领带
hard - neckerchief - 围巾
hard - kerchief - 方巾
hard - handkerchief - 手帕
hard - pocket square - 口袋巾
hard - bandanna - 大手帕
hard - do-rag - 头巾
hard - babushka - 头巾
hard - headscarf - 头巾
hard - headwrap - 头巾
hard - gele - 头巾
hard - keffiyeh - 头巾
hard - shemagh - 头巾
hard - agal - 头箍
hard - fez - 毡帽
hard - kufi - 无檐帽
hard - taqiyah - 无檐帽
hard - skullcap - 无檐帽
hard - yarmulke - 犹太小帽
hard - kippah - 犹太小帽
hard - zucchetto - 小圆帽
hard - biretta - 方帽
hard - mitre - 主教冠
hard - papal tiara - 教皇冠
hard - pschent - 双冠
hard - uraeus - 圣蛇饰
hard - nemes - 头巾
hard - shendyt - 腰布
hard - kalasiris - 长袍
hard - chiton - 束腰外衣
hard - himation - 外袍
hard - peplos - 束腰外衣
hard - chlamys - 斗篷
hard - toga - 托加袍
hard - stola - 长袍
hard - palla - 披肩
hard - paludamentum - 军用斗篷
hard - sagum - 军用斗篷
hard - lacerna - 斗篷
hard - paenula - 斗篷
@@ -0,0 +1,277 @@
easy - red - 红色
easy - blue - 蓝色
easy - yellow - 黄色
easy - green - 绿色
easy - orange - 橙色
easy - purple - 紫色
easy - pink - 粉色
easy - brown - 棕色
easy - black - 黑色
easy - white - 白色
easy - gray - 灰色
easy - gold - 金色
easy - silver - 银色
easy - beige - 米色
easy - tan - 棕褐色
easy - cream - 奶油色
easy - ivory - 象牙色
easy - navy - 海军蓝
easy - sky blue - 天蓝色
easy - light blue - 浅蓝色
easy - dark blue - 深蓝色
easy - bright blue - 亮蓝色
easy - pale blue - 淡蓝色
easy - light green - 浅绿色
easy - dark green - 深绿色
easy - bright green - 亮绿色
easy - pale green - 淡绿色
easy - lime green - 酸橙绿
easy - mint green - 薄荷绿
easy - olive green - 橄榄绿
easy - forest green - 森林绿
easy - sea green - 海绿色
easy - light red - 浅红色
easy - dark red - 深红色
easy - bright red - 亮红色
easy - pale red - 淡红色
easy - cherry red - 樱桃红
easy - blood red - 血红色
easy - wine red - 酒红色
easy - light yellow - 浅黄色
easy - dark yellow - 深黄色
easy - bright yellow - 亮黄色
easy - pale yellow - 淡黄色
easy - lemon yellow - 柠檬黄
easy - golden yellow - 金黄色
easy - light orange - 浅橙色
easy - dark orange - 深橙色
easy - bright orange - 亮橙色
easy - pale orange - 淡橙色
easy - light purple - 浅紫色
easy - dark purple - 深紫色
easy - bright purple - 亮紫色
easy - pale purple - 淡紫色
easy - light pink - 浅粉色
easy - dark pink - 深粉色
easy - bright pink - 亮粉色
easy - pale pink - 淡粉色
easy - hot pink - 桃红色
easy - baby pink - 婴儿粉
easy - light brown - 浅棕色
easy - dark brown - 深棕色
easy - light gray - 浅灰色
easy - dark gray - 深灰色
easy - charcoal - 炭灰色
easy - slate gray - 石板灰
medium - crimson - 深红色
medium - scarlet - 猩红色
medium - maroon - 栗色
medium - burgundy - 勃艮第酒红
medium - ruby - 红宝石色
medium - rose - 玫瑰色
medium - coral - 珊瑚色
medium - salmon - 鲑鱼色
medium - peach - 桃色
medium - apricot - 杏色
medium - amber - 琥珀色
medium - bronze - 青铜色
medium - copper - 铜色
medium - rust - 铁锈色
medium - terracotta - 赤陶色
medium - sienna - 赭色
medium - umber - 褐色
medium - mahogany - 桃花心木色
medium - chestnut - 栗色
medium - chocolate - 巧克力色
medium - coffee - 咖啡色
medium - caramel - 焦糖色
medium - khaki - 卡其色
medium - sand - 沙色
medium - wheat - 小麦色
medium - buff - 浅黄色
medium - ecru - 本色
medium - taupe - 灰褐色
medium - fawn - 浅黄褐色
medium - sepia - 深褐色
medium - bisque - 淡黄色
medium - vanilla - 香草色
medium - champagne - 香槟色
medium - pearl - 珍珠色
medium - platinum - 铂金色
medium - steel - 钢色
medium - pewter - 锡色
medium - ash - 灰色
medium - smoke - 烟灰色
medium - graphite - 石墨色
medium - jet - 煤黑色
medium - ebony - 乌木色
medium - onyx - 缟玛瑙色
medium - sable - 黑貂色
medium - raven - 乌鸦色
medium - cobalt - 钴蓝色
medium - azure - 天蓝色
medium - cerulean - 蔚蓝色
medium - sapphire - 蓝宝石色
medium - indigo - 靛蓝色
medium - denim - 牛仔蓝
medium - teal - 青色
medium - turquoise - 绿松石色
medium - aqua - 水绿色
medium - cyan - 青色
medium - aquamarine - 海蓝宝石色
medium - emerald - 祖母绿色
medium - jade - 翡翠色
medium - viridian - 铬绿色
medium - chartreuse - 黄绿色
medium - lime - 酸橙色
medium - olive - 橄榄色
medium - moss - 苔藓绿
medium - sage - 鼠尾草绿
medium - mint - 薄荷色
medium - pistachio - 开心果色
medium - avocado - 鳄梨色
medium - pear - 梨色
medium - lavender - 薰衣草色
medium - lilac - 丁香色
medium - violet - 紫罗兰色
medium - amethyst - 紫水晶色
medium - plum - 梅子色
medium - eggplant - 茄子色
medium - mauve - 淡紫色
medium - orchid - 兰花色
medium - magenta - 洋红色
medium - fuchsia - 紫红色
medium - cerise - 樱桃色
medium - raspberry - 覆盆子色
medium - strawberry - 草莓色
medium - watermelon - 西瓜色
medium - blush - 腮红色
medium - rouge - 胭脂色
medium - carnation - 康乃馨色
medium - flamingo - 火烈鸟色
medium - bubblegum - 泡泡糖色
hard - vermilion - 朱红色
hard - carmine - 胭脂红
hard - celadon - 青瓷色
hard - chartreuse - 黄绿色
hard - periwinkle - 长春花色
hard - puce - 紫褐色
hard - saffron - 藏红花色
hard - ochre - 赭石色
hard - gamboge - 藤黄色
hard - aureolin - 金黄色
hard - citrine - 黄水晶色
hard - topaz - 黄玉色
hard - mustard - 芥末色
hard - goldenrod - 一枝黄花色
hard - marigold - 万寿菊色
hard - sunflower - 向日葵色
hard - canary - 金丝雀色
hard - butter - 黄油色
hard - primrose - 樱草色
hard - daffodil - 水仙色
hard - jonquil - 长寿花色
hard - dandelion - 蒲公英色
hard - honey - 蜂蜜色
hard - butterscotch - 奶油糖色
hard - tawny - 黄褐色
hard - ginger - 姜色
hard - cinnamon - 肉桂色
hard - nutmeg - 肉豆蔻色
hard - clove - 丁香色
hard - cayenne - 辣椒色
hard - paprika - 辣椒粉色
hard - brick - 砖红色
hard - cinnabar - 朱砂色
hard - madder - 茜草红
hard - alizarin - 茜素红
hard - cochineal - 胭脂虫红
hard - kermes - 胭脂虫红
hard - tyrian - 泰尔紫
hard - byzantium - 拜占庭紫
hard - heliotrope - 天芥菜紫
hard - wisteria - 紫藤色
hard - periwinkle - 长春花色
hard - cornflower - 矢车菊色
hard - delphinium - 飞燕草色
hard - hyacinth - 风信子色
hard - iris - 鸢尾花色
hard - lupine - 羽扇豆色
hard - bellflower - 风铃草色
hard - bluebell - 蓝铃花色
hard - gentian - 龙胆色
hard - forget-me-not - 勿忘我色
hard - morning glory - 牵牛花色
hard - peacock - 孔雀蓝
hard - prussian - 普鲁士蓝
hard - ultramarine - 群青色
hard - lapis - 青金石色
hard - midnight - 午夜蓝
hard - navy - 海军蓝
hard - oxford - 牛津蓝
hard - royal - 皇家蓝
hard - electric - 电光蓝
hard - dodger - 道奇蓝
hard - powder - 粉蓝色
hard - alice - 爱丽丝蓝
hard - baby - 婴儿蓝
hard - columbia - 哥伦比亚蓝
hard - carolina - 卡罗来纳蓝
hard - maya - 玛雅蓝
hard - egyptian - 埃及蓝
hard - persian - 波斯蓝
hard - steel - 钢蓝色
hard - slate - 石板蓝
hard - cadet - 军校蓝
hard - dusk - 暮色蓝
hard - pewter - 锡蓝色
hard - gunmetal - 炮铜色
hard - battleship - 战舰灰
hard - payne's - 佩恩灰
hard - davy's - 戴维灰
hard - feldgrau - 野战灰
hard - taupe - 灰褐色
hard - greige - 灰米色
hard - mushroom - 蘑菇色
hard - pebble - 鹅卵石色
hard - stone - 石色
hard - cement - 水泥色
hard - concrete - 混凝土色
hard - asphalt - 沥青色
hard - basalt - 玄武岩色
hard - granite - 花岗岩色
hard - marble - 大理石色
hard - limestone - 石灰石色
hard - sandstone - 砂岩色
hard - slate - 板岩色
hard - shale - 页岩色
hard - obsidian - 黑曜石色
hard - flint - 燧石色
hard - quartz - 石英色
hard - alabaster - 雪花石膏色
hard - porcelain - 瓷色
hard - bone - 骨色
hard - chalk - 粉笔色
hard - milk - 牛奶色
hard - linen - 亚麻色
hard - parchment - 羊皮纸色
hard - vellum - 犊皮纸色
hard - papyrus - 纸莎草色
hard - manila - 马尼拉纸色
hard - newsprint - 新闻纸色
hard - cardboard - 纸板色
hard - kraft - 牛皮纸色
hard - burlap - 粗麻布色
hard - hessian - 粗麻布色
hard - jute - 黄麻色
hard - hemp - 大麻色
hard - flax - 亚麻色
hard - cotton - 棉花色
hard - wool - 羊毛色
hard - cashmere - 羊绒色
hard - mohair - 马海毛色
hard - angora - 安哥拉毛色
hard - alpaca - 羊驼毛色
hard - vicuna - 骆马毛色
hard - camel - 驼色
@@ -0,0 +1,307 @@
easy - happy - 快乐
easy - sad - 悲伤
easy - angry - 生气
easy - scared - 害怕
easy - excited - 兴奋
easy - tired - 疲倦
easy - bored - 无聊
easy - surprised - 惊讶
easy - worried - 担心
easy - nervous - 紧张
easy - proud - 骄傲
easy - shy - 害羞
easy - brave - 勇敢
easy - calm - 平静
easy - confused - 困惑
easy - curious - 好奇
easy - disappointed - 失望
easy - embarrassed - 尴尬
easy - frustrated - 沮丧
easy - grateful - 感激
easy - guilty - 内疚
easy - hopeful - 充满希望
easy - jealous - 嫉妒
easy - lonely - 孤独
easy - loved - 被爱
easy - mad - 疯狂
easy - peaceful - 和平
easy - relaxed - 放松
easy - satisfied - 满意
easy - shocked - 震惊
easy - silly - 愚蠢
easy - stressed - 压力大
easy - uncomfortable - 不舒服
easy - upset - 心烦
easy - warm - 温暖
easy - cold - 冷淡
easy - cheerful - 愉快
easy - gloomy - 阴郁
easy - joyful - 欢乐
easy - miserable - 痛苦
easy - pleased - 高兴
easy - unhappy - 不快乐
easy - content - 满足
easy - discontent - 不满
easy - delighted - 高兴
easy - depressed - 沮丧
easy - elated - 兴高采烈
easy - melancholy - 忧郁
easy - enthusiastic - 热情
easy - apathetic - 冷漠
easy - optimistic - 乐观
easy - pessimistic - 悲观
easy - confident - 自信
easy - insecure - 不安全
easy - comfortable - 舒适
easy - anxious - 焦虑
easy - secure - 安全
easy - threatened - 受威胁
easy - safe - 安全
easy - vulnerable - 脆弱
easy - strong - 强大
easy - weak - 虚弱
easy - energetic - 精力充沛
easy - lethargic - 无精打采
easy - alert - 警觉
easy - drowsy - 昏昏欲睡
easy - awake - 清醒
easy - sleepy - 困倦
medium - ecstatic - 狂喜
medium - despondent - 沮丧
medium - furious - 愤怒
medium - terrified - 恐惧
medium - thrilled - 激动
medium - exhausted - 筋疲力尽
medium - indifferent - 漠不关心
medium - astonished - 惊讶
medium - concerned - 关心
medium - tense - 紧张
medium - arrogant - 傲慢
medium - timid - 胆怯
medium - courageous - 勇敢
medium - serene - 宁静
medium - bewildered - 困惑
medium - inquisitive - 好奇
medium - disheartened - 灰心
medium - mortified - 羞愧
medium - exasperated - 恼怒
medium - thankful - 感谢
medium - remorseful - 懊悔
medium - encouraged - 鼓励
medium - envious - 羡慕
medium - isolated - 孤立
medium - cherished - 珍爱
medium - irate - 愤怒
medium - tranquil - 平静
medium - composed - 镇定
medium - fulfilled - 满足
medium - appalled - 震惊
medium - playful - 顽皮
medium - overwhelmed - 不知所措
medium - awkward - 尴尬
medium - agitated - 激动
medium - affectionate - 深情
medium - distant - 疏远
medium - jovial - 愉快
medium - somber - 阴郁
medium - jubilant - 欢腾
medium - wretched - 悲惨
medium - gratified - 满意
medium - displeased - 不悦
medium - contented - 满足
medium - dissatisfied - 不满
medium - overjoyed - 欣喜若狂
medium - dejected - 沮丧
medium - euphoric - 欣快
medium - mournful - 悲哀
medium - passionate - 热情
medium - dispassionate - 冷静
medium - sanguine - 乐观
medium - cynical - 愤世嫉俗
medium - assured - 确信
medium - doubtful - 怀疑
medium - cozy - 舒适
medium - uneasy - 不安
medium - protected - 受保护
medium - endangered - 濒危
medium - sheltered - 庇护
medium - exposed - 暴露
medium - robust - 强健
medium - frail - 虚弱
medium - vigorous - 充满活力
medium - sluggish - 迟钝
medium - attentive - 专注
medium - distracted - 分心
medium - conscious - 有意识
medium - unconscious - 无意识
medium - refreshed - 精神焕发
medium - fatigued - 疲劳
medium - invigorated - 充满活力
medium - drained - 精疲力竭
medium - stimulated - 刺激
medium - numbed - 麻木
medium - animated - 活跃
medium - lifeless - 无生气
medium - spirited - 精神饱满
medium - dispirited - 沮丧
medium - buoyant - 轻快
medium - heavy-hearted - 心情沉重
medium - lighthearted - 轻松愉快
medium - downcast - 沮丧
medium - uplifted - 振奋
medium - dejected - 沮丧
medium - inspired - 受启发
medium - uninspired - 缺乏灵感
medium - motivated - 有动力
medium - unmotivated - 没有动力
medium - determined - 坚定
medium - hesitant - 犹豫
medium - resolute - 坚决
medium - wavering - 动摇
medium - steadfast - 坚定
medium - vacillating - 摇摆不定
medium - decisive - 果断
medium - indecisive - 优柔寡断
medium - bold - 大胆
medium - cautious - 谨慎
medium - daring - 勇敢
medium - wary - 警惕
medium - adventurous - 冒险
medium - conservative - 保守
medium - spontaneous - 自发
medium - calculated - 精心计算
medium - impulsive - 冲动
medium - deliberate - 深思熟虑
medium - reckless - 鲁莽
medium - prudent - 谨慎
medium - carefree - 无忧无虑
medium - burdened - 负担
medium - untroubled - 无忧
medium - troubled - 烦恼
medium - unperturbed - 镇定
medium - perturbed - 不安
medium - undisturbed - 不受干扰
medium - disturbed - 不安
medium - unruffled - 镇定
medium - ruffled - 不安
medium - unflappable - 镇定
medium - flustered - 慌乱
hard - euphoric - 欣快
hard - dysphoric - 烦躁
hard - elated - 兴高采烈
hard - crestfallen - 垂头丧气
hard - incensed - 激怒
hard - petrified - 吓呆
hard - exhilarated - 兴奋
hard - enervated - 衰弱
hard - nonchalant - 漠不关心
hard - flabbergasted - 目瞪口呆
hard - apprehensive - 忧虑
hard - fraught - 焦虑
hard - haughty - 傲慢
hard - diffident - 缺乏自信
hard - valiant - 英勇
hard - placid - 平静
hard - perplexed - 困惑
hard - intrigued - 好奇
hard - crestfallen - 沮丧
hard - abashed - 羞愧
hard - vexed - 恼怒
hard - appreciative - 感激
hard - contrite - 悔恨
hard - buoyed - 振奋
hard - covetous - 贪婪
hard - ostracized - 排斥
hard - adored - 崇拜
hard - livid - 愤怒
hard - equanimous - 平静
hard - poised - 镇定
hard - satiated - 满足
hard - aghast - 惊骇
hard - whimsical - 异想天开
hard - beleaguered - 困扰
hard - sheepish - 羞怯
hard - perturbed - 不安
hard - tender - 温柔
hard - aloof - 冷漠
hard - ebullient - 热情洋溢
hard - lugubrious - 悲哀
hard - rapturous - 狂喜
hard - abject - 悲惨
hard - appeased - 平息
hard - irked - 恼怒
hard - sated - 满足
hard - malcontent - 不满
hard - transported - 狂喜
hard - forlorn - 孤独
hard - rhapsodic - 狂喜
hard - plaintive - 哀伤
hard - fervent - 热情
hard - phlegmatic - 冷静
hard - buoyant - 乐观
hard - sardonic - 讽刺
hard - self-assured - 自信
hard - diffident - 缺乏自信
hard - snug - 舒适
hard - disquieted - 不安
hard - fortified - 加强
hard - imperiled - 危险
hard - ensconced - 安置
hard - vulnerable - 脆弱
hard - stalwart - 坚定
hard - infirm - 虚弱
hard - ebullient - 热情洋溢
hard - torpid - 迟钝
hard - vigilant - 警惕
hard - oblivious - 忘记
hard - lucid - 清醒
hard - stuporous - 昏迷
hard - rejuvenated - 恢复活力
hard - debilitated - 虚弱
hard - galvanized - 激励
hard - enervated - 衰弱
hard - aroused - 唤醒
hard - desensitized - 麻木
hard - vivacious - 活泼
hard - moribund - 垂死
hard - effervescent - 活跃
hard - languid - 无精打采
hard - resilient - 有弹性
hard - disconsolate - 忧郁
hard - blithe - 快乐
hard - doleful - 悲哀
hard - exalted - 高兴
hard - abased - 降低
hard - emboldened - 鼓励
hard - daunted - 气馁
hard - galvanized - 激励
hard - demoralized - 士气低落
hard - tenacious - 坚韧
hard - irresolute - 优柔寡断
hard - unwavering - 坚定
hard - faltering - 犹豫
hard - intrepid - 无畏
hard - timorous - 胆怯
hard - audacious - 大胆
hard - circumspect - 谨慎
hard - venturesome - 冒险
hard - risk-averse - 规避风险
hard - extemporaneous - 即兴
hard - premeditated - 预谋
hard - precipitate - 仓促
hard - judicious - 明智
hard - foolhardy - 鲁莽
hard - sagacious - 睿智
hard - insouciant - 漫不经心
hard - encumbered - 负担
hard - unencumbered - 无负担
hard - vexed - 烦恼
hard - imperturbable - 镇定
hard - agitated - 激动
hard - tranquil - 平静
hard - discomposed - 不安
hard - unperturbed - 镇定
hard - disconcerted - 不安
hard - composed - 镇定
hard - flustered - 慌乱
@@ -0,0 +1,377 @@
easy - rice - 米饭
easy - bread - 面包
easy - water - 水
easy - milk - 牛奶
easy - egg - 鸡蛋
easy - apple - 苹果
easy - banana - 香蕉
easy - orange - 橙子
easy - meat - 肉
easy - fish - 鱼
easy - chicken - 鸡肉
easy - pork - 猪肉
easy - beef - 牛肉
easy - noodles - 面条
easy - soup - 汤
easy - tea - 茶
easy - coffee - 咖啡
easy - juice - 果汁
easy - cake - 蛋糕
easy - candy - 糖果
easy - chocolate - 巧克力
easy - ice cream - 冰淇淋
easy - cookie - 饼干
easy - pizza - 披萨
easy - hamburger - 汉堡
easy - hot dog - 热狗
easy - sandwich - 三明治
easy - salad - 沙拉
easy - potato - 土豆
easy - tomato - 西红柿
easy - carrot - 胡萝卜
easy - cucumber - 黄瓜
easy - onion - 洋葱
easy - garlic - 大蒜
easy - pepper - 辣椒
easy - corn - 玉米
easy - cabbage - 白菜
easy - lettuce - 生菜
easy - spinach - 菠菜
easy - grape - 葡萄
easy - watermelon - 西瓜
easy - strawberry - 草莓
easy - peach - 桃子
easy - pear - 梨
easy - lemon - 柠檬
easy - cherry - 樱桃
easy - pineapple - 菠萝
easy - mango - 芒果
easy - kiwi - 猕猴桃
easy - melon - 甜瓜
easy - sugar - 糖
easy - salt - 盐
easy - oil - 油
easy - butter - 黄油
easy - cheese - 奶酪
easy - yogurt - 酸奶
easy - honey - 蜂蜜
easy - jam - 果酱
easy - sauce - 酱
easy - vinegar - 醋
easy - soy sauce - 酱油
easy - dumpling - 饺子
easy - steamed bun - 馒头
easy - tofu - 豆腐
easy - soybean - 黄豆
easy - peanut - 花生
easy - walnut - 核桃
medium - sushi - 寿司
medium - ramen - 拉面
medium - tempura - 天妇罗
medium - kimchi - 泡菜
medium - curry - 咖喱
medium - pasta - 意大利面
medium - lasagna - 千层面
medium - ravioli - 意大利饺子
medium - risotto - 意大利烩饭
medium - paella - 西班牙海鲜饭
medium - burrito - 墨西哥卷饼
medium - taco - 墨西哥玉米饼
medium - enchilada - 墨西哥卷饼
medium - guacamole - 鳄梨酱
medium - hummus - 鹰嘴豆泥
medium - falafel - 炸豆丸子
medium - kebab - 烤肉串
medium - shawarma - 沙威玛
medium - pho - 越南河粉
medium - pad thai - 泰式炒河粉
medium - satay - 沙爹
medium - dim sum - 点心
medium - wonton - 馄饨
medium - spring roll - 春卷
medium - fried rice - 炒饭
medium - congee - 粥
medium - hot pot - 火锅
medium - barbecue - 烧烤
medium - steak - 牛排
medium - lamb - 羊肉
medium - duck - 鸭肉
medium - bacon - 培根
medium - sausage - 香肠
medium - ham - 火腿
medium - salmon - 三文鱼
medium - tuna - 金枪鱼
medium - shrimp - 虾
medium - crab - 螃蟹
medium - lobster - 龙虾
medium - oyster - 牡蛎
medium - clam - 蛤蜊
medium - squid - 鱿鱼
medium - octopus - 章鱼
medium - seaweed - 海藻
medium - mushroom - 蘑菇
medium - eggplant - 茄子
medium - zucchini - 西葫芦
medium - broccoli - 西兰花
medium - cauliflower - 花菜
medium - asparagus - 芦笋
medium - celery - 芹菜
medium - radish - 萝卜
medium - turnip - 芜菁
medium - pumpkin - 南瓜
medium - squash - 南瓜
medium - bean - 豆子
medium - lentil - 扁豆
medium - chickpea - 鹰嘴豆
medium - almond - 杏仁
medium - cashew - 腰果
medium - pistachio - 开心果
medium - hazelnut - 榛子
medium - chestnut - 栗子
medium - coconut - 椰子
medium - avocado - 鳄梨
medium - papaya - 木瓜
medium - guava - 番石榴
medium - passion fruit - 百香果
medium - dragon fruit - 火龙果
medium - lychee - 荔枝
medium - longan - 龙眼
medium - pomegranate - 石榴
medium - fig - 无花果
medium - date - 椰枣
medium - apricot - 杏
medium - plum - 李子
medium - blueberry - 蓝莓
medium - raspberry - 覆盆子
medium - blackberry - 黑莓
medium - cranberry - 蔓越莓
medium - grapefruit - 葡萄柚
medium - tangerine - 橘子
medium - lime - 青柠
medium - persimmon - 柿子
medium - cantaloupe - 哈密瓜
medium - honeydew - 蜜瓜
medium - flour - 面粉
medium - yeast - 酵母
medium - baking soda - 小苏打
medium - cinnamon - 肉桂
medium - ginger - 姜
medium - turmeric - 姜黄
medium - cumin - 孜然
medium - paprika - 辣椒粉
medium - oregano - 牛至
medium - basil - 罗勒
medium - thyme - 百里香
medium - rosemary - 迷迭香
medium - mint - 薄荷
medium - parsley - 欧芹
medium - cilantro - 香菜
medium - dill - 莳萝
medium - fennel - 茴香
medium - vanilla - 香草
medium - nutmeg - 肉豆蔻
medium - clove - 丁香
medium - cardamom - 豆蔻
medium - saffron - 藏红花
medium - sesame - 芝麻
medium - mayonnaise - 蛋黄酱
medium - ketchup - 番茄酱
medium - mustard - 芥末
medium - wasabi - 芥末
medium - horseradish - 辣根
medium - chili sauce - 辣椒酱
medium - oyster sauce - 蚝油
medium - fish sauce - 鱼露
medium - teriyaki - 照烧酱
medium - miso - 味噌
medium - maple syrup - 枫糖浆
medium - molasses - 糖浆
medium - marmalade - 橘子酱
medium - pesto - 青酱
medium - gravy - 肉汁
medium - broth - 高汤
medium - stock - 高汤
medium - gelatin - 明胶
medium - pectin - 果胶
medium - cornstarch - 玉米淀粉
medium - agar - 琼脂
hard - foie gras - 鹅肝
hard - caviar - 鱼子酱
hard - truffle - 松露
hard - escargot - 蜗牛
hard - prosciutto - 意大利火腿
hard - pancetta - 意大利培根
hard - mortadella - 意大利香肠
hard - chorizo - 西班牙香肠
hard - salami - 萨拉米香肠
hard - pastrami - 熏牛肉
hard - brisket - 牛胸肉
hard - sirloin - 牛腰肉
hard - tenderloin - 里脊
hard - ribeye - 肋眼牛排
hard - filet mignon - 菲力牛排
hard - veal - 小牛肉
hard - venison - 鹿肉
hard - quail - 鹌鹑
hard - pheasant - 野鸡
hard - partridge - 鹧鸪
hard - anchovy - 凤尾鱼
hard - sardine - 沙丁鱼
hard - mackerel - 鲭鱼
hard - herring - 鲱鱼
hard - cod - 鳕鱼
hard - halibut - 大比目鱼
hard - sole - 鳎鱼
hard - flounder - 比目鱼
hard - perch - 鲈鱼
hard - trout - 鳟鱼
hard - pike - 梭子鱼
hard - carp - 鲤鱼
hard - catfish - 鲶鱼
hard - eel - 鳗鱼
hard - conch - 海螺
hard - abalone - 鲍鱼
hard - scallop - 扇贝
hard - mussel - 贻贝
hard - cockle - 鸟蛤
hard - whelk - 峨螺
hard - sea urchin - 海胆
hard - sea cucumber - 海参
hard - jellyfish - 海蜇
hard - artichoke - 洋蓟
hard - arugula - 芝麻菜
hard - endive - 菊苣
hard - radicchio - 红菊苣
hard - bok choy - 白菜
hard - napa cabbage - 大白菜
hard - kohlrabi - 大头菜
hard - kale - 羽衣甘蓝
hard - collard greens - 羽衣甘蓝
hard - chard - 甜菜叶
hard - watercress - 豆瓣菜
hard - leek - 韭葱
hard - shallot - 青葱
hard - scallion - 葱
hard - chive - 细香葱
hard - jicama - 豆薯
hard - taro - 芋头
hard - yam - 山药
hard - parsnip - 欧洲防风草
hard - rutabaga - 芜菁甘蓝
hard - beet - 甜菜
hard - daikon - 白萝卜
hard - okra - 秋葵
hard - bamboo shoot - 竹笋
hard - water chestnut - 荸荠
hard - lotus root - 莲藕
hard - shiitake - 香菇
hard - enoki - 金针菇
hard - oyster mushroom - 平菇
hard - porcini - 牛肝菌
hard - chanterelle - 鸡油菌
hard - morel - 羊肚菌
hard - truffle - 松露菌
hard - rambutan - 红毛丹
hard - durian - 榴莲
hard - mangosteen - 山竹
hard - starfruit - 杨桃
hard - jackfruit - 菠萝蜜
hard - breadfruit - 面包果
hard - kumquat - 金桔
hard - quince - 榅桲
hard - medlar - 欧楂
hard - elderberry - 接骨木果
hard - gooseberry - 醋栗
hard - currant - 黑醋栗
hard - mulberry - 桑葚
hard - acai - 巴西莓
hard - goji berry - 枸杞
hard - tamarind - 罗望子
hard - carob - 角豆
hard - anise - 茴芹
hard - caraway - 香菜籽
hard - coriander - 芫荽
hard - fenugreek - 胡芦巴
hard - marjoram - 墨角兰
hard - sage - 鼠尾草
hard - savory - 香薄荷
hard - tarragon - 龙蒿
hard - chervil - 细叶芹
hard - sorrel - 酢浆草
hard - bay leaf - 月桂叶
hard - lemongrass - 柠檬草
hard - galangal - 高良姜
hard - kaffir lime - 泰国青柠
hard - star anise - 八角
hard - szechuan pepper - 花椒
hard - black pepper - 黑胡椒
hard - white pepper - 白胡椒
hard - cayenne - 卡宴辣椒
hard - chipotle - 墨西哥辣椒
hard - habanero - 哈瓦那辣椒
hard - jalapeño - 墨西哥青椒
hard - poblano - 波布拉诺辣椒
hard - serrano - 塞拉诺辣椒
hard - allspice - 多香果
hard - juniper - 杜松子
hard - sumac - 漆树
hard - asafoetida - 阿魏
hard - mace - 肉豆蔻皮
hard - capers - 酸豆
hard - tahini - 芝麻酱
hard - mirin - 味淋
hard - sake - 清酒
hard - rice vinegar - 米醋
hard - balsamic vinegar - 香醋
hard - sherry vinegar - 雪利酒醋
hard - apple cider vinegar - 苹果醋
hard - tamarind paste - 罗望子酱
hard - hoisin sauce - 海鲜酱
hard - plum sauce - 梅子酱
hard - black bean sauce - 豆豉酱
hard - sambal - 参巴酱
hard - sriracha - 是拉差辣酱
hard - gochujang - 韩国辣酱
hard - harissa - 哈里萨辣酱
hard - chimichurri - 青酱
hard - aioli - 蒜泥蛋黄酱
hard - rouille - 红辣椒蒜泥蛋黄酱
hard - remoulade - 雷莫拉德酱
hard - béarnaise - 贝亚恩酱
hard - hollandaise - 荷兰酱
hard - béchamel - 白酱
hard - velouté - 白汁
hard - espagnole - 西班牙酱
hard - demi-glace - 半釉汁
hard - bordelaise - 波尔多酱
hard - chasseur - 猎人酱
hard - lyonnaise - 里昂酱
hard - provençale - 普罗旺斯酱
hard - normande - 诺曼底酱
hard - mornay - 莫内酱
hard - soubise - 洋葱酱
hard - ravigote - 酸辣酱
hard - gribiche - 法式酸菜酱
hard - chutney - 酸辣酱
hard - relish - 调味酱
hard - piccalilli - 印度泡菜
hard - mostarda - 芥末水果
hard - membrillo - 木瓜酱
hard - compote - 蜜饯
hard - conserve - 果酱
hard - confiture - 果酱
hard - coulis - 果泥
hard - gastrique - 焦糖醋汁
hard - reduction - 浓缩汁
hard - jus - 肉汁
hard - fumet - 鱼汤
hard - court bouillon - 清汤
hard - consommé - 清汤
hard - bisque - 浓汤
hard - chowder - 海鲜浓汤
hard - gumbo - 秋葵浓汤
hard - minestrone - 意大利蔬菜汤
hard - gazpacho - 西班牙冷汤
hard - vichyssoise - 冷韭葱汤
hard - borscht - 罗宋汤
@@ -0,0 +1,263 @@
easy - song - 歌曲
easy - music - 音乐
easy - sing - 唱歌
easy - dance - 跳舞
easy - piano - 钢琴
easy - guitar - 吉他
easy - drum - 鼓
easy - violin - 小提琴
easy - flute - 笛子
easy - trumpet - 小号
easy - singer - 歌手
easy - band - 乐队
easy - concert - 音乐会
easy - stage - 舞台
easy - microphone - 麦克风
easy - speaker - 扬声器
easy - radio - 收音机
easy - CD - 光盘
easy - record - 唱片
easy - album - 专辑
easy - melody - 旋律
easy - rhythm - 节奏
easy - beat - 节拍
easy - tempo - 速度
easy - loud - 大声
easy - quiet - 安静
easy - fast - 快
easy - slow - 慢
easy - high - 高音
easy - low - 低音
easy - note - 音符
easy - scale - 音阶
easy - chord - 和弦
easy - key - 调
easy - sharp - 升号
easy - flat - 降号
easy - major - 大调
easy - minor - 小调
easy - rock - 摇滚
easy - pop - 流行音乐
easy - jazz - 爵士乐
easy - blues - 蓝调
easy - folk - 民谣
easy - country - 乡村音乐
easy - classical - 古典音乐
easy - opera - 歌剧
easy - rap - 说唱
easy - hip hop - 嘻哈
easy - disco - 迪斯科
easy - techno - 电子音乐
easy - metal - 金属乐
easy - punk - 朋克
easy - reggae - 雷鬼
easy - soul - 灵魂乐
easy - funk - 放克
easy - gospel - 福音音乐
easy - ballad - 民谣
easy - anthem - 国歌
easy - hymn - 赞美诗
easy - lullaby - 摇篮曲
easy - march - 进行曲
easy - waltz - 华尔兹
easy - tango - 探戈
easy - salsa - 萨尔萨
easy - samba - 桑巴
easy - rumba - 伦巴
medium - saxophone - 萨克斯风
medium - clarinet - 单簧管
medium - oboe - 双簧管
medium - bassoon - 巴松管
medium - trombone - 长号
medium - tuba - 大号
medium - horn - 圆号
medium - cornet - 短号
medium - bugle - 军号
medium - harmonica - 口琴
medium - accordion - 手风琴
medium - organ - 管风琴
medium - keyboard - 键盘
medium - synthesizer - 合成器
medium - xylophone - 木琴
medium - marimba - 马林巴
medium - vibraphone - 颤音琴
medium - glockenspiel - 钟琴
medium - timpani - 定音鼓
medium - cymbal - 钹
medium - tambourine - 铃鼓
medium - triangle - 三角铁
medium - castanets - 响板
medium - maracas - 沙槌
medium - bongo - 邦戈鼓
medium - conga - 康加鼓
medium - djembe - 非洲鼓
medium - tabla - 塔布拉鼓
medium - sitar - 西塔琴
medium - banjo - 班卓琴
medium - mandolin - 曼陀林
medium - ukulele - 尤克里里
medium - harp - 竖琴
medium - lute - 琵琶
medium - zither - 古筝
medium - dulcimer - 扬琴
medium - bagpipe - 风笛
medium - recorder - 竖笛
medium - piccolo - 短笛
medium - bass - 贝斯
medium - cello - 大提琴
medium - viola - 中提琴
medium - double bass - 低音提琴
medium - fiddle - 小提琴
medium - bow - 琴弓
medium - string - 弦
medium - fret - 品
medium - pick - 拨片
medium - plectrum - 拨片
medium - tuning - 调音
medium - pitch - 音高
medium - octave - 八度
medium - interval - 音程
medium - harmony - 和声
medium - counterpoint - 对位
medium - polyphony - 复调
medium - homophony - 主调
medium - monophony - 单声部
medium - unison - 齐唱
medium - canon - 卡农
medium - fugue - 赋格
medium - sonata - 奏鸣曲
medium - symphony - 交响曲
medium - concerto - 协奏曲
medium - suite - 组曲
medium - overture - 序曲
medium - prelude - 前奏曲
medium - interlude - 间奏曲
medium - postlude - 后奏曲
medium - etude - 练习曲
medium - nocturne - 夜曲
medium - serenade - 小夜曲
medium - rhapsody - 狂想曲
medium - fantasia - 幻想曲
medium - caprice - 随想曲
medium - impromptu - 即兴曲
medium - scherzo - 谐谑曲
medium - minuet - 小步舞曲
medium - mazurka - 玛祖卡
medium - polonaise - 波兰舞曲
medium - gavotte - 加沃特
medium - bourree - 布列舞曲
medium - gigue - 吉格舞曲
medium - sarabande - 萨拉班德
medium - allemande - 阿勒曼德
medium - courante - 库朗特
medium - pavane - 帕凡舞曲
medium - galliard - 加利亚德
medium - tarantella - 塔兰泰拉
medium - bolero - 波莱罗
medium - fandango - 凡丹戈
medium - flamenco - 弗拉明戈
medium - habanera - 哈巴涅拉
medium - bossa nova - 波萨诺瓦
medium - mambo - 曼波
medium - cha-cha - 恰恰
medium - foxtrot - 狐步舞
medium - quickstep - 快步舞
medium - jive - 捷舞
medium - swing - 摇摆舞
hard - contrabassoon - 低音巴松
hard - English horn - 英国管
hard - alto saxophone - 中音萨克斯
hard - tenor saxophone - 高音萨克斯
hard - baritone saxophone - 上低音萨克斯
hard - soprano saxophone - 女高音萨克斯
hard - flugelhorn - 富鲁格号
hard - euphonium - 上低音号
hard - sousaphone - 苏萨号
hard - mellophone - 圆号
hard - Wagner tuba - 瓦格纳大号
hard - serpent - 蛇形号
hard - ophicleide - 奥菲克莱德号
hard - shawm - 肖姆管
hard - crumhorn - 克鲁姆管
hard - rackett - 拉克特管
hard - dulcian - 杜尔西安管
hard - chalumeau - 沙吕莫管
hard - ocarina - 陶笛
hard - panpipes - 排箫
hard - shakuhachi - 尺八
hard - didgeridoo - 迪吉里杜管
hard - alphorn - 阿尔卑斯号
hard - conch - 海螺号
hard - shofar - 羊角号
hard - vuvuzela - 呜呜祖拉
hard - kazoo - 卡祖笛
hard - melodica - 口风琴
hard - concertina - 六角手风琴
hard - bandoneon - 班多钮手风琴
hard - hurdy-gurdy - 绞弦琴
hard - autoharp - 自动竖琴
hard - psaltery - 索尔特里琴
hard - lyre - 里拉琴
hard - koto - 筝
hard - shamisen - 三味线
hard - erhu - 二胡
hard - pipa - 琵琶
hard - guqin - 古琴
hard - guzheng - 古筝
hard - yangqin - 扬琴
hard - sanxian - 三弦
hard - ruan - 阮
hard - liuqin - 柳琴
hard - zhongruan - 中阮
hard - daruan - 大阮
hard - dizi - 笛子
hard - xiao - 箫
hard - sheng - 笙
hard - suona - 唢呐
hard - gong - 锣
hard - chime - 编钟
hard - clapper - 梆子
hard - woodblock - 木鱼
hard - temple block - 木鱼
hard - cowbell - 牛铃
hard - agogo - 阿戈戈铃
hard - cabasa - 卡巴萨
hard - guiro - 刮瓜
hard - vibraslap - 颤音器
hard - flexatone - 弹音器
hard - ratchet - 齿轮
hard - slapstick - 拍板
hard - whip - 鞭子
hard - thundersheet - 雷板
hard - wind machine - 风声器
hard - rain stick - 雨棍
hard - ocean drum - 海浪鼓
hard - lion's roar - 狮吼
hard - cuica - 库伊卡
hard - berimbau - 贝林巴乌
hard - kalimba - 卡林巴
hard - mbira - 姆比拉
hard - balafon - 巴拉风
hard - gamelan - 甘美兰
hard - angklung - 昂格隆
hard - theremin - 特雷门琴
hard - ondes Martenot - 马特诺琴
hard - glass harmonica - 玻璃琴
hard - musical saw - 锯琴
hard - waterphone - 水琴
hard - hang drum - 手碟
hard - steel drum - 钢鼓
hard - handpan - 手碟
hard - cajón - 卡洪鼓
hard - bodhrán - 博德兰鼓
hard - darbuka - 达布卡鼓
hard - doumbek - 杜姆贝克鼓
hard - ashiko - 阿希科鼓
hard - talking drum - 会说话的鼓
hard - udu - 乌杜鼓
hard - frame drum - 框鼓
hard - dhol - 多尔鼓
hard - dholak - 多拉克鼓
hard - mridangam - 姆里丹加姆鼓
hard - pakhawaj - 帕卡瓦吉鼓
@@ -0,0 +1,390 @@
easy - tree - 树
easy - flower - 花
easy - grass - 草
easy - leaf - 叶子
easy - branch - 树枝
easy - root - 根
easy - seed - 种子
easy - fruit - 水果
easy - plant - 植物
easy - bush - 灌木
easy - forest - 森林
easy - mountain - 山
easy - river - 河
easy - lake - 湖
easy - ocean - 海洋
easy - sea - 海
easy - beach - 海滩
easy - wave - 波浪
easy - sand - 沙子
easy - rock - 岩石
easy - stone - 石头
easy - soil - 土壤
easy - mud - 泥
easy - dirt - 泥土
easy - water - 水
easy - rain - 雨
easy - snow - 雪
easy - ice - 冰
easy - cloud - 云
easy - sky - 天空
easy - sun - 太阳
easy - moon - 月亮
easy - star - 星星
easy - wind - 风
easy - storm - 暴风雨
easy - thunder - 雷
easy - lightning - 闪电
easy - rainbow - 彩虹
easy - fog - 雾
easy - mist - 薄雾
easy - dew - 露水
easy - frost - 霜
easy - fire - 火
easy - flame - 火焰
easy - smoke - 烟
easy - ash - 灰烬
easy - coal - 煤
easy - diamond - 钻石
easy - gold - 金
easy - silver - 银
easy - copper - 铜
easy - iron - 铁
easy - metal - 金属
easy - mineral - 矿物
easy - crystal - 水晶
easy - gem - 宝石
easy - pearl - 珍珠
easy - shell - 贝壳
easy - coral - 珊瑚
easy - seaweed - 海藻
easy - moss - 苔藓
easy - fern - 蕨类
easy - bamboo - 竹子
easy - cactus - 仙人掌
easy - palm - 棕榈
easy - pine - 松树
easy - oak - 橡树
easy - maple - 枫树
medium - willow - 柳树
medium - birch - 桦树
medium - cedar - 雪松
medium - cypress - 柏树
medium - elm - 榆树
medium - ash - 白蜡树
medium - beech - 山毛榉
medium - poplar - 杨树
medium - aspen - 白杨
medium - sycamore - 梧桐
medium - chestnut - 栗树
medium - walnut - 核桃树
medium - cherry - 樱桃树
medium - apple - 苹果树
medium - pear - 梨树
medium - plum - 李树
medium - peach - 桃树
medium - apricot - 杏树
medium - magnolia - 木兰
medium - dogwood - 山茱萸
medium - redwood - 红杉
medium - sequoia - 巨杉
medium - eucalyptus - 桉树
medium - acacia - 相思树
medium - mahogany - 桃花心木
medium - teak - 柚木
medium - ebony - 乌木
medium - sandalwood - 檀香木
medium - rosewood - 红木
medium - ivy - 常春藤
medium - vine - 藤蔓
medium - creeper - 爬藤
medium - climber - 攀缘植物
medium - hedge - 树篱
medium - shrub - 灌木
medium - thicket - 灌木丛
medium - undergrowth - 下层植被
medium - canopy - 树冠
medium - foliage - 叶子
medium - blossom - 花朵
medium - bloom - 花
medium - bud - 花蕾
medium - petal - 花瓣
medium - stem - 茎
medium - stalk - 茎秆
medium - thorn - 刺
medium - bark - 树皮
medium - trunk - 树干
medium - sapling - 树苗
medium - seedling - 幼苗
medium - sprout - 新芽
medium - shoot - 嫩枝
medium - twig - 细枝
medium - stump - 树桩
medium - log - 圆木
medium - timber - 木材
medium - lumber - 木材
medium - firewood - 柴火
medium - kindling - 引火柴
medium - charcoal - 木炭
medium - peat - 泥炭
medium - turf - 草皮
medium - sod - 草皮
medium - lawn - 草坪
medium - meadow - 草地
medium - field - 田野
medium - pasture - 牧场
medium - prairie - 草原
medium - savanna - 热带草原
medium - steppe - 草原
medium - tundra - 苔原
medium - taiga - 针叶林
medium - rainforest - 雨林
medium - jungle - 丛林
medium - woodland - 林地
medium - grove - 小树林
medium - copse - 矮树林
medium - thicket - 灌木丛
medium - scrubland - 灌木地
medium - brushwood - 灌木林
medium - wilderness - 荒野
medium - wetland - 湿地
medium - swamp - 沼泽
medium - marsh - 沼泽
medium - bog - 泥沼
medium - fen - 沼泽
medium - moor - 荒野
medium - heath - 荒地
medium - dune - 沙丘
medium - oasis - 绿洲
medium - canyon - 峡谷
medium - gorge - 峡谷
medium - ravine - 峡谷
medium - valley - 山谷
medium - dale - 山谷
medium - glen - 幽谷
medium - hollow - 山谷
medium - basin - 盆地
medium - plain - 平原
medium - plateau - 高原
medium - mesa - 平顶山
medium - butte - 孤山
medium - cliff - 悬崖
medium - precipice - 悬崖
medium - crag - 峭壁
medium - bluff - 断崖
medium - escarpment - 陡坡
medium - slope - 斜坡
medium - hillside - 山坡
medium - foothill - 山麓
medium - ridge - 山脊
medium - crest - 山顶
medium - peak - 山峰
medium - summit - 山顶
medium - pinnacle - 顶峰
medium - volcano - 火山
medium - crater - 火山口
medium - caldera - 火山口
medium - lava - 熔岩
medium - magma - 岩浆
medium - eruption - 喷发
medium - geyser - 间歇泉
medium - hot spring - 温泉
medium - spring - 泉
medium - fountain - 泉水
medium - well - 井
medium - stream - 小溪
medium - brook - 小河
medium - creek - 小溪
medium - rivulet - 小河
medium - tributary - 支流
medium - rapids - 急流
medium - waterfall - 瀑布
medium - cascade - 小瀑布
medium - cataract - 大瀑布
hard - conifer - 针叶树
hard - deciduous - 落叶树
hard - evergreen - 常绿树
hard - broadleaf - 阔叶树
hard - hardwood - 硬木
hard - softwood - 软木
hard - sapwood - 边材
hard - heartwood - 心材
hard - cambium - 形成层
hard - phloem - 韧皮部
hard - xylem - 木质部
hard - chlorophyll - 叶绿素
hard - photosynthesis - 光合作用
hard - transpiration - 蒸腾作用
hard - respiration - 呼吸作用
hard - germination - 发芽
hard - pollination - 授粉
hard - fertilization - 受精
hard - propagation - 繁殖
hard - cultivation - 栽培
hard - horticulture - 园艺
hard - arboriculture - 树木栽培
hard - silviculture - 造林
hard - forestry - 林业
hard - deforestation - 森林砍伐
hard - reforestation - 重新造林
hard - afforestation - 植树造林
hard - conservation - 保护
hard - preservation - 保存
hard - ecosystem - 生态系统
hard - biodiversity - 生物多样性
hard - habitat - 栖息地
hard - biome - 生物群系
hard - estuary - 河口
hard - delta - 三角洲
hard - fjord - 峡湾
hard - sound - 海湾
hard - strait - 海峡
hard - channel - 海峡
hard - archipelago - 群岛
hard - atoll - 环礁
hard - lagoon - 泻湖
hard - reef - 礁石
hard - shoal - 浅滩
hard - sandbar - 沙洲
hard - spit - 沙嘴
hard - headland - 岬
hard - promontory - 海角
hard - peninsula - 半岛
hard - isthmus - 地峡
hard - coast - 海岸
hard - coastline - 海岸线
hard - shoreline - 海岸线
hard - littoral - 沿海地区
hard - tidemark - 潮汐线
hard - high tide - 高潮
hard - low tide - 低潮
hard - neap tide - 小潮
hard - spring tide - 大潮
hard - tidal pool - 潮池
hard - rockpool - 岩池
hard - current - 洋流
hard - undercurrent - 暗流
hard - undertow - 回流
hard - riptide - 离岸流
hard - eddy - 涡流
hard - whirlpool - 漩涡
hard - maelstrom - 大漩涡
hard - vortex - 漩涡
hard - wave crest - 波峰
hard - wave trough - 波谷
hard - breaker - 碎浪
hard - surf - 拍岸浪
hard - swell - 涌浪
hard - ripple - 涟漪
hard - tsunami - 海啸
hard - tidal wave - 潮汐波
hard - storm surge - 风暴潮
hard - monsoon - 季风
hard - typhoon - 台风
hard - hurricane - 飓风
hard - cyclone - 气旋
hard - tornado - 龙卷风
hard - twister - 旋风
hard - whirlwind - 旋风
hard - dust devil - 尘卷风
hard - sandstorm - 沙尘暴
hard - blizzard - 暴风雪
hard - squall - 狂风
hard - gale - 大风
hard - tempest - 暴风雨
hard - deluge - 暴雨
hard - downpour - 倾盆大雨
hard - drizzle - 细雨
hard - shower - 阵雨
hard - precipitation - 降水
hard - hail - 冰雹
hard - sleet - 雨夹雪
hard - snowflake - 雪花
hard - snowfall - 降雪
hard - avalanche - 雪崩
hard - glacier - 冰川
hard - iceberg - 冰山
hard - ice sheet - 冰盖
hard - ice shelf - 冰架
hard - ice floe - 浮冰
hard - pack ice - 浮冰群
hard - permafrost - 永冻层
hard - hoarfrost - 白霜
hard - rime - 雾凇
hard - icicle - 冰柱
hard - stalactite - 钟乳石
hard - stalagmite - 石笋
hard - limestone - 石灰岩
hard - marble - 大理石
hard - granite - 花岗岩
hard - basalt - 玄武岩
hard - obsidian - 黑曜石
hard - pumice - 浮石
hard - sandstone - 砂岩
hard - shale - 页岩
hard - slate - 板岩
hard - quartzite - 石英岩
hard - schist - 片岩
hard - gneiss - 片麻岩
hard - conglomerate - 砾岩
hard - breccia - 角砾岩
hard - sediment - 沉积物
hard - silt - 淤泥
hard - clay - 黏土
hard - loam - 壤土
hard - topsoil - 表层土
hard - subsoil - 底土
hard - bedrock - 基岩
hard - mantle - 地幔
hard - crust - 地壳
hard - core - 地核
hard - tectonic plate - 构造板块
hard - fault line - 断层线
hard - earthquake - 地震
hard - seismic - 地震的
hard - tremor - 震动
hard - aftershock - 余震
hard - epicenter - 震中
hard - magnitude - 震级
hard - richter scale - 里氏震级
hard - landslide - 滑坡
hard - mudslide - 泥石流
hard - rockfall - 落石
hard - erosion - 侵蚀
hard - weathering - 风化
hard - sedimentation - 沉积
hard - deposition - 沉积
hard - alluvium - 冲积层
hard - moraine - 冰碛
hard - scree - 碎石堆
hard - talus - 岩屑堆
hard - boulder - 巨石
hard - pebble - 卵石
hard - gravel - 砾石
hard - cobblestone - 鹅卵石
hard - bedding plane - 层理面
hard - stratum - 地层
hard - outcrop - 露头
hard - vein - 矿脉
hard - ore - 矿石
hard - deposit - 矿床
hard - fossil - 化石
hard - amber - 琥珀
hard - petrification - 石化
hard - mineralization - 矿化
hard - crystallization - 结晶
hard - gemstone - 宝石
hard - emerald - 祖母绿
hard - ruby - 红宝石
hard - sapphire - 蓝宝石
hard - topaz - 黄玉
hard - amethyst - 紫水晶
hard - turquoise - 绿松石
hard - jade - 翡翠
hard - opal - 蛋白石
hard - garnet - 石榴石
hard - quartz - 石英
hard - feldspar - 长石
hard - mica - 云母
hard - graphite - 石墨
@@ -0,0 +1,433 @@
easy - table - 桌子
easy - chair - 椅子
easy - bed - 床
easy - door - 门
easy - window - 窗户
easy - lamp - 灯
easy - book - 书
easy - pen - 笔
easy - pencil - 铅笔
easy - paper - 纸
easy - bag - 包
easy - box - 盒子
easy - cup - 杯子
easy - plate - 盘子
easy - bowl - 碗
easy - spoon - 勺子
easy - fork - 叉子
easy - knife - 刀
easy - bottle - 瓶子
easy - glass - 玻璃杯
easy - phone - 电话
easy - computer - 电脑
easy - TV - 电视
easy - clock - 时钟
easy - watch - 手表
easy - key - 钥匙
easy - lock - 锁
easy - mirror - 镜子
easy - picture - 图片
easy - painting - 画
easy - photo - 照片
easy - camera - 相机
easy - umbrella - 雨伞
easy - towel - 毛巾
easy - soap - 肥皂
easy - toothbrush - 牙刷
easy - comb - 梳子
easy - brush - 刷子
easy - scissors - 剪刀
easy - needle - 针
easy - thread - 线
easy - button - 纽扣
easy - zipper - 拉链
easy - pillow - 枕头
easy - blanket - 毯子
easy - sheet - 床单
easy - curtain - 窗帘
easy - rug - 地毯
easy - mat - 垫子
easy - basket - 篮子
easy - bucket - 桶
easy - broom - 扫帚
easy - mop - 拖把
easy - sponge - 海绵
easy - cloth - 布
easy - rope - 绳子
easy - string - 绳子
easy - wire - 电线
easy - nail - 钉子
easy - screw - 螺丝
easy - hammer - 锤子
easy - wrench - 扳手
easy - screwdriver - 螺丝刀
easy - saw - 锯
easy - drill - 钻
easy - ladder - 梯子
easy - wheel - 轮子
easy - tire - 轮胎
easy - engine - 引擎
medium - sofa - 沙发
medium - couch - 长沙发
medium - armchair - 扶手椅
medium - stool - 凳子
medium - bench - 长凳
medium - desk - 书桌
medium - cabinet - 柜子
medium - shelf - 架子
medium - bookcase - 书架
medium - wardrobe - 衣柜
medium - dresser - 梳妆台
medium - nightstand - 床头柜
medium - mattress - 床垫
medium - headboard - 床头板
medium - footboard - 床尾板
medium - bedframe - 床架
medium - canopy - 床罩
medium - duvet - 羽绒被
medium - comforter - 被子
medium - quilt - 被子
medium - bedspread - 床罩
medium - pillowcase - 枕套
medium - cushion - 靠垫
medium - ottoman - 脚凳
medium - recliner - 躺椅
medium - rocking chair - 摇椅
medium - highchair - 高脚椅
medium - booster seat - 增高座椅
medium - chandelier - 吊灯
medium - pendant - 吊灯
medium - sconce - 壁灯
medium - spotlight - 聚光灯
medium - flashlight - 手电筒
medium - lantern - 灯笼
medium - candle - 蜡烛
medium - candlestick - 烛台
medium - vase - 花瓶
medium - pot - 锅
medium - pan - 平底锅
medium - wok - 炒锅
medium - skillet - 煎锅
medium - kettle - 水壶
medium - teapot - 茶壶
medium - pitcher - 水罐
medium - jug - 壶
medium - carafe - 玻璃水瓶
medium - decanter - 醒酒器
medium - mug - 马克杯
medium - saucer - 茶托
medium - platter - 大盘子
medium - tray - 托盘
medium - cutting board - 砧板
medium - colander - 滤器
medium - strainer - 过滤器
medium - grater - 擦菜板
medium - peeler - 削皮器
medium - whisk - 打蛋器
medium - spatula - 铲子
medium - ladle - 勺子
medium - tongs - 夹子
medium - can opener - 开罐器
medium - corkscrew - 开瓶器
medium - bottle opener - 开瓶器
medium - rolling pin - 擀面杖
medium - measuring cup - 量杯
medium - scale - 秤
medium - timer - 定时器
medium - blender - 搅拌机
medium - mixer - 搅拌器
medium - toaster - 烤面包机
medium - oven - 烤箱
medium - microwave - 微波炉
medium - stove - 炉子
medium - refrigerator - 冰箱
medium - freezer - 冰柜
medium - dishwasher - 洗碗机
medium - sink - 水槽
medium - faucet - 水龙头
medium - drain - 排水管
medium - pipe - 管子
medium - valve - 阀门
medium - hose - 软管
medium - sprinkler - 洒水器
medium - watering can - 喷壶
medium - shovel - 铲子
medium - spade - 铁锹
medium - rake - 耙子
medium - hoe - 锄头
medium - trowel - 泥刀
medium - shears - 大剪刀
medium - clippers - 剪刀
medium - pruner - 修枝剪
medium - axe - 斧头
medium - hatchet - 短柄斧
medium - chisel - 凿子
medium - plane - 刨子
medium - file - 锉刀
medium - sandpaper - 砂纸
medium - pliers - 钳子
medium - clamp - 夹具
medium - vise - 虎钳
medium - crowbar - 撬棍
medium - lever - 杠杆
medium - pulley - 滑轮
medium - jack - 千斤顶
medium - hinge - 铰链
medium - bolt - 螺栓
medium - nut - 螺母
medium - washer - 垫圈
medium - rivet - 铆钉
medium - dowel - 暗榫
medium - peg - 木钉
medium - hook - 钩子
medium - latch - 门闩
medium - knob - 把手
medium - handle - 把手
medium - lever - 杠杆
medium - switch - 开关
medium - socket - 插座
medium - plug - 插头
medium - adapter - 适配器
medium - extension cord - 延长线
medium - battery - 电池
medium - charger - 充电器
medium - remote - 遥控器
medium - antenna - 天线
medium - speaker - 扬声器
medium - headphones - 耳机
medium - earbuds - 耳塞
medium - microphone - 麦克风
medium - keyboard - 键盘
medium - mouse - 鼠标
medium - monitor - 显示器
medium - screen - 屏幕
medium - printer - 打印机
medium - scanner - 扫描仪
medium - router - 路由器
medium - modem - 调制解调器
medium - cable - 电缆
medium - flash drive - 闪存盘
medium - hard drive - 硬盘
medium - disc - 光盘
medium - cartridge - 墨盒
medium - stapler - 订书机
medium - staple - 订书钉
medium - paperclip - 回形针
medium - tape - 胶带
medium - glue - 胶水
medium - ruler - 尺子
medium - protractor - 量角器
medium - compass - 圆规
medium - calculator - 计算器
medium - eraser - 橡皮
medium - sharpener - 削笔刀
medium - marker - 马克笔
medium - highlighter - 荧光笔
medium - crayon - 蜡笔
medium - chalk - 粉笔
medium - easel - 画架
medium - canvas - 画布
medium - palette - 调色板
medium - paintbrush - 画笔
hard - chiffonier - 五斗柜
hard - credenza - 餐具柜
hard - sideboard - 餐具柜
hard - buffet - 餐具柜
hard - hutch - 碗橱
hard - armoire - 大衣柜
hard - chifforobe - 衣橱
hard - tallboy - 高脚柜
hard - lowboy - 矮脚柜
hard - commode - 五斗柜
hard - secretaire - 写字台
hard - davenport - 写字台
hard - escritoire - 写字桌
hard - bureau - 写字台
hard - vanity - 梳妆台
hard - console - 控制台
hard - pedestal - 基座
hard - lectern - 讲台
hard - podium - 讲台
hard - rostrum - 演讲台
hard - dais - 讲台
hard - pulpit - 讲道坛
hard - prie-dieu - 祈祷台
hard - chaise longue - 躺椅
hard - settee - 长椅
hard - loveseat - 双人沙发
hard - divan - 长沙发
hard - futon - 蒲团
hard - daybed - 沙发床
hard - trundle bed - 脚轮床
hard - bunk bed - 双层床
hard - crib - 婴儿床
hard - cradle - 摇篮
hard - bassinet - 摇篮
hard - hammock - 吊床
hard - pallet - 草垫
hard - cot - 帆布床
hard - camp bed - 行军床
hard - folding chair - 折叠椅
hard - deck chair - 躺椅
hard - lawn chair - 草坪椅
hard - director's chair - 导演椅
hard - sling chair - 吊椅
hard - swivel chair - 转椅
hard - ergonomic chair - 人体工学椅
hard - barstool - 酒吧凳
hard - footstool - 脚凳
hard - hassock - 软凳
hard - pouffe - 软垫
hard - beanbag - 豆袋椅
hard - tabouret - 无靠背凳
hard - tuffet - 矮凳
hard - credence - 供桌
hard - refectory table - 长餐桌
hard - trestle table - 支架桌
hard - gateleg table - 折叠桌
hard - drop-leaf table - 折叶桌
hard - pedestal table - 底座桌
hard - console table - 玄关桌
hard - sofa table - 沙发桌
hard - coffee table - 茶几
hard - end table - 边桌
hard - occasional table - 茶几
hard - nesting tables - 套桌
hard - card table - 纸牌桌
hard - drafting table - 绘图桌
hard - easel - 画架
hard - lectern - 讲台
hard - music stand - 乐谱架
hard - coat rack - 衣帽架
hard - hall tree - 衣帽架
hard - umbrella stand - 伞架
hard - magazine rack - 杂志架
hard - shoe rack - 鞋架
hard - wine rack - 酒架
hard - towel rack - 毛巾架
hard - drying rack - 晾衣架
hard - clothes horse - 晾衣架
hard - garment rack - 衣架
hard - coat hanger - 衣架
hard - trouser press - 裤子夹
hard - shoe tree - 鞋楦
hard - hatbox - 帽盒
hard - bandbox - 纸盒
hard - carton - 纸箱
hard - crate - 木箱
hard - chest - 箱子
hard - trunk - 大箱子
hard - footlocker - 脚箱
hard - steamer trunk - 蒸汽箱
hard - hope chest - 嫁妆箱
hard - coffer - 保险箱
hard - strongbox - 保险箱
hard - safe - 保险箱
hard - vault - 保险库
hard - lockbox - 上锁盒
hard - casket - 首饰盒
hard - jewel box - 首饰盒
hard - trinket box - 小饰品盒
hard - snuffbox - 鼻烟盒
hard - pillbox - 药盒
hard - compact - 粉盒
hard - cigarette case - 烟盒
hard - cigar box - 雪茄盒
hard - humidor - 雪茄盒
hard - caddy - 茶叶罐
hard - canister - 罐
hard - jar - 罐子
hard - crock - 瓦罐
hard - urn - 瓮
hard - amphora - 双耳瓶
hard - ewer - 大口水壶
hard - flagon - 大酒壶
hard - tankard - 大酒杯
hard - stein - 啤酒杯
hard - goblet - 高脚杯
hard - chalice - 圣杯
hard - tumbler - 平底杯
hard - beaker - 烧杯
hard - flask - 烧瓶
hard - retort - 曲颈瓶
hard - crucible - 坩埚
hard - mortar - 研钵
hard - pestle - 杵
hard - grindstone - 磨石
hard - whetstone - 磨刀石
hard - hone - 磨石
hard - strop - 磨刀皮带
hard - anvil - 铁砧
hard - bellows - 风箱
hard - forge - 熔炉
hard - cauldron - 大锅
hard - brazier - 火盆
hard - chafing dish - 火锅
hard - tureen - 汤碗
hard - terrine - 陶罐
hard - casserole - 砂锅
hard - Dutch oven - 荷兰烤锅
hard - pressure cooker - 压力锅
hard - double boiler - 双层蒸锅
hard - steamer - 蒸锅
hard - roaster - 烤盘
hard - griddle - 平底锅
hard - saucepan - 炖锅
hard - stockpot - 汤锅
hard - skimmer - 撇油勺
hard - slotted spoon - 漏勺
hard - serving spoon - 上菜勺
hard - soup ladle - 汤勺
hard - gravy boat - 肉汁船
hard - creamer - 奶油罐
hard - sugar bowl - 糖罐
hard - butter dish - 黄油碟
hard - salt cellar - 盐罐
hard - pepper mill - 胡椒研磨器
hard - cruet - 调味瓶
hard - napkin ring - 餐巾环
hard - tablecloth - 桌布
hard - place mat - 餐垫
hard - doily - 装饰垫
hard - antimacassar - 椅套
hard - slipcover - 沙发套
hard - upholstery - 室内装潢
hard - tapestry - 挂毯
hard - drapery - 帷幕
hard - valance - 帷幔
hard - pelmet - 窗帘盒
hard - cornice - 檐口
hard - finial - 顶饰
hard - baluster - 栏杆柱
hard - balustrade - 栏杆
hard - banister - 扶手
hard - newel post - 楼梯柱
hard - spindle - 纺锤
hard - strut - 支柱
hard - brace - 支架
hard - bracket - 托架
hard - corbel - 托臂
hard - lintel - 过梁
hard - transom - 横梁
hard - jamb - 门框
hard - sill - 窗台
hard - mullion - 竖框
hard - muntin - 窗格条
hard - casement - 窗扇
hard - shutter - 百叶窗
hard - louver - 百叶窗
hard - blind - 百叶窗
hard - shade - 遮阳帘
hard - awning - 遮篷
hard - canopy - 天篷
hard - marquee - 大帐篷
hard - pavilion - 凉亭
hard - gazebo - 凉亭
hard - pergola - 藤架
hard - arbor - 凉亭
hard - bower - 凉亭
hard - trellis - 格架
hard - lattice - 格子
hard - espalier - 墙树
hard - topiary - 造型树
@@ -0,0 +1,410 @@
easy - home - 家
easy - school - 学校
easy - park - 公园
easy - hospital - 医院
easy - store - 商店
easy - restaurant - 餐厅
easy - hotel - 酒店
easy - bank - 银行
easy - post office - 邮局
easy - library - 图书馆
easy - museum - 博物馆
easy - zoo - 动物园
easy - beach - 海滩
easy - mountain - 山
easy - river - 河
easy - lake - 湖
easy - forest - 森林
easy - desert - 沙漠
easy - island - 岛屿
easy - city - 城市
easy - town - 城镇
easy - village - 村庄
easy - street - 街道
easy - road - 路
easy - bridge - 桥
easy - building - 建筑
easy - house - 房子
easy - apartment - 公寓
easy - garden - 花园
easy - farm - 农场
easy - factory - 工厂
easy - office - 办公室
easy - classroom - 教室
easy - bedroom - 卧室
easy - kitchen - 厨房
easy - bathroom - 浴室
easy - living room - 客厅
easy - garage - 车库
easy - basement - 地下室
easy - attic - 阁楼
easy - station - 车站
easy - airport - 机场
easy - subway - 地铁
easy - market - 市场
easy - mall - 商场
easy - supermarket - 超市
easy - bakery - 面包店
easy - cafe - 咖啡馆
easy - bar - 酒吧
easy - club - 俱乐部
easy - gym - 健身房
easy - stadium - 体育场
easy - playground - 操场
easy - pool - 游泳池
easy - theater - 剧院
easy - cinema - 电影院
easy - church - 教堂
easy - temple - 寺庙
easy - mosque - 清真寺
easy - palace - 宫殿
easy - castle - 城堡
easy - tower - 塔
easy - wall - 墙
easy - gate - 大门
easy - square - 广场
easy - plaza - 广场
easy - avenue - 大道
easy - alley - 小巷
medium - cathedral - 大教堂
medium - monastery - 修道院
medium - abbey - 修道院
medium - shrine - 神社
medium - pagoda - 宝塔
medium - minaret - 宣礼塔
medium - dome - 圆顶
medium - spire - 尖塔
medium - belfry - 钟楼
medium - steeple - 尖塔
medium - fortress - 堡垒
medium - citadel - 城堡
medium - stronghold - 要塞
medium - rampart - 城墙
medium - moat - 护城河
medium - drawbridge - 吊桥
medium - turret - 塔楼
medium - dungeon - 地牢
medium - keep - 主堡
medium - bailey - 城墙
medium - manor - 庄园
medium - estate - 庄园
medium - villa - 别墅
medium - cottage - 小屋
medium - cabin - 小木屋
medium - bungalow - 平房
medium - mansion - 豪宅
medium - penthouse - 顶层公寓
medium - loft - 阁楼
medium - studio - 单间公寓
medium - condominium - 公寓
medium - dormitory - 宿舍
medium - barracks - 营房
medium - warehouse - 仓库
medium - depot - 仓库
medium - hangar - 机库
medium - shed - 棚屋
medium - barn - 谷仓
medium - stable - 马厩
medium - silo - 筒仓
medium - greenhouse - 温室
medium - conservatory - 温室
medium - nursery - 苗圃
medium - orchard - 果园
medium - vineyard - 葡萄园
medium - plantation - 种植园
medium - ranch - 牧场
medium - pasture - 牧场
medium - meadow - 草地
medium - prairie - 草原
medium - savanna - 热带草原
medium - steppe - 草原
medium - tundra - 苔原
medium - taiga - 针叶林
medium - rainforest - 雨林
medium - jungle - 丛林
medium - swamp - 沼泽
medium - marsh - 沼泽
medium - wetland - 湿地
medium - bog - 泥沼
medium - canyon - 峡谷
medium - gorge - 峡谷
medium - ravine - 峡谷
medium - valley - 山谷
medium - plain - 平原
medium - plateau - 高原
medium - cliff - 悬崖
medium - hill - 小山
medium - peak - 山峰
medium - summit - 山顶
medium - slope - 斜坡
medium - ridge - 山脊
medium - glacier - 冰川
medium - iceberg - 冰山
medium - volcano - 火山
medium - crater - 火山口
medium - geyser - 间歇泉
medium - hot spring - 温泉
medium - waterfall - 瀑布
medium - rapids - 急流
medium - stream - 小溪
medium - creek - 小河
medium - brook - 小溪
medium - pond - 池塘
medium - reservoir - 水库
medium - dam - 大坝
medium - canal - 运河
medium - harbor - 港口
medium - port - 港口
medium - wharf - 码头
medium - pier - 码头
medium - dock - 码头
medium - marina - 游艇码头
medium - bay - 海湾
medium - cove - 海湾
medium - inlet - 入海口
medium - strait - 海峡
medium - channel - 海峡
medium - lagoon - 泻湖
medium - reef - 礁石
medium - peninsula - 半岛
medium - cape - 海角
medium - coast - 海岸
medium - shore - 海岸
medium - seaside - 海边
medium - oceanfront - 海滨
medium - boardwalk - 木板路
medium - promenade - 海滨步道
medium - esplanade - 滨海大道
medium - boulevard - 林荫大道
medium - highway - 公路
medium - expressway - 高速公路
medium - freeway - 高速公路
medium - motorway - 高速公路
medium - turnpike - 收费公路
medium - parkway - 园林公路
medium - causeway - 堤道
medium - viaduct - 高架桥
medium - overpass - 立交桥
medium - underpass - 地下通道
medium - tunnel - 隧道
medium - intersection - 十字路口
medium - crossroads - 十字路口
medium - junction - 交叉口
medium - roundabout - 环岛
medium - cul-de-sac - 死胡同
medium - lane - 车道
medium - pathway - 小路
medium - trail - 小径
medium - footpath - 人行道
medium - sidewalk - 人行道
medium - pavement - 人行道
medium - curb - 路边
medium - gutter - 排水沟
medium - courtyard - 庭院
medium - terrace - 露台
medium - balcony - 阳台
medium - porch - 门廊
medium - veranda - 走廊
medium - patio - 露台
medium - deck - 平台
medium - driveway - 车道
medium - parking lot - 停车场
medium - garage - 车库
medium - carport - 车棚
medium - lobby - 大厅
medium - foyer - 门厅
medium - hallway - 走廊
medium - corridor - 走廊
medium - staircase - 楼梯
medium - elevator - 电梯
medium - escalator - 自动扶梯
medium - rooftop - 屋顶
medium - skylight - 天窗
hard - mausoleum - 陵墓
hard - crypt - 地下墓室
hard - catacomb - 地下墓穴
hard - necropolis - 墓地
hard - cemetery - 墓地
hard - graveyard - 墓地
hard - columbarium - 骨灰堂
hard - cenotaph - 纪念碑
hard - obelisk - 方尖碑
hard - stele - 石碑
hard - monument - 纪念碑
hard - memorial - 纪念馆
hard - pantheon - 万神殿
hard - basilica - 长方形教堂
hard - chapel - 小教堂
hard - sanctuary - 圣所
hard - sacristy - 圣器收藏室
hard - vestry - 法衣室
hard - presbytery - 长老会
hard - rectory - 教区长住宅
hard - vicarage - 牧师住所
hard - cloister - 回廊
hard - refectory - 食堂
hard - scriptorium - 写字间
hard - chapter house - 议事厅
hard - narthex - 前厅
hard - nave - 中殿
hard - aisle - 侧廊
hard - transept - 耳堂
hard - apse - 半圆形后殿
hard - chancel - 圣坛
hard - altar - 祭坛
hard - pulpit - 讲道坛
hard - lectern - 讲经台
hard - baptistery - 洗礼堂
hard - confessional - 告解室
hard - pew - 长椅
hard - choir - 唱诗班席
hard - organ loft - 风琴阁楼
hard - campanile - 钟楼
hard - arcade - 拱廊
hard - colonnade - 柱廊
hard - portico - 门廊
hard - vestibule - 前厅
hard - anteroom - 前厅
hard - antechamber - 前厅
hard - rotunda - 圆形大厅
hard - atrium - 中庭
hard - loggia - 凉廊
hard - piazza - 广场
hard - forum - 广场
hard - agora - 集市
hard - bazaar - 集市
hard - souk - 集市
hard - caravansary - 商队旅馆
hard - khan - 客栈
hard - inn - 客栈
hard - tavern - 酒馆
hard - hostel - 旅舍
hard - boarding house - 寄宿处
hard - lodging - 住所
hard - quarters - 住所
hard - bivouac - 露营地
hard - campsite - 营地
hard - encampment - 营地
hard - outpost - 前哨
hard - garrison - 驻军地
hard - bastion - 堡垒
hard - redoubt - 堡垒
hard - parapet - 胸墙
hard - battlement - 城垛
hard - embrasure - 射击孔
hard - portcullis - 吊闸
hard - barbican - 城堡前哨
hard - gatehouse - 门楼
hard - watchtower - 瞭望塔
hard - beacon - 信标塔
hard - lighthouse - 灯塔
hard - observatory - 天文台
hard - planetarium - 天文馆
hard - aquarium - 水族馆
hard - terrarium - 玻璃容器
hard - aviary - 鸟舍
hard - menagerie - 动物园
hard - vivarium - 动物园
hard - apiary - 养蜂场
hard - fishery - 渔场
hard - hatchery - 孵化场
hard - cannery - 罐头厂
hard - brewery - 啤酒厂
hard - distillery - 酿酒厂
hard - winery - 酒庄
hard - refinery - 炼油厂
hard - foundry - 铸造厂
hard - forge - 锻造厂
hard - mill - 磨坊
hard - kiln - 窑
hard - furnace - 熔炉
hard - smelter - 冶炼厂
hard - quarry - 采石场
hard - mine - 矿山
hard - shaft - 矿井
hard - pit - 矿坑
hard - excavation - 挖掘地
hard - trench - 战壕
hard - bunker - 掩体
hard - pillbox - 碉堡
hard - foxhole - 散兵坑
hard - dugout - 防空洞
hard - shelter - 避难所
hard - refuge - 避难所
hard - sanctuary - 庇护所
hard - haven - 避风港
hard - retreat - 隐居处
hard - hermitage - 隐居地
hard - monastery - 修道院
hard - convent - 女修道院
hard - priory - 小修道院
hard - friary - 修士会
hard - seminary - 神学院
hard - academy - 学院
hard - conservatory - 音乐学院
hard - lyceum - 学园
hard - athenaeum - 文学会馆
hard - polytechnic - 理工学院
hard - institute - 研究所
hard - laboratory - 实验室
hard - workshop - 工作坊
hard - studio - 工作室
hard - atelier - 画室
hard - foundry - 铸造厂
hard - smithy - 铁匠铺
hard - tannery - 制革厂
hard - cooperage - 制桶厂
hard - chandlery - 蜡烛作坊
hard - apothecary - 药房
hard - dispensary - 药房
hard - infirmary - 医务室
hard - sanatorium - 疗养院
hard - asylum - 收容所
hard - hospice - 临终关怀院
hard - almshouse - 救济院
hard - orphanage - 孤儿院
hard - foundling home - 弃婴收容所
hard - penitentiary - 监狱
hard - correctional facility - 惩教所
hard - stockade - 军事监狱
hard - brig - 军舰拘留室
hard - guardhouse - 警卫室
hard - lockup - 拘留所
hard - jailhouse - 监狱
hard - calaboose - 监狱
hard - clink - 监狱
hard - hoosegow - 监狱
hard - slammer - 监狱
hard - pen - 监狱
hard - workhouse - 劳教所
hard - reformatory - 少年管教所
hard - detention center - 拘留中心
hard - halfway house - 中途之家
hard - safehouse - 安全屋
hard - hideout - 藏身处
hard - lair - 巢穴
hard - den - 巢穴
hard - burrow - 洞穴
hard - warren - 兔窝
hard - nest - 巢
hard - rookery - 群居地
hard - colony - 聚居地
hard - settlement - 定居点
hard - hamlet - 小村庄
hard - township - 镇区
hard - borough - 自治市镇
hard - burgh - 自治市
hard - municipality - 自治市
hard - metropolis - 大都市
hard - megalopolis - 特大城市
hard - conurbation - 城市群
hard - agglomeration - 集聚区
hard - suburb - 郊区
hard - outskirts - 郊区
hard - periphery - 边缘地带
hard - hinterland - 腹地
hard - backcountry - 偏远地区
hard - wilderness - 荒野
hard - badlands - 荒地
hard - wasteland - 荒原
@@ -0,0 +1,260 @@
easy - teacher - 教师
easy - doctor - 医生
easy - nurse - 护士
easy - driver - 司机
easy - cook - 厨师
easy - waiter - 服务员
easy - farmer - 农民
easy - worker - 工人
easy - student - 学生
easy - police - 警察
easy - soldier - 士兵
easy - singer - 歌手
easy - dancer - 舞者
easy - actor - 演员
easy - artist - 艺术家
easy - writer - 作家
easy - painter - 画家
easy - musician - 音乐家
easy - pilot - 飞行员
easy - sailor - 水手
easy - fisherman - 渔夫
easy - hunter - 猎人
easy - builder - 建筑工人
easy - cleaner - 清洁工
easy - barber - 理发师
easy - tailor - 裁缝
easy - shoemaker - 鞋匠
easy - baker - 面包师
easy - butcher - 屠夫
easy - gardener - 园丁
easy - postman - 邮递员
easy - fireman - 消防员
easy - dentist - 牙医
easy - vet - 兽医
easy - lawyer - 律师
easy - judge - 法官
easy - banker - 银行家
easy - clerk - 职员
easy - secretary - 秘书
easy - manager - 经理
easy - boss - 老板
easy - engineer - 工程师
easy - scientist - 科学家
easy - professor - 教授
easy - librarian - 图书管理员
easy - photographer - 摄影师
easy - reporter - 记者
easy - editor - 编辑
easy - designer - 设计师
easy - architect - 建筑师
easy - mechanic - 机械师
easy - electrician - 电工
easy - plumber - 水管工
easy - carpenter - 木匠
easy - mason - 泥瓦匠
easy - welder - 焊工
easy - miner - 矿工
easy - sailor - 水手
easy - captain - 船长
easy - guard - 保安
easy - detective - 侦探
easy - spy - 间谍
easy - soldier - 士兵
easy - general - 将军
easy - president - 总统
easy - mayor - 市长
easy - governor - 州长
easy - minister - 部长
medium - surgeon - 外科医生
medium - physician - 内科医生
medium - pediatrician - 儿科医生
medium - psychiatrist - 精神科医生
medium - psychologist - 心理学家
medium - therapist - 治疗师
medium - pharmacist - 药剂师
medium - optician - 验光师
medium - radiologist - 放射科医生
medium - anesthesiologist - 麻醉师
medium - paramedic - 护理人员
medium - midwife - 助产士
medium - nutritionist - 营养师
medium - dietitian - 营养师
medium - chiropractor - 脊椎按摩师
medium - acupuncturist - 针灸师
medium - physiotherapist - 物理治疗师
medium - occupational therapist - 职业治疗师
medium - speech therapist - 言语治疗师
medium - counselor - 顾问
medium - social worker - 社会工作者
medium - prosecutor - 检察官
medium - attorney - 律师
medium - solicitor - 律师
medium - barrister - 出庭律师
medium - notary - 公证人
medium - paralegal - 律师助理
medium - bailiff - 法警
medium - magistrate - 治安法官
medium - accountant - 会计师
medium - auditor - 审计师
medium - bookkeeper - 簿记员
medium - cashier - 收银员
medium - teller - 出纳员
medium - broker - 经纪人
medium - trader - 交易员
medium - investor - 投资者
medium - analyst - 分析师
medium - consultant - 顾问
medium - advisor - 顾问
medium - economist - 经济学家
medium - statistician - 统计学家
medium - actuary - 精算师
medium - underwriter - 承销商
medium - realtor - 房地产经纪人
medium - appraiser - 评估师
medium - surveyor - 测量师
medium - inspector - 检查员
medium - superintendent - 主管
medium - supervisor - 监督员
medium - foreman - 工头
medium - coordinator - 协调员
medium - administrator - 管理员
medium - executive - 高管
medium - director - 董事
medium - CEO - 首席执行官
medium - CFO - 首席财务官
medium - CTO - 首席技术官
medium - entrepreneur - 企业家
medium - proprietor - 业主
medium - franchisee - 特许经营者
medium - retailer - 零售商
medium - wholesaler - 批发商
medium - distributor - 经销商
medium - vendor - 供应商
medium - merchant - 商人
medium - salesman - 销售员
medium - representative - 代表
medium - agent - 代理人
medium - ambassador - 大使
medium - diplomat - 外交官
medium - consul - 领事
medium - attaché - 专员
medium - envoy - 使节
medium - delegate - 代表
medium - legislator - 立法者
medium - senator - 参议员
medium - congressman - 国会议员
medium - councilor - 议员
medium - alderman - 市议员
medium - commissioner - 专员
medium - bureaucrat - 官僚
medium - civil servant - 公务员
medium - clerk - 文员
medium - receptionist - 接待员
medium - typist - 打字员
medium - stenographer - 速记员
medium - transcriptionist - 转录员
hard - oncologist - 肿瘤学家
hard - cardiologist - 心脏病专家
hard - neurologist - 神经科医生
hard - dermatologist - 皮肤科医生
hard - ophthalmologist - 眼科医生
hard - otolaryngologist - 耳鼻喉科医生
hard - urologist - 泌尿科医生
hard - gynecologist - 妇科医生
hard - obstetrician - 产科医生
hard - orthopedist - 骨科医生
hard - rheumatologist - 风湿病专家
hard - endocrinologist - 内分泌学家
hard - gastroenterologist - 胃肠病学家
hard - nephrologist - 肾病学家
hard - pulmonologist - 肺病学家
hard - hematologist - 血液学家
hard - immunologist - 免疫学家
hard - pathologist - 病理学家
hard - epidemiologist - 流行病学家
hard - toxicologist - 毒理学家
hard - forensic scientist - 法医
hard - coroner - 验尸官
hard - mortician - 殡仪师
hard - embalmer - 防腐师
hard - undertaker - 殡葬承办人
hard - sommelier - 侍酒师
hard - barista - 咖啡师
hard - bartender - 调酒师
hard - mixologist - 调酒师
hard - chef - 主厨
hard - pastry chef - 糕点师
hard - sous chef - 副厨师长
hard - line cook - 厨师
hard - prep cook - 备菜厨师
hard - dishwasher - 洗碗工
hard - busboy - 餐厅杂工
hard - maitre d' - 餐厅领班
hard - sommelier - 品酒师
hard - caterer - 承办酒席者
hard - food critic - 美食评论家
hard - choreographer - 编舞家
hard - conductor - 指挥家
hard - composer - 作曲家
hard - lyricist - 作词家
hard - arranger - 编曲家
hard - producer - 制作人
hard - sound engineer - 音响师
hard - roadie - 巡回演出工作人员
hard - stagehand - 舞台工作人员
hard - gaffer - 灯光师
hard - grip - 场务
hard - cinematographer - 摄影师
hard - director of photography - 摄影指导
hard - screenwriter - 编剧
hard - playwright - 剧作家
hard - dramaturg - 戏剧顾问
hard - impresario - 演出经理
hard - curator - 策展人
hard - archivist - 档案管理员
hard - conservator - 文物修复师
hard - restorer - 修复师
hard - taxidermist - 标本剥制师
hard - cartographer - 制图师
hard - geographer - 地理学家
hard - geologist - 地质学家
hard - seismologist - 地震学家
hard - volcanologist - 火山学家
hard - meteorologist - 气象学家
hard - climatologist - 气候学家
hard - oceanographer - 海洋学家
hard - hydrologist - 水文学家
hard - ecologist - 生态学家
hard - botanist - 植物学家
hard - zoologist - 动物学家
hard - entomologist - 昆虫学家
hard - ornithologist - 鸟类学家
hard - ichthyologist - 鱼类学家
hard - herpetologist - 爬行动物学家
hard - mammalogist - 哺乳动物学家
hard - primatologist - 灵长类动物学家
hard - paleontologist - 古生物学家
hard - archaeologist - 考古学家
hard - anthropologist - 人类学家
hard - ethnographer - 民族志学者
hard - sociologist - 社会学家
hard - demographer - 人口学家
hard - criminologist - 犯罪学家
hard - penologist - 刑罚学家
hard - lexicographer - 词典编纂者
hard - philologist - 语言学家
hard - linguist - 语言学家
hard - etymologist - 词源学家
hard - phonetician - 语音学家
hard - semanticist - 语义学家
hard - syntactician - 句法学家
hard - grammarian - 语法学家
hard - rhetorician - 修辞学家
hard - logician - 逻辑学家
hard - epistemologist - 认识论学者
hard - metaphysician - 形而上学家
hard - ethicist - 伦理学家
hard - aesthetician - 美学家
hard - theologian - 神学家
@@ -0,0 +1,315 @@
easy - football - 足球
easy - basketball - 篮球
easy - volleyball - 排球
easy - tennis - 网球
easy - baseball - 棒球
easy - soccer - 足球
easy - running - 跑步
easy - swimming - 游泳
easy - cycling - 骑自行车
easy - skating - 滑冰
easy - skiing - 滑雪
easy - boxing - 拳击
easy - wrestling - 摔跤
easy - golf - 高尔夫
easy - bowling - 保龄球
easy - fishing - 钓鱼
easy - hiking - 徒步
easy - climbing - 攀登
easy - jumping - 跳跃
easy - throwing - 投掷
easy - kicking - 踢
easy - catching - 接球
easy - shooting - 射击
easy - riding - 骑马
easy - racing - 赛跑
easy - jogging - 慢跑
easy - walking - 步行
easy - dancing - 跳舞
easy - yoga - 瑜伽
easy - gymnastics - 体操
easy - diving - 跳水
easy - surfing - 冲浪
easy - sailing - 帆船
easy - rowing - 划船
easy - kayaking - 皮划艇
easy - canoeing - 独木舟
easy - rafting - 漂流
easy - water skiing - 滑水
easy - ice skating - 滑冰
easy - figure skating - 花样滑冰
easy - speed skating - 速滑
easy - hockey - 曲棍球
easy - ice hockey - 冰球
easy - field hockey - 曲棍球
easy - lacrosse - 长曲棍球
easy - cricket - 板球
easy - badminton - 羽毛球
easy - table tennis - 乒乓球
easy - ping pong - 乒乓球
easy - squash - 壁球
easy - racquetball - 壁球
easy - handball - 手球
easy - dodgeball - 躲避球
easy - kickball - 踢球
easy - softball - 垒球
easy - archery - 射箭
easy - darts - 飞镖
easy - billiards - 台球
easy - pool - 台球
easy - snooker - 斯诺克
easy - chess - 国际象棋
easy - checkers - 跳棋
easy - cards - 纸牌
easy - poker - 扑克
easy - mahjong - 麻将
easy - dominos - 多米诺
easy - dice - 骰子
easy - backgammon - 西洋双陆棋
medium - martial arts - 武术
medium - karate - 空手道
medium - judo - 柔道
medium - taekwondo - 跆拳道
medium - kung fu - 功夫
medium - aikido - 合气道
medium - jiu-jitsu - 柔术
medium - kickboxing - 自由搏击
medium - muay thai - 泰拳
medium - capoeira - 卡波耶拉
medium - fencing - 击剑
medium - kendo - 剑道
medium - sumo - 相扑
medium - weightlifting - 举重
medium - powerlifting - 力量举
medium - bodybuilding - 健美
medium - crossfit - 综合体能训练
medium - aerobics - 有氧运动
medium - pilates - 普拉提
medium - zumba - 尊巴
medium - spinning - 动感单车
medium - treadmill - 跑步机
medium - elliptical - 椭圆机
medium - stepper - 踏步机
medium - rowing machine - 划船机
medium - trampoline - 蹦床
medium - parkour - 跑酷
medium - skateboarding - 滑板
medium - rollerblading - 轮滑
medium - scootering - 滑板车
medium - BMX - 小轮车
medium - mountain biking - 山地自行车
medium - road cycling - 公路自行车
medium - track cycling - 场地自行车
medium - triathlon - 铁人三项
medium - decathlon - 十项全能
medium - pentathlon - 五项全能
medium - heptathlon - 七项全能
medium - marathon - 马拉松
medium - half marathon - 半程马拉松
medium - sprint - 短跑
medium - hurdles - 跨栏
medium - relay - 接力赛
medium - long jump - 跳远
medium - high jump - 跳高
medium - triple jump - 三级跳
medium - pole vault - 撑杆跳
medium - shot put - 铅球
medium - discus - 铁饼
medium - javelin - 标枪
medium - hammer throw - 链球
medium - steeplechase - 障碍赛跑
medium - racewalking - 竞走
medium - equestrian - 马术
medium - dressage - 盛装舞步
medium - show jumping - 障碍赛
medium - eventing - 三日赛
medium - polo - 马球
medium - rodeo - 牛仔竞技
medium - bull riding - 骑公牛
medium - barrel racing - 绕桶赛
medium - lasso - 套索
medium - rock climbing - 攀岩
medium - bouldering - 抱石
medium - ice climbing - 攀冰
medium - mountaineering - 登山
medium - rappelling - 绳降
medium - caving - 洞穴探险
medium - spelunking - 洞穴探险
medium - canyoning - 溪降
medium - bungee jumping - 蹦极
medium - skydiving - 跳伞
medium - paragliding - 滑翔伞
medium - hang gliding - 悬挂滑翔
medium - base jumping - 低空跳伞
medium - wingsuit flying - 翼装飞行
medium - hot air ballooning - 热气球
medium - gliding - 滑翔
medium - soaring - 滑翔
medium - scuba diving - 水肺潜水
medium - snorkeling - 浮潜
medium - freediving - 自由潜水
medium - spearfishing - 鱼叉捕鱼
medium - windsurfing - 帆板
medium - kitesurfing - 风筝冲浪
medium - wakeboarding - 尾波滑水
medium - bodyboarding - 趴板冲浪
medium - standup paddleboarding - 站立式桨板
medium - jet skiing - 水上摩托
medium - water polo - 水球
medium - synchronized swimming - 花样游泳
medium - backstroke - 仰泳
medium - breaststroke - 蛙泳
medium - butterfly - 蝶泳
medium - freestyle - 自由泳
medium - medley - 混合泳
medium - relay race - 接力赛
medium - bobsled - 雪橇
medium - luge - 无舵雪橇
medium - skeleton - 钢架雪车
medium - curling - 冰壶
medium - biathlon - 冬季两项
medium - cross-country skiing - 越野滑雪
medium - alpine skiing - 高山滑雪
medium - downhill - 速降滑雪
medium - slalom - 回转滑雪
medium - giant slalom - 大回转
medium - super-G - 超级大回转
medium - ski jumping - 跳台滑雪
medium - freestyle skiing - 自由式滑雪
medium - moguls - 雪上技巧
medium - aerial skiing - 空中技巧
medium - snowboarding - 单板滑雪
medium - halfpipe - 半管
medium - slopestyle - 坡面障碍技巧
medium - snowshoeing - 雪鞋行走
medium - sledding - 雪橇
medium - tobogganing - 平底雪橇
hard - orienteering - 定向越野
hard - rogaining - 长距离定向
hard - geocaching - 寻宝
hard - trail running - 越野跑
hard - ultramarathon - 超级马拉松
hard - skyrunning - 天空跑
hard - fell running - 山地跑
hard - obstacle course racing - 障碍赛跑
hard - adventure racing - 探险赛
hard - rallying - 拉力赛
hard - motocross - 越野摩托
hard - supercross - 室内越野摩托
hard - enduro - 耐力赛
hard - speedway - 摩托车赛
hard - drag racing - 直线竞速
hard - autocross - 场地赛
hard - time trial - 计时赛
hard - criterium - 绕圈赛
hard - velodrome - 赛车场
hard - keirin - 竞轮
hard - omnium - 全能赛
hard - madison - 麦迪逊赛
hard - pursuit - 追逐赛
hard - scratch race - 记分赛
hard - points race - 积分赛
hard - elimination race - 淘汰赛
hard - sprint - 争先赛
hard - team sprint - 团队竞速
hard - kilo - 千米赛
hard - madison - 麦迪逊赛
hard - boardsailing - 帆板
hard - iceboat racing - 冰上帆船
hard - land sailing - 陆上帆船
hard - yacht racing - 游艇赛
hard - regatta - 帆船赛
hard - match racing - 对抗赛
hard - fleet racing - 舰队赛
hard - offshore racing - 近海赛
hard - keel boat - 龙骨船
hard - dinghy - 小艇
hard - catamaran - 双体船
hard - trimaran - 三体船
hard - monohull - 单体船
hard - multihull - 多体船
hard - icebreaker - 破冰船
hard - outrigger - 舷外支架
hard - canoe sprint - 皮划艇激流回旋
hard - canoe slalom - 皮划艇激流回旋
hard - wildwater canoeing - 激流皮划艇
hard - canoe marathon - 皮划艇马拉松
hard - dragon boat - 龙舟
hard - outrigger canoe - 舷外支架独木舟
hard - stand-up paddleboarding - 站立式桨板
hard - waveski - 冲浪艇
hard - sea kayaking - 海上皮划艇
hard - whitewater kayaking - 激流皮划艇
hard - playboating - 花式皮划艇
hard - squirt boating - 喷射式皮划艇
hard - canoeing - 独木舟
hard - rafting - 漂流
hard - hydrospeed - 激流冲浪
hard - bodyboarding - 趴板
hard - riverboarding - 河流冲浪
hard - kneeboarding - 跪板冲浪
hard - barefoot waterskiing - 赤脚滑水
hard - slalom skiing - 滑水回转
hard - trick skiing - 花式滑水
hard - jump skiing - 跳跃滑水
hard - speed skiing - 速度滑雪
hard - telemark - 泰勒马克滑雪
hard - backcountry skiing - 野外滑雪
hard - heli-skiing - 直升机滑雪
hard - cat skiing - 雪地车滑雪
hard - ski mountaineering - 滑雪登山
hard - ski touring - 滑雪旅行
hard - Nordic combined - 北欧两项
hard - ski flying - 滑雪飞行
hard - ski ballet - 滑雪芭蕾
hard - acro skiing - 特技滑雪
hard - ski cross - 滑雪越野
hard - boardercross - 单板滑雪越野
hard - banked slalom - 倾斜回转
hard - parallel giant slalom - 平行大回转
hard - parallel slalom - 平行回转
hard - snowboard cross - 单板滑雪越野
hard - big air - 大跳台
hard - quarterpipe - 四分之一管
hard - superpipe - 超级半管
hard - jibbing - 道具滑雪
hard - rail sliding - 滑轨
hard - box sliding - 滑箱
hard - butter - 转体
hard - cork - 空翻转体
hard - rodeo - 侧空翻
hard - misty - 倒转
hard - flat spin - 平转
hard - bio - 后空翻
hard - cab - 反脚转体
hard - switch - 反脚滑行
hard - fakie - 倒滑
hard - nollie - 前轮翘起
hard - ollie - 豚跳
hard - kickflip - 踢翻
hard - heelflip - 后跟翻
hard - pop shove-it - 跳转
hard - boardslide - 板滑
hard - lipslide - 边滑
hard - noseslide - 前端滑
hard - tailslide - 后端滑
hard - bluntslide - 钝滑
hard - noseblunt - 前端钝滑
hard - 5-0 grind - 后轮滑
hard - 50-50 grind - 双轮滑
hard - nosegrind - 前轮滑
hard - crooked grind - 斜滑
hard - overcrook - 过度斜滑
hard - smith grind - 史密斯滑
hard - feeble grind - 虚弱滑
hard - salad grind - 沙拉滑
hard - willy grind - 威利滑
hard - suski grind - 苏斯基滑
hard - hurricane - 飓风转
hard - frontside - 正面转
hard - backside - 背面转
hard - revert - 反转
hard - half-cab - 半程转
hard - full-cab - 全程转
hard - caballerial - 卡巴列里尔转
@@ -0,0 +1,396 @@
easy - computer - 电脑
easy - phone - 电话
easy - tablet - 平板电脑
easy - laptop - 笔记本电脑
easy - keyboard - 键盘
easy - mouse - 鼠标
easy - screen - 屏幕
easy - monitor - 显示器
easy - printer - 打印机
easy - camera - 相机
easy - TV - 电视
easy - radio - 收音机
easy - speaker - 扬声器
easy - headphones - 耳机
easy - microphone - 麦克风
easy - remote - 遥控器
easy - charger - 充电器
easy - battery - 电池
easy - cable - 电缆
easy - plug - 插头
easy - socket - 插座
easy - switch - 开关
easy - button - 按钮
easy - app - 应用程序
easy - website - 网站
easy - email - 电子邮件
easy - internet - 互联网
easy - wifi - 无线网络
easy - bluetooth - 蓝牙
easy - GPS - 全球定位系统
easy - video - 视频
easy - photo - 照片
easy - music - 音乐
easy - game - 游戏
easy - software - 软件
easy - hardware - 硬件
easy - disk - 磁盘
easy - file - 文件
easy - folder - 文件夹
easy - icon - 图标
easy - cursor - 光标
easy - click - 点击
easy - download - 下载
easy - upload - 上传
easy - save - 保存
easy - delete - 删除
easy - copy - 复制
easy - paste - 粘贴
easy - cut - 剪切
easy - undo - 撤销
easy - redo - 重做
easy - search - 搜索
easy - zoom - 缩放
easy - scroll - 滚动
easy - password - 密码
easy - login - 登录
easy - logout - 登出
easy - profile - 个人资料
easy - settings - 设置
easy - menu - 菜单
easy - window - 窗口
easy - tab - 标签页
easy - link - 链接
easy - message - 消息
easy - notification - 通知
easy - alarm - 闹钟
easy - calculator - 计算器
easy - calendar - 日历
easy - clock - 时钟
easy - timer - 计时器
medium - smartphone - 智能手机
medium - smartwatch - 智能手表
medium - desktop - 台式电脑
medium - server - 服务器
medium - router - 路由器
medium - modem - 调制解调器
medium - hub - 集线器
medium - switch - 交换机
medium - firewall - 防火墙
medium - access point - 接入点
medium - repeater - 中继器
medium - adapter - 适配器
medium - converter - 转换器
medium - splitter - 分配器
medium - amplifier - 放大器
medium - receiver - 接收器
medium - transmitter - 发射器
medium - antenna - 天线
medium - satellite dish - 卫星天线
medium - webcam - 网络摄像头
medium - scanner - 扫描仪
medium - copier - 复印机
medium - fax machine - 传真机
medium - projector - 投影仪
medium - touchscreen - 触摸屏
medium - stylus - 触控笔
medium - trackpad - 触控板
medium - joystick - 操纵杆
medium - gamepad - 游戏手柄
medium - controller - 控制器
medium - console - 游戏机
medium - VR headset - VR头显
medium - drone - 无人机
medium - robot - 机器人
medium - sensor - 传感器
medium - actuator - 执行器
medium - chip - 芯片
medium - processor - 处理器
medium - CPU - 中央处理器
medium - GPU - 图形处理器
medium - RAM - 随机存取存储器
medium - ROM - 只读存储器
medium - motherboard - 主板
medium - circuit board - 电路板
medium - hard drive - 硬盘
medium - SSD - 固态硬盘
medium - flash drive - 闪存盘
medium - memory card - 存储卡
medium - CD - 光盘
medium - DVD - 数字光盘
medium - blu-ray - 蓝光光盘
medium - USB - USB接口
medium - HDMI - HDMI接口
medium - ethernet - 以太网
medium - fiber optic - 光纤
medium - cloud - 云
medium - database - 数据库
medium - server - 服务器
medium - network - 网络
medium - protocol - 协议
medium - encryption - 加密
medium - firewall - 防火墙
medium - antivirus - 杀毒软件
medium - malware - 恶意软件
medium - virus - 病毒
medium - spam - 垃圾邮件
medium - phishing - 网络钓鱼
medium - hacking - 黑客攻击
medium - cybersecurity - 网络安全
medium - backup - 备份
medium - restore - 恢复
medium - update - 更新
medium - upgrade - 升级
medium - patch - 补丁
medium - bug - 漏洞
medium - crash - 崩溃
medium - freeze - 冻结
medium - lag - 延迟
medium - bandwidth - 带宽
medium - latency - 延迟
medium - throughput - 吞吐量
medium - packet - 数据包
medium - ping - 延迟测试
medium - download speed - 下载速度
medium - upload speed - 上传速度
medium - streaming - 流媒体
medium - buffering - 缓冲
medium - compression - 压缩
medium - resolution - 分辨率
medium - pixel - 像素
medium - DPI - 每英寸点数
medium - refresh rate - 刷新率
medium - frame rate - 帧率
medium - aspect ratio - 纵横比
medium - contrast - 对比度
medium - brightness - 亮度
medium - saturation - 饱和度
medium - hue - 色调
medium - RGB - 红绿蓝
medium - CMYK - 青品黄黑
medium - codec - 编解码器
medium - format - 格式
medium - extension - 扩展名
medium - metadata - 元数据
medium - thumbnail - 缩略图
medium - preview - 预览
medium - rendering - 渲染
medium - graphics - 图形
medium - animation - 动画
medium - simulation - 模拟
medium - modeling - 建模
medium - texture - 纹理
medium - shader - 着色器
medium - polygon - 多边形
medium - vector - 矢量
medium - raster - 栅格
medium - bitmap - 位图
medium - interface - 界面
medium - dashboard - 仪表板
medium - toolbar - 工具栏
medium - sidebar - 侧边栏
medium - dropdown - 下拉菜单
medium - checkbox - 复选框
medium - radio button - 单选按钮
medium - slider - 滑块
medium - toggle - 切换
medium - tooltip - 工具提示
medium - popup - 弹窗
medium - modal - 模态框
medium - dialog - 对话框
medium - alert - 警报
medium - banner - 横幅
medium - widget - 小部件
medium - plugin - 插件
medium - extension - 扩展
medium - addon - 附加组件
medium - module - 模块
medium - library - 库
medium - framework - 框架
medium - API - 应用程序接口
medium - SDK - 软件开发工具包
medium - IDE - 集成开发环境
medium - compiler - 编译器
medium - interpreter - 解释器
medium - debugger - 调试器
medium - version control - 版本控制
medium - repository - 仓库
medium - commit - 提交
medium - branch - 分支
medium - merge - 合并
medium - pull request - 拉取请求
medium - code review - 代码审查
medium - testing - 测试
medium - deployment - 部署
medium - DevOps - 开发运维
medium - container - 容器
medium - virtual machine - 虚拟机
medium - hypervisor - 虚拟机监控程序
medium - cloud computing - 云计算
medium - SaaS - 软件即服务
medium - PaaS - 平台即服务
medium - IaaS - 基础设施即服务
hard - microprocessor - 微处理器
hard - microcontroller - 微控制器
hard - FPGA - 现场可编程门阵列
hard - ASIC - 专用集成电路
hard - SoC - 片上系统
hard - ALU - 算术逻辑单元
hard - FPU - 浮点运算单元
hard - cache - 缓存
hard - register - 寄存器
hard - pipeline - 流水线
hard - instruction set - 指令集
hard - architecture - 架构
hard - x86 - x86架构
hard - ARM - ARM架构
hard - RISC - 精简指令集
hard - CISC - 复杂指令集
hard - BIOS - 基本输入输出系统
hard - UEFI - 统一可扩展固件接口
hard - bootloader - 引导加载程序
hard - kernel - 内核
hard - operating system - 操作系统
hard - driver - 驱动程序
hard - firmware - 固件
hard - middleware - 中间件
hard - runtime - 运行时
hard - virtual memory - 虚拟内存
hard - paging - 分页
hard - segmentation - 分段
hard - multithreading - 多线程
hard - multiprocessing - 多处理
hard - parallel computing - 并行计算
hard - distributed computing - 分布式计算
hard - cluster - 集群
hard - load balancing - 负载均衡
hard - failover - 故障转移
hard - redundancy - 冗余
hard - replication - 复制
hard - sharding - 分片
hard - partitioning - 分区
hard - indexing - 索引
hard - caching - 缓存
hard - query - 查询
hard - transaction - 事务
hard - ACID - 原子性一致性隔离性持久性
hard - normalization - 规范化
hard - denormalization - 反规范化
hard - SQL - 结构化查询语言
hard - NoSQL - 非关系型数据库
hard - relational database - 关系型数据库
hard - document database - 文档数据库
hard - key-value store - 键值存储
hard - graph database - 图数据库
hard - time series database - 时序数据库
hard - data warehouse - 数据仓库
hard - data lake - 数据湖
hard - ETL - 提取转换加载
hard - data mining - 数据挖掘
hard - machine learning - 机器学习
hard - deep learning - 深度学习
hard - neural network - 神经网络
hard - convolutional network - 卷积神经网络
hard - recurrent network - 循环神经网络
hard - transformer - 变换器
hard - attention mechanism - 注意力机制
hard - reinforcement learning - 强化学习
hard - supervised learning - 监督学习
hard - unsupervised learning - 无监督学习
hard - semi-supervised learning - 半监督学习
hard - transfer learning - 迁移学习
hard - overfitting - 过拟合
hard - underfitting - 欠拟合
hard - regularization - 正则化
hard - hyperparameter - 超参数
hard - gradient descent - 梯度下降
hard - backpropagation - 反向传播
hard - activation function - 激活函数
hard - loss function - 损失函数
hard - optimizer - 优化器
hard - epoch - 训练轮次
hard - batch - 批次
hard - inference - 推理
hard - training - 训练
hard - validation - 验证
hard - testing - 测试
hard - accuracy - 准确率
hard - precision - 精确率
hard - recall - 召回率
hard - F1 score - F1分数
hard - confusion matrix - 混淆矩阵
hard - ROC curve - ROC曲线
hard - AUC - 曲线下面积
hard - natural language processing - 自然语言处理
hard - computer vision - 计算机视觉
hard - speech recognition - 语音识别
hard - text generation - 文本生成
hard - image classification - 图像分类
hard - object detection - 物体检测
hard - semantic segmentation - 语义分割
hard - instance segmentation - 实例分割
hard - face recognition - 人脸识别
hard - pose estimation - 姿态估计
hard - optical character recognition - 光学字符识别
hard - sentiment analysis - 情感分析
hard - named entity recognition - 命名实体识别
hard - part-of-speech tagging - 词性标注
hard - dependency parsing - 依存句法分析
hard - constituency parsing - 短语结构分析
hard - coreference resolution - 共指消解
hard - word embedding - 词嵌入
hard - tokenization - 分词
hard - lemmatization - 词形还原
hard - stemming - 词干提取
hard - stop words - 停用词
hard - n-gram - n元语法
hard - bag of words - 词袋模型
hard - TF-IDF - 词频-逆文档频率
hard - skip-gram - 跳字模型
hard - CBOW - 连续词袋
hard - seq2seq - 序列到序列
hard - encoder-decoder - 编码器-解码器
hard - LSTM - 长短期记忆网络
hard - GRU - 门控循环单元
hard - BERT - 双向编码器表示
hard - GPT - 生成式预训练
hard - diffusion model - 扩散模型
hard - GAN - 生成对抗网络
hard - VAE - 变分自编码器
hard - autoencoder - 自编码器
hard - residual network - 残差网络
hard - batch normalization - 批归一化
hard - layer normalization - 层归一化
hard - dropout - 随机失活
hard - data augmentation - 数据增强
hard - feature extraction - 特征提取
hard - dimensionality reduction - 降维
hard - principal component analysis - 主成分分析
hard - t-SNE - t分布随机邻域嵌入
hard - clustering - 聚类
hard - k-means - k均值
hard - hierarchical clustering - 层次聚类
hard - DBSCAN - 基于密度的聚类
hard - anomaly detection - 异常检测
hard - outlier detection - 离群值检测
hard - recommendation system - 推荐系统
hard - collaborative filtering - 协同过滤
hard - content-based filtering - 基于内容的过滤
hard - matrix factorization - 矩阵分解
hard - ensemble learning - 集成学习
hard - bagging - 袋装法
hard - boosting - 提升法
hard - random forest - 随机森林
hard - decision tree - 决策树
hard - support vector machine - 支持向量机
hard - naive bayes - 朴素贝叶斯
hard - logistic regression - 逻辑回归
hard - linear regression - 线性回归
hard - polynomial regression - 多项式回归
hard - ridge regression - 岭回归
hard - lasso regression - Lasso回归
hard - cross-validation - 交叉验证
hard - grid search - 网格搜索
hard - random search - 随机搜索
hard - Bayesian optimization - 贝叶斯优化
@@ -0,0 +1,380 @@
easy - car - 汽车
easy - bus - 公共汽车
easy - truck - 卡车
easy - van - 货车
easy - taxi - 出租车
easy - bike - 自行车
easy - motorcycle - 摩托车
easy - scooter - 小型摩托车
easy - train - 火车
easy - subway - 地铁
easy - tram - 有轨电车
easy - boat - 船
easy - ship - 轮船
easy - plane - 飞机
easy - helicopter - 直升机
easy - rocket - 火箭
easy - bicycle - 自行车
easy - tricycle - 三轮车
easy - skateboard - 滑板
easy - rollerblades - 轮滑
easy - sled - 雪橇
easy - sleigh - 雪橇
easy - wagon - 马车
easy - cart - 手推车
easy - carriage - 马车
easy - stroller - 婴儿车
easy - wheelchair - 轮椅
easy - ambulance - 救护车
easy - fire truck - 消防车
easy - police car - 警车
easy - limousine - 豪华轿车
easy - sports car - 跑车
easy - sedan - 轿车
easy - coupe - 双门轿车
easy - convertible - 敞篷车
easy - minivan - 小型货车
easy - SUV - 运动型多用途车
easy - jeep - 吉普车
easy - pickup - 皮卡
easy - trailer - 拖车
easy - camper - 露营车
easy - RV - 房车
easy - motorhome - 房车
easy - tractor - 拖拉机
easy - bulldozer - 推土机
easy - crane - 起重机
easy - excavator - 挖掘机
easy - dump truck - 自卸车
easy - cement mixer - 水泥搅拌车
easy - forklift - 叉车
easy - golf cart - 高尔夫球车
easy - go-kart - 卡丁车
easy - rickshaw - 人力车
easy - canoe - 独木舟
easy - kayak - 皮划艇
easy - rowboat - 划艇
easy - sailboat - 帆船
easy - yacht - 游艇
easy - ferry - 渡轮
easy - cruise ship - 游轮
easy - submarine - 潜艇
easy - jet ski - 水上摩托
easy - surfboard - 冲浪板
easy - glider - 滑翔机
easy - balloon - 气球
easy - blimp - 飞艇
easy - zeppelin - 齐柏林飞艇
medium - hatchback - 掀背车
medium - station wagon - 旅行车
medium - crossover - 跨界车
medium - roadster - 敞篷跑车
medium - hardtop - 硬顶车
medium - fastback - 快背车
medium - notchback - 阶背车
medium - landau - 活顶轿车
medium - brougham - 轿式马车
medium - phaeton - 敞篷车
medium - cabriolet - 敞篷车
medium - targa - 塔尔加车顶
medium - speedster - 快速跑车
medium - grand tourer - 豪华旅行车
medium - muscle car - 肌肉车
medium - hot hatch - 热舱车
medium - dragster - 直线竞速车
medium - funny car - 趣味车
medium - stock car - 改装赛车
medium - rally car - 拉力赛车
medium - race car - 赛车
medium - formula car - 方程式赛车
medium - touring car - 房车赛车
medium - prototype - 原型车
medium - concept car - 概念车
medium - hybrid - 混合动力车
medium - electric car - 电动车
medium - fuel cell - 燃料电池车
medium - diesel - 柴油车
medium - four-wheel drive - 四轮驱动
medium - all-wheel drive - 全轮驱动
medium - front-wheel drive - 前轮驱动
medium - rear-wheel drive - 后轮驱动
medium - manual transmission - 手动变速器
medium - automatic transmission - 自动变速器
medium - stick shift - 手动档
medium - clutch - 离合器
medium - gearbox - 变速箱
medium - engine - 发动机
medium - motor - 马达
medium - turbocharger - 涡轮增压器
medium - supercharger - 机械增压器
medium - carburetor - 化油器
medium - fuel injection - 燃油喷射
medium - radiator - 散热器
medium - alternator - 交流发电机
medium - battery - 电池
medium - starter - 启动器
medium - spark plug - 火花塞
medium - piston - 活塞
medium - crankshaft - 曲轴
medium - camshaft - 凸轮轴
medium - cylinder - 气缸
medium - exhaust pipe - 排气管
medium - muffler - 消音器
medium - catalytic converter - 催化转换器
medium - suspension - 悬挂系统
medium - shock absorber - 减震器
medium - strut - 支柱
medium - spring - 弹簧
medium - axle - 车轴
medium - differential - 差速器
medium - driveshaft - 传动轴
medium - transmission - 变速器
medium - brake - 刹车
medium - disc brake - 盘式制动器
medium - drum brake - 鼓式制动器
medium - brake pad - 刹车片
medium - brake shoe - 刹车蹄
medium - steering wheel - 方向盘
medium - power steering - 动力转向
medium - rack and pinion - 齿轮齿条
medium - tie rod - 拉杆
medium - ball joint - 球头
medium - control arm - 控制臂
medium - stabilizer bar - 稳定杆
medium - sway bar - 防倾杆
medium - bumper - 保险杠
medium - fender - 挡泥板
medium - hood - 引擎盖
medium - trunk - 后备箱
medium - tailgate - 尾门
medium - windshield - 挡风玻璃
medium - sunroof - 天窗
medium - moonroof - 月光顶
medium - side mirror - 后视镜
medium - rearview mirror - 后视镜
medium - headlight - 前大灯
medium - taillight - 尾灯
medium - turn signal - 转向灯
medium - hazard light - 警示灯
medium - fog light - 雾灯
medium - horn - 喇叭
medium - windshield wiper - 雨刷
medium - washer fluid - 洗涤液
medium - air conditioning - 空调
medium - heater - 暖气
medium - defroster - 除霜器
medium - dashboard - 仪表板
medium - speedometer - 速度计
medium - odometer - 里程表
medium - tachometer - 转速表
medium - fuel gauge - 油量表
medium - temperature gauge - 温度计
medium - airbag - 安全气囊
medium - seatbelt - 安全带
medium - child seat - 儿童座椅
medium - armrest - 扶手
medium - cup holder - 杯架
medium - glove compartment - 手套箱
medium - center console - 中控台
medium - parking brake - 手刹
medium - pedal - 踏板
medium - accelerator - 油门
medium - gas pedal - 油门踏板
medium - brake pedal - 刹车踏板
medium - clutch pedal - 离合器踏板
medium - wheel - 轮子
medium - rim - 轮毂
medium - tire - 轮胎
medium - hubcap - 轮毂盖
medium - spare tire - 备胎
medium - lug nut - 螺母
medium - valve stem - 气门嘴
medium - tread - 胎面
medium - sidewall - 胎侧
medium - alignment - 定位
medium - balancing - 平衡
medium - rotation - 轮换
medium - motorcycle - 摩托车
medium - cruiser - 巡航摩托
medium - sportbike - 运动摩托
medium - touring bike - 旅行摩托
medium - chopper - 斩波摩托
medium - bobber - 短尾摩托
medium - cafe racer - 咖啡赛车
medium - scrambler - 攀爬摩托
medium - dual-sport - 双运动摩托
medium - adventure bike - 探险摩托
medium - dirt bike - 越野摩托
medium - motocross - 越野摩托
medium - enduro - 耐力摩托
medium - trial bike - 障碍赛摩托
medium - supermoto - 超级摩托
medium - naked bike - 裸车
medium - standard - 标准摩托
medium - moped - 轻便摩托
medium - scooter - 踏板摩托
medium - vespa - 维斯帕
medium - minibike - 迷你摩托
medium - pocket bike - 袖珍摩托
medium - pit bike - 小轮摩托
medium - quad - 四轮摩托
medium - ATV - 全地形车
medium - UTV - 多功能车
medium - dune buggy - 沙滩车
medium - snowmobile - 雪地摩托
medium - jet ski - 水上摩托
medium - waverunner - 水上摩托
medium - personal watercraft - 私人水上摩托
hard - landaulet - 半敞篷车
hard - limousine - 豪华轿车
hard - stretch limo - 加长豪华轿车
hard - town car - 城市轿车
hard - shooting brake - 猎装车
hard - estate car - 旅行车
hard - woody wagon - 木质旅行车
hard - panel van - 厢式货车
hard - cargo van - 货运厢式车
hard - delivery van - 送货车
hard - box truck - 厢式卡车
hard - flatbed - 平板卡车
hard - semi-truck - 半挂车
hard - tractor-trailer - 牵引拖车
hard - big rig - 大型卡车
hard - articulated lorry - 铰接式卡车
hard - tanker truck - 罐车
hard - refrigerated truck - 冷藏车
hard - mobile crane - 移动起重机
hard - tower crane - 塔式起重机
hard - gantry crane - 门式起重机
hard - overhead crane - 桥式起重机
hard - jib crane - 悬臂起重机
hard - telescopic crane - 伸缩式起重机
hard - crawler crane - 履带起重机
hard - rough terrain crane - 全地形起重机
hard - backhoe - 反铲挖掘机
hard - front loader - 前装载机
hard - wheel loader - 轮式装载机
hard - skid steer - 滑移装载机
hard - bobcat - 山猫装载机
hard - grader - 平地机
hard - road roller - 压路机
hard - steamroller - 蒸汽压路机
hard - compactor - 压实机
hard - paver - 摊铺机
hard - asphalt paver - 沥青摊铺机
hard - milling machine - 铣刨机
hard - trencher - 开沟机
hard - pile driver - 打桩机
hard - drill rig - 钻机
hard - cherry picker - 高空作业车
hard - bucket truck - 斗车
hard - boom lift - 臂式升降机
hard - scissor lift - 剪叉式升降机
hard - aerial work platform - 高空作业平台
hard - tugboat - 拖船
hard - towboat - 拖船
hard - pushboat - 推船
hard - barge - 驳船
hard - lighter - 驳船
hard - freighter - 货船
hard - cargo ship - 货船
hard - container ship - 集装箱船
hard - bulk carrier - 散货船
hard - tanker - 油轮
hard - supertanker - 超级油轮
hard - VLCC - 超大型油轮
hard - ULCC - 超巨型油轮
hard - LNG carrier - 液化天然气船
hard - reefer ship - 冷藏船
hard - ro-ro ship - 滚装船
hard - vehicle carrier - 汽车运输船
hard - livestock carrier - 牲畜运输船
hard - dredger - 挖泥船
hard - hopper dredger - 耙吸挖泥船
hard - cutter suction dredger - 绞吸挖泥船
hard - trailing suction dredger - 拖吸挖泥船
hard - fishing vessel - 渔船
hard - trawler - 拖网渔船
hard - longliner - 延绳钓船
hard - purse seiner - 围网渔船
hard - factory ship - 加工船
hard - whaler - 捕鲸船
hard - icebreaker - 破冰船
hard - research vessel - 科考船
hard - survey vessel - 测量船
hard - cable layer - 电缆铺设船
hard - pipe layer - 管道铺设船
hard - crane vessel - 起重船
hard - floating crane - 浮吊
hard - platform supply vessel - 平台供应船
hard - anchor handling tug - 锚作拖船
hard - fireboat - 消防船
hard - pilot boat - 引水船
hard - patrol boat - 巡逻艇
hard - coast guard cutter - 海岸警卫艇
hard - customs boat - 海关艇
hard - lifeboat - 救生艇
hard - rescue boat - 救援艇
hard - inflatable boat - 充气艇
hard - rigid inflatable boat - 硬式充气艇
hard - pontoon boat - 浮筒船
hard - houseboat - 游艇
hard - narrowboat - 窄船
hard - canal boat - 运河船
hard - river boat - 江船
hard - paddle steamer - 明轮船
hard - sternwheeler - 尾轮船
hard - sidewheeler - 侧轮船
hard - propeller - 螺旋桨
hard - screw - 螺旋桨
hard - thruster - 推进器
hard - bow thruster - 艏侧推器
hard - stern thruster - 艉侧推器
hard - azimuth thruster - 全回转推进器
hard - waterjet - 喷水推进
hard - paddle - 桨
hard - oar - 桨
hard - rudder - 舵
hard - keel - 龙骨
hard - hull - 船体
hard - bow - 船头
hard - stern - 船尾
hard - port - 左舷
hard - starboard - 右舷
hard - deck - 甲板
hard - bridge - 驾驶台
hard - wheelhouse - 驾驶室
hard - helm - 舵
hard - anchor - 锚
hard - mooring - 系泊
hard - hawser - 缆绳
hard - winch - 绞车
hard - capstan - 绞盘
hard - davit - 吊艇架
hard - gangway - 舷梯
hard - companionway - 升降口
hard - hatch - 舱口
hard - hold - 货舱
hard - ballast - 压舱物
hard - bulkhead - 舱壁
hard - berth - 泊位
hard - cabin - 舱室
hard - galley - 厨房
hard - mess - 餐厅
hard - head - 厕所
hard - quarter - 船尾
hard - forecastle - 前甲板
hard - poop deck - 尾楼甲板
hard - flight deck - 飞行甲板
hard - hangar deck - 机库甲板
hard - gun deck - 炮甲板
hard - orlop deck - 最下层甲板
hard - tween deck - 中层甲板
hard - promenade deck - 散步甲板
hard - sun deck - 日光浴甲板
hard - boat deck - 救生艇甲板
hard - weather deck - 露天甲板
hard - upper deck - 上甲板
hard - main deck - 主甲板
hard - lower deck - 下甲板
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import NewGameModal from './components/NewGameModal.vue'
import WordModal from './components/WordModal.vue'
import { PRESET_TOPICS, wordlistUrl } from './logic/topics'
import { parseWordlist, pickWords, wordKeyOf, type Word } from './logic/wordlist'
import { addSeen, loadState, saveState, type GameState } from './logic/storage'
const game = ref<GameState | null>(null)
const seenWords = ref<string[]>([])
const showConfig = ref(false)
const showWord = ref(false)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
onMounted(() => {
const s = loadState()
game.value = s.game
seenWords.value = s.seenWords
})
watch(
[game, seenWords],
() => saveState({ game: game.value, seenWords: seenWords.value }),
{ deep: true },
)
const currentWord = computed<Word | null>(() => {
if (!game.value) return null
if (game.value.currentIndex >= game.value.queue.length) return null
return game.value.queue[game.value.currentIndex]
})
const isComplete = computed(() => {
if (!game.value) return false
return game.value.currentIndex >= game.value.queue.length
})
const remaining = computed(() => {
if (!game.value) return 0
return Math.max(0, game.value.queue.length - game.value.currentIndex)
})
function difficultyLabel(d: 1 | 2 | 3): string {
return d === 1 ? '简单' : d === 2 ? '中等' : '困难'
}
async function fetchTopic(topic: string): Promise<Word[]> {
const res = await fetch(wordlistUrl(topic))
if (!res.ok) throw new Error(`无法加载主题 ${topic}${res.status}`)
return parseWordlist(await res.text())
}
async function loadPool(topic: string | null): Promise<Word[]> {
if (topic) {
return await fetchTopic(topic)
}
const all = await Promise.all(PRESET_TOPICS.map((t) => fetchTopic(t.value).catch(() => [] as Word[])))
return all.flat()
}
async function onStart(cfg: { topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }) {
loading.value = true
errorMsg.value = null
try {
const pool = await loadPool(cfg.topic)
if (pool.length === 0) {
errorMsg.value = `主题 "${cfg.topic ?? 'any'}" 没有可用单词`
return
}
const seenSet = new Set(seenWords.value)
const picked = pickWords(pool, cfg.difficulty, cfg.totalWords, seenSet)
if (picked.length === 0) {
errorMsg.value = '无法生成单词,请换一个主题或难度'
return
}
game.value = {
config: { topic: cfg.topic, difficulty: cfg.difficulty, totalWords: cfg.totalWords },
queue: picked,
currentIndex: 0,
correctCount: 0,
passCount: 0,
}
seenWords.value = addSeen(seenWords.value, picked.map(wordKeyOf))
showConfig.value = false
} catch (e) {
errorMsg.value = (e as Error).message
} finally {
loading.value = false
}
}
function onCorrect() {
if (!game.value || isComplete.value) return
game.value = {
...game.value,
correctCount: game.value.correctCount + 1,
currentIndex: game.value.currentIndex + 1,
}
if (isComplete.value) showWord.value = false
}
function onPass() {
if (!game.value || isComplete.value) return
game.value = {
...game.value,
passCount: game.value.passCount + 1,
currentIndex: game.value.currentIndex + 1,
}
if (isComplete.value) showWord.value = false
}
function reset() {
if (!confirm('确定重置游戏?')) return
game.value = null
}
</script>
<template>
<main>
<header class="topbar">
<h1>🎴 Articulate</h1>
<div class="actions">
<button v-if="game" class="ghost" @click="reset">重置</button>
<button class="primary" @click="showConfig = true">{{ game ? '新一轮' : '开始游戏' }}</button>
</div>
</header>
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<section v-if="loading" class="hint-block">
<p>加载词库中...</p>
</section>
<section v-else-if="!game" class="hint-block">
<p>中英猜词游戏选好主题难度词数 一人描述全队猜</p>
<p class="dim">看到中文不能说英文 / 看到英文不能说中文猜对按 跳过按 </p>
</section>
<section v-else class="board">
<div v-if="!isComplete && currentWord" class="word" @click="showWord = true">
<div class="zh">{{ currentWord.chinese }}</div>
<div class="en">{{ currentWord.english }}</div>
</div>
<div v-else class="done">
<div class="emoji">🎉</div>
<h2>游戏结束</h2>
<p>所有单词已完成</p>
</div>
<div class="actions-row">
<button class="ok" :disabled="isComplete" @click="onCorrect">
<span class="ic"></span> 猜对了
</button>
<button class="warn" :disabled="isComplete" @click="onPass">
<span class="ic"></span> 跳过
</button>
</div>
<div class="stats">
<div class="stat ok"><div class="lbl">猜对</div><div class="val">{{ game.correctCount }}</div></div>
<div class="stat warn"><div class="lbl">跳过</div><div class="val">{{ game.passCount }}</div></div>
<div class="stat info"><div class="lbl">剩余</div><div class="val">{{ remaining }}</div></div>
</div>
<div class="info">
<span v-if="game.config.topic">主题{{ game.config.topic }}</span>
<span>难度{{ difficultyLabel(game.config.difficulty) }}</span>
<span>已记忆 {{ seenWords.length }} </span>
</div>
</section>
<WordModal
:show="showWord"
:word="currentWord"
:is-complete="isComplete"
@close="showWord = false"
@correct="onCorrect"
@pass="onPass"
/>
<NewGameModal
:show="showConfig"
:initial="game?.config"
@close="showConfig = false"
@start="onStart"
/>
</main>
</template>
<style scoped>
main {
max-width: 600px;
margin: 0 auto;
padding: 16px 16px 40px;
}
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.5rem; }
.actions { display: flex; gap: 8px; }
button.primary { background: var(--accent); border: none; color: white; padding: 10px 16px; border-radius: 8px; font-weight: bold; }
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--fg); padding: 10px 14px; border-radius: 8px; }
.error { background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.4); color: #ff8080; padding: 10px 14px; border-radius: 8px; }
.hint-block { padding: 30px; text-align: center; background: var(--bg-soft); border-radius: 12px; color: var(--fg-dim); }
.hint-block .dim { font-size: 0.85rem; opacity: 0.7; margin-top: 12px; }
.word {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 28px 20px;
text-align: center;
cursor: pointer;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
}
.zh { font-size: 3rem; font-weight: bold; line-height: 1.1; margin-bottom: 8px; }
.en { font-size: 2rem; opacity: 0.9; line-height: 1.1; }
.done {
text-align: center;
background: var(--bg-soft);
border-radius: 16px;
padding: 40px;
margin-bottom: 20px;
}
.done .emoji { font-size: 60px; margin-bottom: 8px; }
.done h2 { margin: 4px 0; }
.actions-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;
}
.actions-row button {
padding: 18px;
border: none;
border-radius: 12px;
color: white;
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.actions-row button.ok { background: linear-gradient(135deg, #4CAF50, #45a049); }
.actions-row button.warn { background: linear-gradient(135deg, #FF9800, #F57C00); }
.actions-row button:disabled { background: #444; color: #888; }
.ic { font-size: 1.1rem; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.stat {
background: var(--bg-soft);
border-radius: 8px;
padding: 10px 12px;
border-left: 4px solid var(--border);
}
.stat.ok { border-left-color: var(--accent); }
.stat.warn { border-left-color: var(--warn); }
.stat.info { border-left-color: #2196F3; }
.lbl { font-size: 0.78rem; color: var(--fg-dim); }
.val { font-size: 1.2rem; font-weight: bold; }
.info {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.85rem;
color: var(--fg-dim);
padding: 12px;
background: var(--bg-soft);
border-radius: 8px;
justify-content: center;
}
</style>
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { addSeen } from '../logic/storage'
describe('addSeen', () => {
it('appends new keys to the end', () => {
expect(addSeen(['a', 'b'], ['c', 'd'])).toEqual(['a', 'b', 'c', 'd'])
})
it('deduplicates existing keys', () => {
expect(addSeen(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c'])
})
it('handles all-duplicate addition', () => {
expect(addSeen(['a', 'b'], ['a', 'b'])).toEqual(['a', 'b'])
})
it('respects MAX_SEEN cap (5000) by trimming oldest', () => {
const existing = Array.from({ length: 5000 }, (_, i) => `k${i}`)
const out = addSeen(existing, ['new1', 'new2'])
expect(out).toHaveLength(5000)
expect(out[out.length - 1]).toBe('new2')
expect(out[out.length - 2]).toBe('new1')
// 最旧的 'k0' 和 'k1' 被挤出
expect(out.includes('k0')).toBe(false)
expect(out.includes('k1')).toBe(false)
})
it('empty input', () => {
expect(addSeen([], [])).toEqual([])
expect(addSeen([], ['a'])).toEqual(['a'])
expect(addSeen(['a'], [])).toEqual(['a'])
})
})
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
difficultyLevels,
parseWordlist,
parseWordlistLine,
pickWords,
wordKeyOf,
type Rng,
type Word,
} from '../logic/wordlist'
function mulberry32(seed: number): Rng {
let a = seed
return {
next() {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
},
}
}
describe('parseWordlistLine', () => {
it('parses a valid easy line', () => {
expect(parseWordlistLine('easy - run - 跑')).toEqual({
difficulty: 'easy',
english: 'run',
chinese: '跑',
})
})
it('parses medium / hard', () => {
expect(parseWordlistLine('medium - hop - 单脚跳')?.difficulty).toBe('medium')
expect(parseWordlistLine('hard - perambulate - 漫步')?.difficulty).toBe('hard')
})
it('rejects empty / whitespace lines', () => {
expect(parseWordlistLine('')).toBeNull()
expect(parseWordlistLine(' ')).toBeNull()
})
it('rejects wrong field count', () => {
expect(parseWordlistLine('easy - run')).toBeNull()
expect(parseWordlistLine('easy - run - 跑 - extra')).toBeNull()
})
it('rejects unknown difficulty', () => {
expect(parseWordlistLine('insane - run - 跑')).toBeNull()
})
it('rejects empty english/chinese', () => {
expect(parseWordlistLine('easy - - 跑')).toBeNull()
expect(parseWordlistLine('easy - run - ')).toBeNull()
})
})
describe('parseWordlist', () => {
it('parses multi-line text and skips bad lines', () => {
const text = ['easy - run - 跑', '', 'invalid line', 'medium - hop - 单脚跳', 'easy - walk - 走'].join('\n')
const out = parseWordlist(text)
expect(out).toHaveLength(3)
expect(out.map((w) => w.english)).toEqual(['run', 'hop', 'walk'])
})
it('handles CRLF line endings', () => {
const text = 'easy - run - 跑\r\neasy - walk - 走\r\n'
expect(parseWordlist(text)).toHaveLength(2)
})
})
describe('difficultyLevels', () => {
it('1 = easy only', () => {
expect(difficultyLevels(1)).toEqual(['easy'])
})
it('2 = easy + medium', () => {
expect(difficultyLevels(2)).toEqual(['easy', 'medium'])
})
it('3 = all three', () => {
expect(difficultyLevels(3)).toEqual(['easy', 'medium', 'hard'])
})
})
describe('pickWords', () => {
const pool: Word[] = [
{ difficulty: 'easy', english: 'run', chinese: '跑' },
{ difficulty: 'easy', english: 'walk', chinese: '走' },
{ difficulty: 'easy', english: 'jump', chinese: '跳' },
{ difficulty: 'medium', english: 'hop', chinese: '单脚跳' },
{ difficulty: 'hard', english: 'perambulate', chinese: '漫步' },
]
it('respects difficulty level filter', () => {
const out = pickWords(pool, 1, 10, new Set(), mulberry32(1))
expect(out.every((w) => w.difficulty === 'easy')).toBe(true)
expect(out).toHaveLength(3)
})
it('includes easy + medium for difficulty 2', () => {
const out = pickWords(pool, 2, 10, new Set(), mulberry32(1))
expect(out).toHaveLength(4)
expect(out.some((w) => w.difficulty === 'hard')).toBe(false)
})
it('prefers unseen words', () => {
const seen = new Set(['walk|走', 'jump|跳'])
const out = pickWords(pool, 1, 1, seen, mulberry32(1))
// 唯一未见过的 easy 词是 'run'
expect(out).toHaveLength(1)
expect(out[0].english).toBe('run')
})
it('falls back to seen words when unseen pool runs out', () => {
const seen = new Set(['run|跑', 'walk|走', 'jump|跳'])
const out = pickWords(pool, 1, 3, seen, mulberry32(1))
expect(out).toHaveLength(3) // all from seen since no unseen left
})
it('returns at most `count` words', () => {
const out = pickWords(pool, 3, 2, new Set(), mulberry32(1))
expect(out).toHaveLength(2)
})
it('handles empty pool', () => {
const out = pickWords([], 2, 10, new Set(), mulberry32(1))
expect(out).toEqual([])
})
it('is deterministic given a deterministic rng', () => {
const a = pickWords(pool, 3, 3, new Set(), mulberry32(7))
const b = pickWords(pool, 3, 3, new Set(), mulberry32(7))
expect(a).toEqual(b)
})
})
describe('wordKeyOf', () => {
it('produces stable english|chinese key', () => {
expect(wordKeyOf({ difficulty: 'easy', english: 'run', chinese: '跑' })).toBe('run|跑')
})
})
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { PRESET_TOPICS } from '../logic/topics'
const props = defineProps<{
show: boolean
initial?: { topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }
}>()
const emit = defineEmits<{
close: []
start: [{ topic: string | null; difficulty: 1 | 2 | 3; totalWords: number }]
}>()
const topic = ref<string>(props.initial?.topic ?? '')
const difficulty = ref<1 | 2 | 3>(props.initial?.difficulty ?? 2)
const wordCount = ref<number>(props.initial?.totalWords ?? 30)
const wordCountOptions = [5, 10, 15, 20, 25, 30]
const randomFlag = ref(false)
watch(
() => props.show,
(s) => {
if (s && props.initial) {
topic.value = props.initial.topic ?? ''
difficulty.value = props.initial.difficulty
wordCount.value = props.initial.totalWords
randomFlag.value = false
}
},
)
const isRandomSelected = computed(() => randomFlag.value && PRESET_TOPICS.some((t) => t.value === topic.value))
const summaryTopic = computed(() => {
if (!topic.value) return '任意(所有主题)'
const preset = PRESET_TOPICS.find((t) => t.value === topic.value)
return preset ? preset.label : topic.value
})
function selectPreset(v: string) {
topic.value = v
randomFlag.value = false
}
function selectRandom() {
topic.value = PRESET_TOPICS[Math.floor(Math.random() * PRESET_TOPICS.length)].value
randomFlag.value = true
}
function selectAny() {
topic.value = ''
randomFlag.value = false
}
function difficultyLabel(d: 1 | 2 | 3): string {
return d === 1 ? '简单' : d === 2 ? '中等' : '困难'
}
function start() {
emit('start', {
topic: topic.value || null,
difficulty: difficulty.value,
totalWords: wordCount.value,
})
}
</script>
<template>
<div v-if="show" class="overlay" @click.self="$emit('close')">
<div class="modal">
<header>
<h2>Articulate · 新游戏</h2>
<button class="x" @click="$emit('close')"></button>
</header>
<section>
<label>主题</label>
<div class="row">
<button
v-for="p in PRESET_TOPICS"
:key="p.value"
:class="['chip', { selected: topic === p.value && !isRandomSelected }]"
@click="selectPreset(p.value)"
>
{{ p.label }}
</button>
<button :class="['chip', { selected: isRandomSelected }]" @click="selectRandom">随机</button>
<button :class="['chip', { selected: topic === '' && !isRandomSelected }]" @click="selectAny">任意</button>
</div>
<input
v-model="topic"
type="text"
placeholder="或输入自定义主题名(如 'animals'"
class="text"
/>
</section>
<section>
<label>难度</label>
<div class="row">
<button
v-for="d in ([1, 2, 3] as const)"
:key="d"
:class="['chip', { selected: difficulty === d }]"
@click="difficulty = d"
>
{{ difficultyLabel(d) }}
</button>
</div>
</section>
<section>
<label>单词数量</label>
<div class="row">
<button
v-for="n in wordCountOptions"
:key="n"
:class="['chip', { selected: wordCount === n }]"
@click="wordCount = n"
>
{{ n }}
</button>
</div>
</section>
<div class="summary">
<div>主题<b>{{ summaryTopic }}</b></div>
<div>难度<b>{{ difficultyLabel(difficulty) }}</b></div>
<div>词数<b>{{ wordCount }}</b></div>
</div>
<footer>
<button @click="$emit('close')" class="cancel">取消</button>
<button @click="start" class="ok">开始游戏</button>
</footer>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex; align-items: flex-start; justify-content: center;
z-index: 1500; padding: 16px; overflow-y: auto;
}
.modal {
background: #1a2027;
border: 1px solid var(--border);
border-radius: 12px;
width: 100%; max-width: 600px;
padding: 16px;
margin: 16px 0;
}
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
h2 { margin: 0; font-size: 1.3rem; }
section { margin-bottom: 18px; }
label { display: block; margin-bottom: 8px; color: var(--fg-dim); font-weight: 500; }
.row { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
padding: 8px 12px;
border: 2px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
border-radius: 8px;
font-size: 0.9rem;
}
.chip.selected { background: var(--accent); border-color: var(--accent); color: white; }
.text {
width: 100%;
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
font-size: 0.95rem;
}
.summary {
background: var(--bg-soft);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
button.x {
width: 32px; height: 32px;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
border: 1px solid var(--border);
}
button.cancel {
background: var(--bg-soft);
border: 1px solid var(--border);
color: var(--fg);
padding: 10px 18px;
border-radius: 8px;
}
button.ok {
background: var(--accent);
border: none;
color: white;
padding: 10px 18px;
border-radius: 8px;
font-weight: bold;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import type { Word } from '../logic/wordlist'
const props = defineProps<{
show: boolean
word: Word | null
isComplete: boolean
}>()
const emit = defineEmits<{
close: []
correct: []
pass: []
}>()
function onKey(e: KeyboardEvent) {
if (!props.show) return
if (e.key === 'Enter') {
e.preventDefault()
emit('correct')
} else if (e.key === ' ') {
e.preventDefault()
emit('pass')
} else if (e.key === 'Escape') {
e.preventDefault()
emit('close')
}
}
watch(
() => props.show,
(s) => {
if (s) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
},
)
onMounted(() => {
if (props.show) window.addEventListener('keydown', onKey)
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div v-if="show" class="overlay" @click="$emit('close')">
<button class="close" @click.stop="$emit('close')"></button>
<div class="word" v-if="!isComplete && word">
<div class="chinese">{{ word.chinese }}</div>
<div class="english">{{ word.english }}</div>
</div>
<div v-else class="done">
<div class="emoji">🎉</div>
<p>所有单词已完成</p>
</div>
<div class="hint">Enter 猜对 · Space 跳过 · Esc 关闭</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
flex-direction: column;
}
.close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.15);
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
font-size: clamp(20px, 3vw, 32px);
padding: 10px 18px;
border-radius: 8px;
}
.word {
text-align: center;
padding: 0 20px;
max-width: 100%;
}
.chinese {
font-size: clamp(56px, 20vw, 300px);
font-weight: bold;
line-height: 1.1;
margin-bottom: clamp(16px, 5vh, 60px);
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
word-break: break-word;
}
.english {
font-size: clamp(36px, 12vw, 200px);
font-weight: 600;
opacity: 0.9;
line-height: 1.1;
word-break: break-word;
}
.done { text-align: center; }
.done .emoji { font-size: 80px; margin-bottom: 12px; }
.hint {
position: absolute;
bottom: 16px;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
}
@media (max-width: 768px) {
.hint { font-size: 12px; }
}
</style>
@@ -0,0 +1,65 @@
import type { Word } from './wordlist'
export interface GameConfig {
topic: string | null
difficulty: 1 | 2 | 3
totalWords: number
}
export interface GameState {
config: GameConfig
queue: Word[]
currentIndex: number
correctCount: number
passCount: number
}
interface PersistedState {
game: GameState | null
seenWords: string[] // wordKey list
}
const KEY = 'articulate:v1'
const MAX_SEEN = 5000
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
export function loadState(): PersistedState {
if (!isBrowser()) return { game: null, seenWords: [] }
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return { game: null, seenWords: [] }
const parsed = JSON.parse(raw) as Partial<PersistedState>
return {
game: parsed.game ?? null,
seenWords: parsed.seenWords ?? [],
}
} catch {
return { game: null, seenWords: [] }
}
}
export function saveState(state: PersistedState): void {
if (!isBrowser()) return
try {
// 限制 seen list 大小
const seen = state.seenWords.slice(-MAX_SEEN)
window.localStorage.setItem(KEY, JSON.stringify({ ...state, seenWords: seen }))
} catch {
// ignore
}
}
export function addSeen(seen: string[], keys: string[]): string[] {
const set = new Set(seen)
const out = [...seen]
for (const k of keys) {
if (!set.has(k)) {
set.add(k)
out.push(k)
}
}
return out.slice(-MAX_SEEN)
}
@@ -0,0 +1,28 @@
// preset topic 列表与 wordlist 文件路径。
export interface PresetTopic {
value: string
label: string
}
export const PRESET_TOPICS: PresetTopic[] = [
{ value: 'animals', label: '动物' },
{ value: 'food', label: '食物' },
{ value: 'places', label: '地点' },
{ value: 'objects', label: '物品' },
{ value: 'actions', label: '动作' },
{ value: 'colors', label: '颜色' },
{ value: 'emotions', label: '情感' },
{ value: 'sports', label: '运动' },
{ value: 'professions', label: '职业' },
{ value: 'nature', label: '自然' },
{ value: 'body', label: '身体' },
{ value: 'clothing', label: '服装' },
{ value: 'vehicles', label: '交通工具' },
{ value: 'music', label: '音乐' },
{ value: 'technology', label: '科技' },
]
export function wordlistUrl(topic: string): string {
return `/wordlists/${encodeURIComponent(topic)}.txt`
}
@@ -0,0 +1,85 @@
// Wordlist 解析 + 抽词。纯函数,可单测。
export interface Word {
difficulty: 'easy' | 'medium' | 'hard'
english: string
chinese: string
}
export interface Rng {
next(): number
}
export const defaultRng: Rng = { next: () => Math.random() }
/** 解析一行 "easy - run - 跑"。空行或格式错误返回 null。 */
export function parseWordlistLine(line: string): Word | null {
const s = line.trim()
if (!s) return null
const parts = s.split(' - ').map((p) => p.trim())
if (parts.length !== 3) return null
const [difficulty, english, chinese] = parts
if (difficulty !== 'easy' && difficulty !== 'medium' && difficulty !== 'hard') return null
if (!english || !chinese) return null
return { difficulty, english, chinese }
}
/** 解析一整个 wordlist 文件文本。跳过空行 / 格式错误行。 */
export function parseWordlist(text: string): Word[] {
return text.split(/\r?\n/).map(parseWordlistLine).filter((w): w is Word => w !== null)
}
/** difficulty 数字 → 包含的难度等级。 */
export function difficultyLevels(d: 1 | 2 | 3): Word['difficulty'][] {
if (d === 1) return ['easy']
if (d === 2) return ['easy', 'medium']
return ['easy', 'medium', 'hard']
}
function wordKey(w: Word): string {
return `${w.english}|${w.chinese}`
}
function shuffle<T>(arr: T[], rng: Rng): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng.next() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
/**
* 从 pool 抽 count 个词,优先未见过的。
* 如果未见过的不够,用见过的补足,避免凑不齐。
*/
export function pickWords(
pool: Word[],
difficulty: 1 | 2 | 3,
count: number,
seen: Set<string>,
rng: Rng = defaultRng,
): Word[] {
const levels = new Set(difficultyLevels(difficulty))
const filtered = pool.filter((w) => levels.has(w.difficulty))
const unseen = filtered.filter((w) => !seen.has(wordKey(w)))
const seenShuffled = shuffle(
filtered.filter((w) => seen.has(wordKey(w))),
rng,
)
const unseenShuffled = shuffle(unseen, rng)
const picked: Word[] = []
for (const w of unseenShuffled) {
if (picked.length >= count) break
picked.push(w)
}
for (const w of seenShuffled) {
if (picked.length >= count) break
picked.push(w)
}
return picked
}
export function wordKeyOf(w: Word): string {
return wordKey(w)
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -0,0 +1,21 @@
:root {
color-scheme: dark;
--bg: #0f1419;
--bg-soft: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.15);
--fg: rgba(255, 255, 255, 0.92);
--fg-dim: rgba(255, 255, 255, 0.6);
--accent: #4caf50;
--warn: #ff9800;
--danger: #ef4444;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0; padding: 0; min-height: 100vh;
background: var(--bg);
color: var(--fg);
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
target: 'es2020',
},
test: {
globals: true,
environment: 'node',
},
})
+82
View File
@@ -0,0 +1,82 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-articulate
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: articulate
namespace: cube-articulate
labels:
app: articulate
spec:
replicas: 1
selector:
matchLabels:
app: articulate
template:
metadata:
labels:
app: articulate
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: articulate
image: registry.famzheng.me/mochi/articulate:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: articulate
namespace: cube-articulate
spec:
selector:
app: articulate
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: articulate
namespace: cube-articulate
spec:
ingressClassName: traefik
rules:
- host: articulate.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: articulate
port:
number: 80
+9
View File
@@ -0,0 +1,9 @@
//! articulate.famzheng.me — 中英猜词派对游戏。纯静态前端,无 API。
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("ARTICULATE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let app = cube_core::base(dist);
cube_core::serve(app, 8080).await
}
+5
View File
@@ -8,4 +8,9 @@ description = "cube.famzheng.me — cube 平台入口门户(app #0"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
+3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import AppCard from './components/AppCard.vue'
import Chatbot from './components/Chatbot.vue'
import { apps } from './apps'
</script>
@@ -33,6 +34,8 @@ import { apps } from './apps'
<span>cube · monorepo at</span>
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
</footer>
<Chatbot />
</main>
</template>
+72
View File
@@ -0,0 +1,72 @@
[
{
"slug": "cube",
"name": "cube",
"description": "你正在看的这个门户。cube 平台本身的入口。",
"url": "https://cube.famzheng.me",
"status": "live"
},
{
"slug": "portfolio",
"name": "portfolio",
"description": "投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。",
"url": "https://portfolio.famzheng.me",
"status": "pending"
},
{
"slug": "repo-vis",
"name": "repo-vis",
"description": "git 仓库可视化。从 oci 迁移中。",
"url": "https://repo-vis.famzheng.me",
"status": "pending"
},
{
"slug": "simpleasm",
"name": "simpleasm",
"description": "汇编教学小游戏。",
"url": "https://asm.famzheng.me",
"status": "live"
},
{
"slug": "music",
"name": "music",
"description": "听歌 + 练琴 曲目管理。243 首曲库(从 oci 旧 guitar 迁过来)+ 自动抓 yopu 吉他/功能谱 + LLM 灵感推荐。",
"url": "https://music.famzheng.me",
"status": "live"
},
{
"slug": "pyroblem",
"name": "pyroblem",
"description": "详情待补。",
"url": "https://pyroblem.famzheng.me",
"status": "tbd"
},
{
"slug": "werewolf",
"name": "werewolf",
"description": "狼人杀单机发牌器。一台手机轮流传,30 个角色、4x 偏好加权、配置历史本地记忆。从 partiverse 移植。",
"url": "https://werewolf.famzheng.me",
"status": "live"
},
{
"slug": "articulate",
"name": "articulate",
"description": "中英猜词派对游戏(Articulate)。15 个主题词库 + 3 档难度 + 已看词跨场记忆。从 partiverse 移植。",
"url": "https://articulate.famzheng.me",
"status": "live"
},
{
"slug": "karaoke",
"name": "karaoke",
"description": "卡拉OK 点歌单本地管理。增删改排 + YouTube 一键搜,10 秒撤销。从 partiverse 移植。",
"url": "https://karaoke.famzheng.me",
"status": "live"
},
{
"slug": "notes",
"name": "notes",
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + contentpassphrase 鉴权。",
"url": "https://notes.famzheng.me",
"status": "live"
}
]
+6 -44
View File
@@ -1,3 +1,8 @@
// apps 列表的 SSOT 是 apps.json — 后端 chat.rs 也 include_str! 同一份,注入到
// chatbot 的 system prompt 里。改 apps 只改 apps.json。
import data from './apps.json'
export type AppStatus = 'live' | 'pending' | 'tbd'
export interface App {
@@ -8,47 +13,4 @@ export interface App {
status: AppStatus
}
export const apps: App[] = [
{
slug: 'cube',
name: 'cube',
description: '你正在看的这个门户。cube 平台本身的入口。',
url: 'https://cube.famzheng.me',
status: 'live',
},
{
slug: 'portfolio',
name: 'portfolio',
description: '投资组合追踪。从 oci 迁移中(原 portfolio.oci.euphon.net)。',
url: 'https://portfolio.famzheng.me',
status: 'pending',
},
{
slug: 'repo-vis',
name: 'repo-vis',
description: 'git 仓库可视化。从 oci 迁移中。',
url: 'https://repo-vis.famzheng.me',
status: 'pending',
},
{
slug: 'simpleasm',
name: 'simpleasm',
description: '汇编教学小游戏。',
url: 'https://asm.famzheng.me',
status: 'live',
},
{
slug: 'guitar',
name: 'guitar',
description: '吉他 player。从 oci 迁移中(原 player.oci.euphon.net)。',
url: 'https://guitar.famzheng.me',
status: 'pending',
},
{
slug: 'pyroblem',
name: 'pyroblem',
description: '详情待补。',
url: 'https://pyroblem.famzheng.me',
status: 'tbd',
},
]
export const apps: App[] = data as App[]
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
interface Msg {
role: 'user' | 'assistant'
content: string
issue?: { number: number; url: string; title: string }
}
const open = ref(false)
const messages = ref<Msg[]>([])
const input = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const canSend = computed(() => !busy.value && input.value.trim().length > 0)
watch(messages, async () => {
await nextTick()
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight, behavior: 'smooth' })
}, { deep: true })
async function send() {
if (!canSend.value) return
const text = input.value.trim()
input.value = ''
error.value = null
messages.value.push({ role: 'user', content: text })
busy.value = true
try {
const payload = {
messages: messages.value.map((m) => ({ role: m.role, content: m.content })),
}
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const data = (await res.json()) as {
reply: string
created_issue: { number: number; url: string; title: string } | null
}
messages.value.push({
role: 'assistant',
content: data.reply,
issue: data.created_issue ?? undefined,
})
} catch (e) {
const msg = (e as Error).message
error.value = msg
messages.value.push({ role: 'assistant', content: `(出错了:${msg}` })
} finally {
busy.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
function reset() {
messages.value = []
error.value = null
}
</script>
<template>
<button v-if="!open" class="fab" @click="open = true" aria-label="打开聊天">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
</svg>
<span>反馈 / 提问</span>
</button>
<div v-else class="panel">
<header>
<div class="title">
<span class="dot" />
<strong>cube · chat</strong>
</div>
<div class="actions">
<button class="icon" @click="reset" title="清空对话"></button>
<button class="icon" @click="open = false" title="收起"></button>
</div>
</header>
<div class="thread" ref="scrollEl">
<div v-if="messages.length === 0" class="hint">
<p> cube 平台的事或反馈 bug / 想法 我会帮你提到 <code>fam/cube</code> issue</p>
</div>
<div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
<div class="content">{{ m.content }}</div>
<a v-if="m.issue" :href="m.issue.url" target="_blank" rel="noopener" class="issue-link">
已建 issue #{{ m.issue.number }}
</a>
</div>
<div v-if="busy" class="bubble assistant typing">
<span /><span /><span />
</div>
</div>
<footer>
<textarea
v-model="input"
:disabled="busy"
@keydown="onKeydown"
rows="2"
placeholder="说点什么...Enter 发送,Shift+Enter 换行)"
/>
<button class="send" :disabled="!canSend" @click="send">发送</button>
</footer>
</div>
</template>
<style scoped>
.fab {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
background: linear-gradient(135deg, #7c3aed, #06b6d4);
color: white;
border: none;
padding: 12px 18px;
border-radius: 999px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.35);
transition: transform 0.15s, box-shadow 0.15s;
}
.fab:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(124, 58, 237, 0.45); }
.panel {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
width: min(380px, calc(100vw - 32px));
height: min(560px, calc(100vh - 40px));
background: var(--bg-soft, rgba(20, 20, 30, 0.95));
backdrop-filter: blur(12px);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
border-radius: 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--fg, rgba(255, 255, 255, 0.92));
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.15));
}
.title { display: flex; align-items: center; gap: 8px; }
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.7);
}
.actions { display: flex; gap: 4px; }
.icon {
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
color: inherit;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.icon:hover { background: rgba(255, 255, 255, 0.06); }
.thread {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.hint { color: var(--fg-dim, rgba(255, 255, 255, 0.6)); font-size: 0.9rem; }
.hint code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.85em;
}
.bubble {
max-width: 85%;
padding: 10px 13px;
border-radius: 12px;
line-height: 1.4;
font-size: 0.92rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble.user {
align-self: flex-end;
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: white;
}
.bubble.assistant {
align-self: flex-start;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
}
.issue-link {
display: inline-block;
margin-top: 8px;
color: #4ea7f7;
font-size: 0.85rem;
text-decoration: none;
border-top: 1px dashed rgba(255, 255, 255, 0.15);
padding-top: 8px;
}
.issue-link:hover { color: #80c2ff; }
.bubble.typing {
display: inline-flex;
gap: 4px;
padding: 12px 14px;
}
.bubble.typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
animation: bounce 1.2s ease-in-out infinite;
}
.bubble.typing span:nth-child(2) { animation-delay: 0.15s; }
.bubble.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
30% { transform: translateY(-5px); opacity: 1; }
}
footer {
padding: 10px 12px 12px;
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.15));
display: flex;
gap: 8px;
align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
border-radius: 8px;
padding: 8px 10px;
font-family: inherit;
font-size: 0.92rem;
color: inherit;
line-height: 1.4;
}
textarea:focus { outline: 2px solid #7c3aed; outline-offset: -1px; }
.send {
background: linear-gradient(135deg, #7c3aed, #06b6d4);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.send:disabled { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.4); cursor: not-allowed; }
@media (max-width: 420px) {
.panel { right: 12px; bottom: 12px; width: calc(100vw - 24px); height: calc(100vh - 24px); }
}
</style>
-1
View File
@@ -1 +0,0 @@
{"root":["./src/apps.ts","./src/main.ts","./src/App.vue","./src/components/AppCard.vue","./vite-env.d.ts"],"version":"5.7.3"}
+97
View File
@@ -0,0 +1,97 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-cube
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cube
namespace: cube-cube
labels:
app: cube
spec:
replicas: 1
selector:
matchLabels:
app: cube
template:
metadata:
labels:
app: cube
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: cube
# tag 由 CI 通过 `kubectl set image` 替换;初次 apply 由 README 部署 runbook 指定
image: registry.famzheng.me/mochi/cube:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
envFrom:
# secret `chat-credentials` (LLM_API_TOKEN + GITEA_TOKEN) 由 kubectl 手工创建,
# 不在 git manifest 里。kubectl apply -f all.yaml 不会动它。
- secretRef:
name: chat-credentials
env:
- name: LLM_GATEWAY
value: "http://3.135.65.204:8848/v1"
- name: LLM_MODEL
value: "gemma-4-31b-it"
- name: GITEA_URL
value: "https://famzheng.me/gitea"
- name: ISSUE_REPO
value: "fam/cube"
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: cube
namespace: cube-cube
spec:
selector:
app: cube
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cube
namespace: cube-cube
spec:
ingressClassName: traefik
rules:
- host: cube.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cube
port:
number: 80
-18
View File
@@ -1,18 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cube
namespace: cube-cube
spec:
ingressClassName: traefik
rules:
- host: cube.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cube
port:
number: 80
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-cube
-12
View File
@@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: cube
namespace: cube-cube
spec:
selector:
app: cube
ports:
- name: http
port: 80
targetPort: 8080
+442
View File
@@ -0,0 +1,442 @@
//! `/api/chat` — 浏览器 ↔ LLM gateway 中转 + `create_issue` 工具调用。
//!
//! 单步 tool calling:拿到用户消息 → 调一次 LLM with tools → 如果 LLM 决定调
//! `create_issue` 就同步建 issue,把结果(issue 编号 + URL)当作 reply 返回给前端。
//! 不做 agent loop,不递归把工具结果喂回 LLM(重新调一次是浪费,issue 已经建好,
//! 直接告诉用户就行)。
use std::sync::Arc;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Clone)]
pub struct Config {
pub gateway: String, // http://3.135.65.204:8848/v1
pub llm_token: String, // Bearer for LLM gateway
pub llm_model: String, // gemma-4-31b-it
pub gitea_url: String, // https://famzheng.me/gitea
pub gitea_token: String,
pub issue_repo: String, // fam/cube
}
impl Config {
pub fn from_env() -> Self {
Self {
gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
llm_token: env_or("LLM_API_TOKEN", ""),
llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
gitea_url: env_or("GITEA_URL", "https://famzheng.me/gitea"),
gitea_token: env_or("GITEA_TOKEN", ""),
issue_repo: env_or("ISSUE_REPO", "fam/cube"),
}
}
}
fn env_or(key: &str, fallback: &str) -> String {
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChatMessage {
pub role: String, // "user" | "assistant" | "system" | "tool"
pub content: String,
}
#[derive(Deserialize)]
pub struct ChatRequest {
pub messages: Vec<ChatMessage>,
}
#[derive(Serialize)]
pub struct CreatedIssue {
pub number: u64,
pub url: String,
pub title: String,
}
#[derive(Serialize)]
pub struct ChatResponse {
pub reply: String,
pub created_issue: Option<CreatedIssue>,
}
pub enum ChatError {
UpstreamLlm(String),
UpstreamGitea(String),
Empty,
}
impl IntoResponse for ChatError {
fn into_response(self) -> axum::response::Response {
match self {
Self::UpstreamLlm(msg) => {
tracing::error!(%msg, "llm upstream failed");
(StatusCode::BAD_GATEWAY, format!("LLM upstream error: {msg}")).into_response()
}
Self::UpstreamGitea(msg) => {
tracing::error!(%msg, "gitea upstream failed");
(StatusCode::BAD_GATEWAY, format!("Gitea upstream error: {msg}")).into_response()
}
Self::Empty => (StatusCode::BAD_REQUEST, "messages 不能为空").into_response(),
}
}
}
/// apps.json 是 SSOT — 前端 apps.ts 也 import 同一份。
const APPS_JSON: &str = include_str!("../frontend/src/apps.json");
#[derive(Deserialize)]
struct AppInfo {
slug: String,
description: String,
url: String,
status: String,
}
/// 把 apps.json 渲染成 markdown bullet list 注进 system prompt。
/// 解析失败兜底成 raw JSON(让 LLM 自己 grok),不让 chatbot 因为 ssot 坏掉而 500。
pub fn render_apps_list() -> String {
match serde_json::from_str::<Vec<AppInfo>>(APPS_JSON) {
Ok(apps) => apps
.iter()
.map(|a| {
format!(
"- **{slug}** ({status}) — {desc} <{url}>",
slug = a.slug,
status = a.status,
desc = a.description,
url = a.url,
)
})
.collect::<Vec<_>>()
.join("\n"),
Err(_) => APPS_JSON.trim().to_string(),
}
}
pub fn system_prompt(repo: &str) -> String {
format!(
"你是 cube 平台(cube.famzheng.meFam 的小 app 平台)入口页上的聊天助手。\n\
\n\
当前 cube 上线的 app 列表(status: live=可用 / pending=迁移中 / tbd=待定):\n\
{apps}\n\
\n\
你可以做两件事:\n\
1. 回答用户关于上面这些 app 的问题,简短直接。**回答时只能基于上面列表的事实**,\n\
不要凭训练知识瞎猜不存在的 app 或功能。如果用户问的 app 不在列表里,直说没有。\n\
2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时,调用 `create_issue`\n\
工具,把它整理成 issue 创建到 `{repo}`。标题简洁明确(≤60 字符),body 包含足够上下文\n\
(涉及哪个 app / 重现步骤 / 期望行为 / 用户原话等)。\n\
\n\
不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\
同一次对话只创建一个 issue。Reply 要简短,不写长段散文。",
apps = render_apps_list(),
repo = repo,
)
}
pub fn create_issue_tool_schema() -> Value {
json!({
"type": "function",
"function": {
"name": "create_issue",
"description": "在 fam/cube 仓库创建一个 issue,用于收集用户反馈、bug 报告或 feature request",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue 标题,简洁明确,不超过 60 个字符"
},
"body": {
"type": "string",
"description": "Issue 正文 Markdown,包含重现步骤 / 期望行为 / 用户原话等上下文"
}
},
"required": ["title", "body"]
}
}
})
}
pub async fn handle(
State(cfg): State<Arc<Config>>,
Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, ChatError> {
if req.messages.is_empty() {
return Err(ChatError::Empty);
}
// 拼 messages:注入 system + 用户历史
let mut messages: Vec<Value> = vec![json!({
"role": "system",
"content": system_prompt(&cfg.issue_repo),
})];
for m in &req.messages {
messages.push(json!({ "role": m.role, "content": m.content }));
}
let body = json!({
"model": cfg.llm_model,
"messages": messages,
"tools": [create_issue_tool_schema()],
"tool_choice": "auto",
"stream": false,
"temperature": 0.6,
});
let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
.post(&endpoint)
.bearer_auth(&cfg.llm_token)
.json(&body)
.send()
.await
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes);
return Err(ChatError::UpstreamLlm(format!("{status}: {body}")));
}
let v: Value = serde_json::from_slice(&bytes)
.map_err(|e| ChatError::UpstreamLlm(format!("invalid json: {e}")))?;
let choice = v
.pointer("/choices/0/message")
.ok_or_else(|| ChatError::UpstreamLlm("no choices/0/message".into()))?;
// 拿 tool_calls 数组;如果有 create_issue 调用就执行,否则返回 content
if let Some(tool_call) = first_create_issue_call(choice) {
let (title, body_md) = extract_issue_args(&tool_call).map_err(ChatError::UpstreamLlm)?;
let created = create_gitea_issue(&cfg, &title, &body_md).await?;
let llm_text = choice
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let reply = if llm_text.is_empty() {
format!("已记下 → issue #{}: {}", created.number, created.title)
} else {
format!("{llm_text}\n\n→ issue #{}: {}", created.number, created.title)
};
return Ok(Json(ChatResponse {
reply,
created_issue: Some(created),
}));
}
// 没工具调用 — 普通回复
let text = choice
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
Ok(Json(ChatResponse {
reply: if text.is_empty() {
"嗯?没听清,再说一遍?".to_string()
} else {
text
},
created_issue: None,
}))
}
/// 从 LLM 返回的 message 里挑第一个 `create_issue` tool_call。
pub fn first_create_issue_call(message: &Value) -> Option<Value> {
let arr = message.get("tool_calls")?.as_array()?;
arr.iter()
.find(|tc| {
tc.pointer("/function/name").and_then(Value::as_str) == Some("create_issue")
})
.cloned()
}
/// arguments 是 JSON 字符串(OpenAI 协议),需要二次解析。
pub fn extract_issue_args(tool_call: &Value) -> Result<(String, String), String> {
let args_raw = tool_call
.pointer("/function/arguments")
.and_then(Value::as_str)
.ok_or_else(|| "tool_call 缺少 arguments".to_string())?;
let args: Value = serde_json::from_str(args_raw)
.map_err(|e| format!("arguments 不是合法 JSON: {e}"))?;
let title = args
.get("title")
.and_then(Value::as_str)
.ok_or_else(|| "tool 调用缺少 title".to_string())?
.trim()
.to_string();
let body = args
.get("body")
.and_then(Value::as_str)
.ok_or_else(|| "tool 调用缺少 body".to_string())?
.trim()
.to_string();
if title.is_empty() {
return Err("title 为空".to_string());
}
Ok((title, body))
}
async fn create_gitea_issue(
cfg: &Config,
title: &str,
body: &str,
) -> Result<CreatedIssue, ChatError> {
let url = format!(
"{}/api/v1/repos/{}/issues",
cfg.gitea_url.trim_end_matches('/'),
cfg.issue_repo
);
let body_md = format!(
"{body}\n\n---\n_via cube portal chatbot · cube.famzheng.me_"
);
let payload = json!({
"title": title,
"body": body_md,
"labels": ["chatbot"],
});
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Authorization", format!("token {}", cfg.gitea_token))
.json(&payload)
.send()
.await
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
if !status.is_success() {
return Err(ChatError::UpstreamGitea(format!(
"{status}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let issue: Value = serde_json::from_slice(&bytes)
.map_err(|e| ChatError::UpstreamGitea(format!("invalid json: {e}")))?;
Ok(CreatedIssue {
number: issue.get("number").and_then(Value::as_u64).unwrap_or(0),
url: issue
.get("html_url")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
title: issue
.get("title")
.and_then(Value::as_str)
.unwrap_or(title)
.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_prompt_includes_repo() {
let p = system_prompt("fam/cube");
assert!(p.contains("fam/cube"));
assert!(p.contains("create_issue"));
}
#[test]
fn render_apps_list_parses_ssot() {
let list = render_apps_list();
// 兜底分支会返回 raw JSON(含 `{`),正常解析后是 markdown bullet(每行以 `- ` 起头)
assert!(list.starts_with("- "), "render output should be markdown bullets, got: {list}");
// 抽查几个已知 slug 在里面
for slug in ["cube", "werewolf", "articulate", "karaoke", "music"] {
assert!(list.contains(slug), "apps list 应该含 {slug}: {list}");
}
}
#[test]
fn system_prompt_includes_apps_list() {
let p = system_prompt("fam/cube");
// 至少一个 app 的 slug 出现在 prompt 里,说明 list 真的被注进去了
assert!(p.contains("werewolf"));
assert!(p.contains("live"));
// 防回退:旧 prompt 写死了 "werewolf / articulate / karaoke / music / simpleasm"
// 现在不再用那种枚举句式 —— 列表是数据驱动的
assert!(!p.contains("werewolf / articulate / karaoke / music / simpleasm"));
}
#[test]
fn tool_schema_shape() {
let s = create_issue_tool_schema();
assert_eq!(s.pointer("/type").and_then(Value::as_str), Some("function"));
assert_eq!(
s.pointer("/function/name").and_then(Value::as_str),
Some("create_issue")
);
let req = s
.pointer("/function/parameters/required")
.and_then(Value::as_array)
.unwrap();
assert!(req.iter().any(|v| v == "title"));
assert!(req.iter().any(|v| v == "body"));
}
#[test]
fn first_create_issue_call_picks_only_matching_tool() {
let m = json!({
"tool_calls": [
{"id": "x1", "type": "function", "function": {"name": "other_tool", "arguments": "{}"}},
{"id": "x2", "type": "function", "function": {"name": "create_issue", "arguments": "{\"title\":\"t\",\"body\":\"b\"}"}}
]
});
let tc = first_create_issue_call(&m).expect("should find one");
assert_eq!(tc.pointer("/function/name").and_then(Value::as_str), Some("create_issue"));
}
#[test]
fn first_create_issue_call_returns_none_if_absent() {
let m = json!({ "tool_calls": [] });
assert!(first_create_issue_call(&m).is_none());
let m = json!({});
assert!(first_create_issue_call(&m).is_none());
}
#[test]
fn extract_issue_args_parses_string_arguments() {
let tc = json!({
"function": {
"name": "create_issue",
"arguments": "{\"title\":\"狼人杀: swipe 失灵\",\"body\":\" iOS Safari 上无法 swipe \"}"
}
});
let (t, b) = extract_issue_args(&tc).unwrap();
assert_eq!(t, "狼人杀: swipe 失灵");
assert_eq!(b, "iOS Safari 上无法 swipe");
}
#[test]
fn extract_issue_args_rejects_empty_title() {
let tc = json!({"function": {"arguments": "{\"title\":\"\",\"body\":\"x\"}"}});
assert!(extract_issue_args(&tc).is_err());
}
#[test]
fn extract_issue_args_rejects_malformed_args() {
let tc = json!({"function": {"arguments": "not json"}});
assert!(extract_issue_args(&tc).is_err());
}
#[test]
fn extract_issue_args_rejects_missing_field() {
let tc = json!({"function": {"arguments": "{\"title\":\"x\"}"}});
assert!(extract_issue_args(&tc).is_err());
}
}
+15 -2
View File
@@ -1,9 +1,22 @@
//! cube.famzheng.me — 入口门户。纯静态 SPA,没有自己的 /api 路由
//! cube.famzheng.me — 入口门户 + 反馈聊天助手
//!
//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。
//! - `POST /api/chat` 转发到 LLM gateway,工具 `create_issue` 直接调 gitea 建 issue。
mod chat;
use std::sync::Arc;
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("CUBE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let app = cube_core::base(dist);
let cfg = Arc::new(chat::Config::from_env());
let api = axum::Router::new()
.route("/chat", axum::routing::post(chat::handle))
.with_state(cfg);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "karaoke"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "karaoke.famzheng.me — 卡拉OK 点歌单本地管理(一台手机),从 partiverse 移植"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
tokio = { workspace = true }
+6
View File
@@ -0,0 +1,6 @@
# karaoke — karaoke.famzheng.me
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/karaoke /karaoke
COPY apps/karaoke/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/karaoke"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1a1a2e" />
<title>Karaoke 点歌单</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "karaoke",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vitest": "^2.1.8",
"vue-tsc": "^2.2.0"
}
}
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import AddSongModal from './components/AddSongModal.vue'
import { addSong, deleteSong, moveSong, youtubeSearchUrl, type Song } from './logic/playlist'
import { loadState, saveState } from './logic/storage'
const playlist = ref<Song[]>([])
const showAdd = ref(false)
const pendingDeletes = ref<Record<number, number>>({}) // songId -> timeoutId
const DELETE_DELAY_MS = 10_000
onMounted(() => {
playlist.value = loadState().playlist
})
watch(playlist, () => saveState({ playlist: playlist.value }), { deep: true })
onUnmounted(() => {
for (const id of Object.values(pendingDeletes.value)) clearTimeout(id)
})
function onAdd(payload: { singer: string; title: string }) {
playlist.value = addSong(playlist.value, payload.singer, payload.title)
showAdd.value = false
}
function onMove(songId: number, direction: 'up' | 'down' | 'first') {
playlist.value = moveSong(playlist.value, songId, direction)
}
function startDelete(songId: number) {
pendingDeletes.value[songId] = window.setTimeout(() => {
playlist.value = deleteSong(playlist.value, songId)
delete pendingDeletes.value[songId]
}, DELETE_DELAY_MS)
}
function cancelDelete(songId: number) {
const t = pendingDeletes.value[songId]
if (t !== undefined) {
clearTimeout(t)
delete pendingDeletes.value[songId]
}
}
function isPending(songId: number): boolean {
return pendingDeletes.value[songId] !== undefined
}
</script>
<template>
<main>
<header class="topbar">
<h1>🎤 Karaoke 点歌</h1>
<button class="primary" @click="showAdd = true">+ 添加歌曲</button>
</header>
<section v-if="playlist.length === 0" class="empty">
<p>点歌单空空如也</p>
<p class="dim">点击 "添加歌曲" 把歌排进队</p>
</section>
<section v-else class="list">
<article
v-for="(song, idx) in playlist"
:key="song.id"
:class="['item', { pending: isPending(song.id) }]"
>
<div class="meta">
<span class="idx">{{ idx + 1 }}</span>
<div class="text">
<div class="title">{{ song.title }}</div>
<div class="singer">{{ song.singer }}</div>
</div>
</div>
<div class="actions">
<a
:href="youtubeSearchUrl(song)"
target="_blank"
rel="noopener noreferrer"
class="action yt"
title="在 YouTube 搜索"
>YT</a>
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'first')" title="置顶"></button>
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'up')" title="上移"></button>
<button class="action" :disabled="idx === playlist.length - 1 || isPending(song.id)" @click="onMove(song.id, 'down')" title="下移"></button>
<button v-if="!isPending(song.id)" class="action danger" @click="startDelete(song.id)" title="删除"></button>
<button v-else class="action cancel-delete" @click="cancelDelete(song.id)">撤销</button>
</div>
<div v-if="isPending(song.id)" class="progress">
<div class="bar" />
</div>
</article>
</section>
<AddSongModal :show="showAdd" @close="showAdd = false" @add="onAdd" />
</main>
</template>
<style scoped>
main {
max-width: 720px;
margin: 0 auto;
padding: 16px 16px 40px;
}
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.5rem; background: linear-gradient(135deg, #fff, var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
button.primary { background: var(--accent); border: none; color: white; padding: 10px 16px; border-radius: 8px; font-weight: bold; }
.empty {
text-align: center;
background: var(--bg-soft);
border-radius: 12px;
padding: 60px 20px;
color: var(--fg-dim);
}
.empty .dim { font-size: 0.9rem; opacity: 0.7; margin-top: 6px; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.item.pending { opacity: 0.55; background: rgba(239, 68, 68, 0.1); }
.meta { display: flex; align-items: center; gap: 12px; min-width: 0; }
.idx {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(236, 72, 153, 0.2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
}
.text { min-width: 0; flex: 1; }
.title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.singer { color: var(--fg-dim); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.action {
min-width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
padding: 0 8px;
text-decoration: none;
font-size: 0.9rem;
}
.action.yt { color: #ff4d4d; }
.action.danger { color: var(--danger); border-color: rgba(239, 68, 68, 0.4); }
.action.cancel-delete { background: var(--accent-2); border-color: var(--accent-2); color: #000; font-size: 0.8rem; }
.progress { height: 3px; background: rgba(239, 68, 68, 0.2); border-radius: 2px; overflow: hidden; }
.bar {
height: 100%;
background: var(--danger);
animation: shrink 10s linear forwards;
}
@keyframes shrink { from { width: 100%; } to { width: 0; } }
@media (max-width: 500px) {
.actions { gap: 4px; }
.action { min-width: 32px; height: 32px; font-size: 0.85rem; }
}
</style>
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
addSong,
deleteSong,
moveSong,
nextId,
youtubeSearchUrl,
type Song,
} from '../logic/playlist'
const sample = (): Song[] => [
{ id: 1, singer: '周杰伦', title: '七里香' },
{ id: 2, singer: '林俊杰', title: '江南' },
{ id: 3, singer: '陶喆', title: '小镇姑娘' },
]
describe('nextId', () => {
it('returns 1 for empty playlist', () => {
expect(nextId([])).toBe(1)
})
it('returns max + 1', () => {
expect(nextId(sample())).toBe(4)
})
it('handles gaps correctly', () => {
expect(nextId([{ id: 7, singer: 'a', title: 'b' }])).toBe(8)
})
})
describe('addSong', () => {
it('appends to the end with next id', () => {
const out = addSong(sample(), '邓紫棋', '泡沫')
expect(out).toHaveLength(4)
expect(out[3]).toEqual({ id: 4, singer: '邓紫棋', title: '泡沫' })
})
it('trims whitespace', () => {
const out = addSong([], ' Adele ', ' Hello ')
expect(out[0]).toEqual({ id: 1, singer: 'Adele', title: 'Hello' })
})
it('rejects empty singer or title (returns unchanged)', () => {
const list = sample()
expect(addSong(list, '', 'Hello')).toBe(list)
expect(addSong(list, 'Adele', '')).toBe(list)
expect(addSong(list, ' ', ' ')).toBe(list)
})
it('does not mutate input', () => {
const list = sample()
addSong(list, 'a', 'b')
expect(list).toHaveLength(3)
})
})
describe('deleteSong', () => {
it('removes by id', () => {
const out = deleteSong(sample(), 2)
expect(out).toHaveLength(2)
expect(out.map((s) => s.id)).toEqual([1, 3])
})
it('is noop for missing id', () => {
const out = deleteSong(sample(), 999)
expect(out).toHaveLength(3)
})
it('does not mutate input', () => {
const list = sample()
deleteSong(list, 1)
expect(list).toHaveLength(3)
})
})
describe('moveSong', () => {
it("moves 'up'", () => {
const out = moveSong(sample(), 2, 'up')
expect(out.map((s) => s.id)).toEqual([2, 1, 3])
})
it("moves 'down'", () => {
const out = moveSong(sample(), 2, 'down')
expect(out.map((s) => s.id)).toEqual([1, 3, 2])
})
it("moves 'first'", () => {
const out = moveSong(sample(), 3, 'first')
expect(out.map((s) => s.id)).toEqual([3, 1, 2])
})
it("'up' on first item is noop", () => {
const out = moveSong(sample(), 1, 'up')
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
})
it("'down' on last item is noop", () => {
const out = moveSong(sample(), 3, 'down')
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
})
it("'first' on first item is noop", () => {
const out = moveSong(sample(), 1, 'first')
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
})
it('handles missing id', () => {
const out = moveSong(sample(), 999, 'up')
expect(out.map((s) => s.id)).toEqual([1, 2, 3])
})
it('preserves the song count', () => {
for (const dir of ['up', 'down', 'first'] as const) {
for (const id of [1, 2, 3]) {
const out = moveSong(sample(), id, dir)
expect(out).toHaveLength(3)
expect(out.map((s) => s.id).sort()).toEqual([1, 2, 3])
}
}
})
it('does not mutate input', () => {
const list = sample()
moveSong(list, 2, 'up')
expect(list.map((s) => s.id)).toEqual([1, 2, 3])
})
})
describe('youtubeSearchUrl', () => {
it('builds an encoded search URL', () => {
const url = youtubeSearchUrl({ id: 1, singer: '周杰伦', title: '七里香' })
expect(url.startsWith('https://www.youtube.com/results?search_query=')).toBe(true)
expect(url).toContain(encodeURIComponent('周杰伦 七里香'))
})
it('encodes special characters', () => {
const url = youtubeSearchUrl({ id: 1, singer: 'A&B', title: 'C/D' })
expect(url).toContain('A%26B')
expect(url).toContain('C%2FD')
})
})
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
add: [{ singer: string; title: string }]
}>()
const singer = ref('')
const title = ref('')
watch(
() => props.show,
(s) => {
if (s) {
singer.value = ''
title.value = ''
}
},
)
const canAdd = () => singer.value.trim() !== '' && title.value.trim() !== ''
function submit() {
if (!canAdd()) return
emit('add', { singer: singer.value.trim(), title: title.value.trim() })
}
</script>
<template>
<div v-if="show" class="overlay" @click.self="$emit('close')">
<div class="modal">
<header>
<h2>添加歌曲</h2>
<button class="x" @click="$emit('close')"></button>
</header>
<section>
<label>歌手</label>
<input v-model="singer" type="text" placeholder="周杰伦" @keydown.enter="submit" />
</section>
<section>
<label>歌名</label>
<input v-model="title" type="text" placeholder="七里香" @keydown.enter="submit" />
</section>
<footer>
<button class="cancel" @click="$emit('close')">取消</button>
<button class="ok" :disabled="!canAdd()" @click="submit">添加</button>
</footer>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex; align-items: center; justify-content: center;
z-index: 1500; padding: 16px;
}
.modal {
background: #232336;
border: 1px solid var(--border);
border-radius: 12px;
width: 100%; max-width: 480px;
padding: 16px;
}
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
h2 { margin: 0; font-size: 1.3rem; }
section { margin-bottom: 16px; }
label { display: block; margin-bottom: 6px; color: var(--fg-dim); font-weight: 500; font-size: 0.9rem; }
input {
width: 100%;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
font-size: 1rem;
}
input:focus { outline: 2px solid var(--accent); }
footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
button.x { width: 32px; height: 32px; border-radius: 6px; background: transparent; color: var(--fg-dim); border: 1px solid var(--border); }
button.cancel { background: var(--bg-soft); border: 1px solid var(--border); color: var(--fg); padding: 10px 18px; border-radius: 8px; }
button.ok { background: var(--accent); border: none; color: white; padding: 10px 18px; border-radius: 8px; font-weight: bold; }
</style>
@@ -0,0 +1,49 @@
// Playlist 不可变操作。所有函数纯,返回新数组。
export interface Song {
id: number
singer: string
title: string
}
export type Direction = 'up' | 'down' | 'first'
export function nextId(playlist: Song[]): number {
let max = 0
for (const s of playlist) if (s.id > max) max = s.id
return max + 1
}
export function addSong(playlist: Song[], singer: string, title: string): Song[] {
const s = singer.trim()
const t = title.trim()
if (!s || !t) return playlist
return [...playlist, { id: nextId(playlist), singer: s, title: t }]
}
export function deleteSong(playlist: Song[], songId: number): Song[] {
return playlist.filter((s) => s.id !== songId)
}
export function moveSong(playlist: Song[], songId: number, direction: Direction): Song[] {
const idx = playlist.findIndex((s) => s.id === songId)
if (idx === -1) return playlist
const next = [...playlist]
const [song] = next.splice(idx, 1)
if (direction === 'first') {
next.unshift(song)
} else if (direction === 'up') {
next.splice(Math.max(0, idx - 1), 0, song)
} else {
// 'down': insert at idx + 1 of original. After splice, original idx + 1
// becomes position idx in `next`. So inserting at idx puts the song before
// the element that *was* at idx + 1 — we want *after* it, hence idx + 1.
next.splice(Math.min(next.length, idx + 1), 0, song)
}
return next
}
export function youtubeSearchUrl(song: Song): string {
const q = encodeURIComponent(`${song.singer} ${song.title}`)
return `https://www.youtube.com/results?search_query=${q}`
}
@@ -0,0 +1,32 @@
import type { Song } from './playlist'
interface PersistedState {
playlist: Song[]
}
const KEY = 'karaoke:v1'
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
export function loadState(): PersistedState {
if (!isBrowser()) return { playlist: [] }
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return { playlist: [] }
const parsed = JSON.parse(raw) as Partial<PersistedState>
return { playlist: parsed.playlist ?? [] }
} catch {
return { playlist: [] }
}
}
export function saveState(state: PersistedState): void {
if (!isBrowser()) return
try {
window.localStorage.setItem(KEY, JSON.stringify(state))
} catch {
// ignore
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+23
View File
@@ -0,0 +1,23 @@
:root {
color-scheme: dark;
--bg: #1a1a2e;
--bg-soft: rgba(255, 255, 255, 0.06);
--bg-card: rgba(255, 255, 255, 0.08);
--border: rgba(255, 255, 255, 0.15);
--fg: rgba(255, 255, 255, 0.92);
--fg-dim: rgba(255, 255, 255, 0.6);
--accent: #ec4899;
--accent-2: #f59e0b;
--danger: #ef4444;
--ok: #4caf50;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0; padding: 0; min-height: 100vh;
background: var(--bg);
color: var(--fg);
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
target: 'es2020',
},
test: {
globals: true,
environment: 'node',
},
})
@@ -1,26 +1,30 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-karaoke
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cube
namespace: cube-cube
name: karaoke
namespace: cube-karaoke
labels:
app: cube
app: karaoke
spec:
replicas: 1
selector:
matchLabels:
app: cube
app: karaoke
template:
metadata:
labels:
app: cube
app: karaoke
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: cube
# tag 由 CI 通过 `kubectl set image` 替换;初次 apply 由 README 部署 runbook 指定
image: registry.famzheng.me/mochi/cube:latest
- name: karaoke
image: registry.famzheng.me/mochi/karaoke:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -44,3 +48,35 @@ spec:
limits:
cpu: 200m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: karaoke
namespace: cube-karaoke
spec:
selector:
app: karaoke
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: karaoke
namespace: cube-karaoke
spec:
ingressClassName: traefik
rules:
- host: karaoke.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: karaoke
port:
number: 80
+9
View File
@@ -0,0 +1,9 @@
//! karaoke.famzheng.me — 卡拉OK 点歌单本地管理。纯静态前端,无 API。
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("KARAOKE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let app = cube_core::base(dist);
cube_core::serve(app, 8080).await
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "llm-proxy"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "llm.famzheng.me — gemma-4-31b-it 反向代理 + token 鉴权 + /chat web UI"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llm-proxy — llm.famzheng.me
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/llm-proxy /llm-proxy
EXPOSE 8080
ENTRYPOINT ["/llm-proxy"]
+90
View File
@@ -0,0 +1,90 @@
apiVersion: v1
kind: Namespace
metadata:
name: llm-proxy
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-proxy
namespace: llm-proxy
labels:
app: llm-proxy
spec:
replicas: 1
selector:
matchLabels:
app: llm-proxy
template:
metadata:
labels:
app: llm-proxy
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: llm-proxy
image: registry.famzheng.me/mochi/llm-proxy:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
envFrom:
# secret `proxy-credentials` 由 kubectl 手工创建(BACKEND_TOKEN +
# PROXY_AUTH_TOKEN),不在 git manifest 里。
- secretRef:
name: proxy-credentials
env:
- name: LLM_GATEWAY
value: "http://3.135.65.204:8848/v1"
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: llm-proxy
namespace: llm-proxy
spec:
selector:
app: llm-proxy
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-proxy
namespace: llm-proxy
spec:
ingressClassName: traefik
rules:
- host: llm.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: llm-proxy
port:
number: 80
+160
View File
@@ -0,0 +1,160 @@
//! llm.famzheng.me — gemma-4-31b-it 反向代理 + 简单 token 鉴权。
//!
//! - `GET /` → `/chat` 跳转
//! - `GET /chat` → 静态 web UI
//! - `POST /v1/chat/completions` → OpenAI 兼容透传 (要 Authorization: token <PROXY_AUTH_TOKEN>)
//! - `GET /healthz` → 不带 auth, 给 k8s probe
mod proxy;
use std::sync::Arc;
use axum::{
extract::State,
http::{header, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use tower_http::trace::TraceLayer;
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let cfg = Arc::new(proxy::Config::from_env());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
let chat_api = Router::new()
.route("/v1/chat/completions", post(proxy::handle))
.route_layer(middleware::from_fn_with_state(cfg.clone(), require_token))
.with_state(cfg);
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/", get(|| async { Redirect::permanent("/chat") }))
.route("/chat", get(chat_ui))
.route("/favicon.svg", get(favicon))
.route("/favicon.ico", get(favicon)) // 浏览器默认会请求 .ico,让它共享同一 SVG
.merge(chat_api)
.layer(TraceLayer::new_for_http());
let addr = format!("0.0.0.0:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(%addr, "llm-proxy listening");
axum::serve(listener, app).await
}
const CHAT_HTML: &str = include_str!("../web/chat.html");
const FAVICON_SVG: &str = include_str!("../web/favicon.svg");
async fn chat_ui() -> Html<&'static str> {
Html(CHAT_HTML)
}
async fn favicon() -> impl IntoResponse {
(
[
(axum::http::header::CONTENT_TYPE, "image/svg+xml"),
(axum::http::header::CACHE_CONTROL, "public, max-age=604800"),
],
FAVICON_SVG,
)
}
/// 验 `Authorization: token <PROXY_AUTH_TOKEN>`,错的直接 401。
async fn require_token(
State(cfg): State<Arc<proxy::Config>>,
req: axum::extract::Request,
next: Next,
) -> Response {
let header_val = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(str::trim);
match header_val {
Some(v) if check_token(v, &cfg.proxy_auth_token) => next.run(req).await,
_ => (
StatusCode::UNAUTHORIZED,
"缺少或不匹配 `Authorization: token <your-token>`",
)
.into_response(),
}
}
/// 接受 `token <T>` 或 `Bearer <T>`OpenAI client 习惯发 Bearer,宽容点)。
pub fn check_token(header_value: &str, expected: &str) -> bool {
if expected.is_empty() {
return false;
}
let trimmed = header_value.trim();
if let Some(rest) = trimmed.strip_prefix("token ") {
return constant_time_eq(rest.trim().as_bytes(), expected.as_bytes());
}
if let Some(rest) = trimmed.strip_prefix("Bearer ") {
return constant_time_eq(rest.trim().as_bytes(), expected.as_bytes());
}
false
}
/// 常时间比较,防 timing attack(虽然这场景影响小,做了不亏)。
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_token_accepts_token_scheme() {
assert!(check_token("token famzheng-llm-2026", "famzheng-llm-2026"));
}
#[test]
fn check_token_accepts_bearer_scheme() {
assert!(check_token("Bearer famzheng-llm-2026", "famzheng-llm-2026"));
}
#[test]
fn check_token_rejects_wrong_value() {
assert!(!check_token("token wrong", "famzheng-llm-2026"));
}
#[test]
fn check_token_rejects_unknown_scheme() {
assert!(!check_token("Basic famzheng-llm-2026", "famzheng-llm-2026"));
assert!(!check_token("famzheng-llm-2026", "famzheng-llm-2026"));
}
#[test]
fn check_token_rejects_empty_expected() {
// 防 misconfigured:空 expected 不应该让任何人通过
assert!(!check_token("token any", ""));
assert!(!check_token("Bearer ", ""));
}
#[test]
fn check_token_strips_extra_whitespace() {
assert!(check_token(" token famzheng-llm-2026 ", "famzheng-llm-2026"));
}
#[test]
fn check_token_rejects_prefix_match() {
// 防止"famzheng-llm-2026-extra" 通过
assert!(!check_token("token famzheng-llm-2026-extra", "famzheng-llm-2026"));
assert!(!check_token("token famzheng-llm", "famzheng-llm-2026"));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! `/v1/chat/completions` 透传 — 替换 Authorization 头,把请求 body 原样 forward 到
//! 上游 LLM gateway,把响应 body 原样回吐给客户端。
//!
//! 一期只支持非 streamingforce `stream: false` 进 body),SSE 透传留给二期。
use std::sync::Arc;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct Config {
pub upstream_url: String, // http://3.135.65.204:8848/v1/chat/completions
pub upstream_token: String,
pub proxy_auth_token: String,
}
impl Config {
pub fn from_env() -> Self {
let gateway = std::env::var("LLM_GATEWAY")
.unwrap_or_else(|_| "http://3.135.65.204:8848/v1".to_string());
let upstream_url = format!("{}/chat/completions", gateway.trim_end_matches('/'));
Self {
upstream_url,
upstream_token: std::env::var("BACKEND_TOKEN").unwrap_or_default(),
proxy_auth_token: std::env::var("PROXY_AUTH_TOKEN").unwrap_or_default(),
}
}
}
pub async fn handle(State(cfg): State<Arc<Config>>, body: Bytes) -> Response {
// 1. parse body → 强制 stream=false(一期不支持流式)
let body_bytes = match force_non_stream(&body) {
Ok(b) => b,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("bad JSON body: {e}")).into_response();
}
};
// 2. forward
let client = reqwest::Client::new();
let res = client
.post(&cfg.upstream_url)
.header("Authorization", format!("Bearer {}", cfg.upstream_token))
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await;
match res {
Ok(r) => relay_response(r).await,
Err(e) => {
tracing::error!(error=%e, "upstream call failed");
(StatusCode::BAD_GATEWAY, format!("upstream error: {e}")).into_response()
}
}
}
/// parse JSON、塞入 `stream: false`、重新 serialize。
/// 如果不是 JSON object 就保持原样(让上游自己报错)。
fn force_non_stream(body: &Bytes) -> Result<Vec<u8>, String> {
if body.is_empty() {
return Err("empty body".into());
}
let mut v: Value = serde_json::from_slice(body).map_err(|e| e.to_string())?;
if let Some(obj) = v.as_object_mut() {
obj.insert("stream".to_string(), Value::Bool(false));
}
serde_json::to_vec(&v).map_err(|e| e.to_string())
}
async fn relay_response(upstream: reqwest::Response) -> Response {
let status = upstream.status();
let ct = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.cloned()
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
let bytes = match upstream.bytes().await {
Ok(b) => b,
Err(e) => {
tracing::error!(error=%e, "read upstream body");
return (StatusCode::BAD_GATEWAY, "read upstream body failed").into_response();
}
};
let mut headers = HeaderMap::new();
headers.insert(axum::http::header::CONTENT_TYPE, ct);
(
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
headers,
bytes,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn force_non_stream_overrides_stream_true() {
let input = Bytes::from(r#"{"model":"gemma","messages":[],"stream":true}"#);
let out = force_non_stream(&input).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["stream"], Value::Bool(false));
}
#[test]
fn force_non_stream_injects_when_absent() {
let input = Bytes::from(r#"{"model":"gemma","messages":[]}"#);
let out = force_non_stream(&input).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["stream"], Value::Bool(false));
}
#[test]
fn force_non_stream_preserves_other_fields() {
let input = Bytes::from(
r#"{"model":"gemma-4-31b-it","temperature":0.7,"messages":[{"role":"user","content":"hi"}]}"#,
);
let out = force_non_stream(&input).unwrap();
let v: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["model"], "gemma-4-31b-it");
assert_eq!(v["temperature"], 0.7);
assert_eq!(v["messages"][0]["role"], "user");
}
#[test]
fn force_non_stream_rejects_empty() {
assert!(force_non_stream(&Bytes::new()).is_err());
}
#[test]
fn force_non_stream_rejects_invalid_json() {
let input = Bytes::from(r#"not json"#);
assert!(force_non_stream(&input).is_err());
}
#[test]
fn config_from_env_builds_completions_url() {
// Saved env keeps test isolation under cargo test (run in parallel)
let prev_gateway = std::env::var("LLM_GATEWAY").ok();
let prev_token = std::env::var("BACKEND_TOKEN").ok();
let prev_proxy = std::env::var("PROXY_AUTH_TOKEN").ok();
std::env::set_var("LLM_GATEWAY", "http://1.2.3.4:8848/v1/");
std::env::set_var("BACKEND_TOKEN", "backend-xxx");
std::env::set_var("PROXY_AUTH_TOKEN", "client-yyy");
let cfg = Config::from_env();
assert_eq!(cfg.upstream_url, "http://1.2.3.4:8848/v1/chat/completions");
assert_eq!(cfg.upstream_token, "backend-xxx");
assert_eq!(cfg.proxy_auth_token, "client-yyy");
// restore
match prev_gateway {
Some(v) => std::env::set_var("LLM_GATEWAY", v),
None => std::env::remove_var("LLM_GATEWAY"),
}
match prev_token {
Some(v) => std::env::set_var("BACKEND_TOKEN", v),
None => std::env::remove_var("BACKEND_TOKEN"),
}
match prev_proxy {
Some(v) => std::env::set_var("PROXY_AUTH_TOKEN", v),
None => std::env::remove_var("PROXY_AUTH_TOKEN"),
}
}
}
+437
View File
@@ -0,0 +1,437 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1419" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>llm.famzheng.me</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1419;
--bg-elev: #161b22;
--soft: rgba(255,255,255,.06);
--softer: rgba(255,255,255,.03);
--border: rgba(255,255,255,.12);
--fg: rgba(255,255,255,.94);
--dim: rgba(255,255,255,.55);
--accent: #7c3aed;
--accent2: #06b6d4;
--danger: #ef4444;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
'Microsoft YaHei', system-ui, sans-serif;
font-size: 15px;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
/* dynamic viewport — 处理移动端软键盘 */
height: 100dvh;
overflow: hidden;
}
@supports not (height: 100dvh) {
body { height: 100vh; }
}
main {
height: 100%;
max-width: 760px;
margin: 0 auto;
padding: 12px 14px env(safe-area-inset-bottom, 12px);
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 10px;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
h1 {
font-size: 1.15rem; margin: 0; font-weight: 600;
background: linear-gradient(135deg, #fff, var(--accent2));
-webkit-background-clip: text; background-clip: text;
color: transparent;
}
header small { color: var(--dim); font-size: 0.78rem; }
.config { display: flex; gap: 6px; flex-wrap: wrap; }
.config input {
flex: 1; min-width: 0;
padding: 8px 10px;
background: var(--soft);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--fg); font: inherit;
}
.config input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.thread {
overflow-y: auto;
padding: 6px 2px;
display: flex; flex-direction: column; gap: 10px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
scrollbar-gutter: stable;
/* iOS momentum */
-webkit-overflow-scrolling: touch;
}
.thread::-webkit-scrollbar { width: 8px; }
.thread::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.thread::-webkit-scrollbar-thumb:hover { background: var(--dim); }
.empty {
margin: auto 0; text-align: center; color: var(--dim);
padding: 24px; line-height: 1.6; font-size: 0.92rem;
}
.empty kbd {
display: inline-block; padding: 1px 6px; border-radius: 4px;
background: var(--soft); border: 1px solid var(--border);
font-family: inherit; font-size: 0.85em;
}
.row { display: flex; }
.row.user { justify-content: flex-end; }
.row.assistant { justify-content: flex-start; }
.row.err { justify-content: stretch; }
.bubble {
max-width: min(85%, 640px);
padding: 10px 13px;
border-radius: 14px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: anywhere;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.row.user .bubble {
background: linear-gradient(135deg, var(--accent), #4f46e5);
color: white;
border-bottom-right-radius: 4px;
}
.row.assistant .bubble {
background: var(--bg-elev);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.row.err .bubble {
background: rgba(239,68,68,.12);
border: 1px solid rgba(239,68,68,.4);
color: #ff8080;
max-width: 100%; width: 100%;
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.meta {
margin-top: 4px;
font-size: 0.72rem;
color: var(--dim);
display: flex; gap: 8px; align-items: center;
}
.copy-btn {
background: transparent; border: none;
color: var(--dim); cursor: pointer;
font-size: 0.72rem; padding: 0;
}
.copy-btn:hover { color: var(--fg); }
.typing {
display: inline-flex; gap: 4px;
padding: 14px 14px;
}
.typing span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--dim); animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: .15s; }
.typing span:nth-child(3) { animation-delay: .30s; }
@keyframes bounce {
0%,60%,100% { transform: translateY(0); opacity: .45; }
30% { transform: translateY(-4px); opacity: 1; }
}
footer {
display: flex; gap: 8px; align-items: flex-end;
}
textarea {
flex: 1;
resize: none;
min-height: 44px;
max-height: 200px;
padding: 10px 12px;
background: var(--soft);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--fg);
font: inherit; line-height: 1.4;
overflow-y: auto;
}
textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.send {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white; border: none;
padding: 0 18px;
height: 44px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.send:disabled {
background: var(--soft); color: var(--dim); cursor: not-allowed;
}
.ghost {
background: transparent; border: 1px solid var(--border);
color: var(--fg);
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.ghost:hover { background: var(--soft); }
details {
color: var(--dim); font-size: 0.85rem;
grid-row: auto;
}
details summary { cursor: pointer; padding: 4px 0; }
details summary:hover { color: var(--fg); }
details pre {
background: rgba(0,0,0,.4); padding: 10px;
border-radius: 8px; overflow-x: auto;
border: 1px solid var(--border);
color: var(--fg);
font-size: 0.82rem;
margin: 6px 0 0;
}
@media (max-width: 520px) {
main { padding: 8px 10px env(safe-area-inset-bottom, 8px); gap: 8px; }
h1 { font-size: 1rem; }
header small { display: none; }
.bubble { font-size: 0.92rem; }
}
</style>
</head>
<body>
<main>
<header>
<h1>llm.famzheng.me</h1>
<small id="meta">gemma-4-31b-it · 反向代理</small>
</header>
<div class="config">
<input id="token" type="password" autocomplete="off" spellcheck="false"
placeholder="your auth token" />
<button class="ghost" id="reset" type="button">清空对话</button>
</div>
<div class="thread" id="thread">
<div class="empty" id="empty">
填好 token 后开聊。<br />
<kbd>Enter</kbd> 发送 · <kbd>Shift+Enter</kbd> 换行
</div>
</div>
<footer>
<textarea id="input" rows="1" placeholder="说点什么..."
autocomplete="off" autocapitalize="off"></textarea>
<button class="send" id="send" type="button">发送</button>
</footer>
<details>
<summary>curl example</summary>
<pre>curl -X POST https://llm.famzheng.me/v1/chat/completions \
-H 'Authorization: token &lt;your-token&gt;' \
-H 'Content-Type: application/json' \
-d '{
"model": "gemma-4-31b-it",
"messages": [{"role":"user","content":"hello"}]
}'</pre>
</details>
</main>
<script>
const TOKEN_KEY = 'llm-proxy-token'
const tokenInput = document.getElementById('token')
const sendBtn = document.getElementById('send')
const resetBtn = document.getElementById('reset')
const input = document.getElementById('input')
const thread = document.getElementById('thread')
const empty = document.getElementById('empty')
tokenInput.value = localStorage.getItem(TOKEN_KEY) || ''
tokenInput.addEventListener('change', () => {
localStorage.setItem(TOKEN_KEY, tokenInput.value.trim())
})
const history = []
function clearEmpty() {
if (empty && empty.parentNode === thread) thread.removeChild(empty)
}
function scrollToBottom() {
// double rAF: 一次让浏览器 layout 新节点,第二次再滚
requestAnimationFrame(() => {
requestAnimationFrame(() => {
thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' })
})
})
}
function addBubble(role, text, opts = {}) {
clearEmpty()
const row = document.createElement('div')
row.className = 'row ' + role
const bubble = document.createElement('div')
bubble.className = 'bubble'
bubble.textContent = text
row.appendChild(bubble)
if (role === 'assistant' && !opts.err) {
const meta = document.createElement('div')
meta.className = 'meta'
const copy = document.createElement('button')
copy.className = 'copy-btn'
copy.type = 'button'
copy.textContent = '复制'
copy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(text)
copy.textContent = '已复制'
setTimeout(() => (copy.textContent = '复制'), 1200)
} catch {
copy.textContent = '复制失败'
}
})
meta.appendChild(copy)
bubble.appendChild(document.createElement('br'))
bubble.appendChild(meta)
}
thread.appendChild(row)
scrollToBottom()
return bubble
}
function addErr(text) {
clearEmpty()
const row = document.createElement('div')
row.className = 'row err'
const b = document.createElement('div')
b.className = 'bubble'
b.textContent = text
row.appendChild(b)
thread.appendChild(row)
scrollToBottom()
}
function addTyping() {
clearEmpty()
const row = document.createElement('div')
row.className = 'row assistant'
const bubble = document.createElement('div')
bubble.className = 'bubble typing'
bubble.innerHTML = '<span></span><span></span><span></span>'
row.appendChild(bubble)
thread.appendChild(row)
scrollToBottom()
return row
}
// textarea 自动 grow
function autoGrow() {
input.style.height = 'auto'
const next = Math.min(input.scrollHeight, 200)
input.style.height = next + 'px'
}
input.addEventListener('input', autoGrow)
async function send() {
const text = input.value.trim()
const token = tokenInput.value.trim()
if (!text) return
if (!token) {
addErr('先在上方填 token。')
tokenInput.focus()
return
}
input.value = ''
autoGrow()
history.push({ role: 'user', content: text })
addBubble('user', text)
sendBtn.disabled = true
const dot = addTyping()
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'token ' + token,
},
body: JSON.stringify({
model: 'gemma-4-31b-it',
messages: history,
}),
})
const body = await res.text()
dot.remove()
if (!res.ok) {
addErr(`HTTP ${res.status} — ${body || '(空响应)'}`)
history.pop()
return
}
let data
try {
data = JSON.parse(body)
} catch {
addErr('上游返回非 JSON: ' + body.slice(0, 300))
history.pop()
return
}
const reply = data?.choices?.[0]?.message?.content?.trim() || '(空回复)'
history.push({ role: 'assistant', content: reply })
addBubble('assistant', reply)
} catch (e) {
dot.remove()
addErr('网络错误: ' + e.message)
history.pop()
} finally {
sendBtn.disabled = false
input.focus()
}
}
sendBtn.addEventListener('click', send)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
})
resetBtn.addEventListener('click', () => {
history.length = 0
thread.innerHTML = ''
thread.appendChild(empty)
input.focus()
})
// 自动聚焦:如果已有 token 聚焦输入框,否则聚焦 token 框
window.addEventListener('load', () => {
if (tokenInput.value) input.focus()
else tokenInput.focus()
})
</script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7c3aed"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="1.2"/>
</filter>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#bg)"/>
<text x="32" y="46" text-anchor="middle"
font-family="ui-serif, Georgia, 'Times New Roman', serif"
font-size="42" font-weight="700" fill="white"
style="font-style: italic;">λ</text>
<circle cx="49" cy="49" r="6.5" fill="#4ade80" filter="url(#glow)" opacity="0.5"/>
<circle cx="49" cy="49" r="4.5" fill="#4ade80"/>
</svg>

After

Width:  |  Height:  |  Size: 756 B

+21
View File
@@ -0,0 +1,21 @@
[package]
name = "music"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "music.famzheng.me — 听歌 + 练琴 曲目管理 (video / audio / pdf / png)"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
axum = { workspace = true, features = ["multipart"] }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
reqwest = { workspace = true }
futures = { workspace = true }
tokio-stream = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/music /music
COPY apps/music/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/music"]
+24
View File
@@ -0,0 +1,24 @@
# music chord-fetcher sidecar
# 抓 yopu.co 截图的 selenium 服务,跟 music 主容器同 pod 共享 PVC。
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium chromium-driver fonts-noto-cjk ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir \
selenium==4.27.1 \
pillow==11.0.0 \
fastapi==0.115.6 \
uvicorn==0.34.0
WORKDIR /app
COPY yopu.py chord_server.py ./
EXPOSE 8001
CMD ["uvicorn", "chord_server:app", "--host", "0.0.0.0", "--port", "8001"]
+149
View File
@@ -0,0 +1,149 @@
"""
chord-fetcher sidecar HTTP service
music 主容器同 pod监听 :8001 music backend 通过 localhost 调用
worker 单线程串行chromium 一次跑一个省资源文件落 /data/chord-fetch/{piece_id}.png
"""
import json
import logging
import queue
import sys
import threading
import os
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
# 调试热更:/data 是 PVC mount,重启容器不丢;放 yopu.py 在 /data/chord-overrides/
# 启动时把它放最高优先级,方便不重 build image 直接 hot-fix selector。
_OVERRIDE_DIR = Path('/data/chord-overrides')
_OVERRIDE_DIR.mkdir(parents=True, exist_ok=True)
if (_OVERRIDE_DIR / 'yopu.py').exists():
sys.path.insert(0, str(_OVERRIDE_DIR))
print(f"[chord-server] using yopu.py override from {_OVERRIDE_DIR}")
import yopu # noqa: E402
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
logger = logging.getLogger('chord-server')
OUT_DIR = Path(os.getenv('CHORD_OUT_DIR', '/data/chord-fetch'))
OUT_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI()
VALID_MODES = ('letters', 'functional')
# in-memory job state. (piece_id, mode) -> {status, error, title, artist}
state: dict[tuple[int, str], dict] = {}
state_lock = threading.Lock()
job_q: queue.Queue = queue.Queue()
def out_path(piece_id: int, mode: str) -> Path:
return OUT_DIR / f"{piece_id}-{mode}.png"
def worker():
while True:
piece_id, mode, title, artist = job_q.get()
key = (piece_id, mode)
with state_lock:
state[key] = {'status': 'processing', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d mode=%s] start: title=%r artist=%r", piece_id, mode, title, artist)
try:
ok, msg = yopu.fetch_chord_chart(title, artist, str(out_path(piece_id, mode)), mode=mode)
with state_lock:
if ok:
state[key] = {'status': 'completed', 'error': '', 'title': title, 'artist': artist}
logger.info("[piece=%d mode=%s] completed: %s", piece_id, mode, msg)
else:
state[key] = {'status': 'failed', 'error': msg, 'title': title, 'artist': artist}
logger.warning("[piece=%d mode=%s] failed: %s", piece_id, mode, msg)
except Exception as e:
logger.exception("[piece=%d mode=%s] worker crash", piece_id, mode)
with state_lock:
state[key] = {'status': 'failed', 'error': str(e), 'title': title, 'artist': artist}
finally:
job_q.task_done()
threading.Thread(target=worker, daemon=True).start()
@app.get('/healthz')
def healthz():
return {'ok': True}
@app.post('/fetch')
def fetch(piece_id: int, title: str, artist: str = '', mode: str = 'functional'):
"""加入 fetch 队列。
mode='letters' = 弹唱谱字母版mode='functional' = 数字级数版
幂等 completed 且文件还在直接返回 completed"""
if piece_id <= 0 or not title.strip():
raise HTTPException(400, 'piece_id / title required')
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
key = (piece_id, mode)
with state_lock:
cur = state.get(key, {})
if cur.get('status') == 'completed' and out_path(piece_id, mode).exists():
return {'status': 'completed'}
if cur.get('status') in ('pending', 'processing'):
return {'status': cur['status']}
state[key] = {'status': 'pending', 'error': '', 'title': title, 'artist': artist}
job_q.put((piece_id, mode, title.strip(), artist.strip()))
return {'status': 'pending'}
@app.get('/status/{piece_id}/{mode}')
def status(piece_id: int, mode: str):
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
key = (piece_id, mode)
with state_lock:
cur = state.get(key, {})
file_exists = out_path(piece_id, mode).exists()
if cur.get('status') == 'completed' and not file_exists:
return {'status': 'failed', 'error': 'png 文件丢了', 'mode': mode}
if not cur and file_exists:
return {'status': 'completed', 'mode': mode, 'file_exists': True}
return {
'status': cur.get('status', 'none'),
'error': cur.get('error', ''),
'mode': mode,
'file_exists': file_exists,
}
@app.get('/image/{piece_id}/{mode}')
def image(piece_id: int, mode: str):
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
p = out_path(piece_id, mode)
if not p.exists():
raise HTTPException(404, 'not found')
return FileResponse(p, media_type='image/png')
@app.delete('/state/{piece_id}/{mode}')
def reset(piece_id: int, mode: str):
"""music backend import 完后清状态 + 删 png(防 PVC 越积越多)。"""
if mode not in VALID_MODES:
raise HTTPException(400, f'mode must be one of {VALID_MODES}')
with state_lock:
state.pop((piece_id, mode), None)
p = out_path(piece_id, mode)
if p.exists():
try:
p.unlink()
except Exception as e:
logger.warning("[piece=%d mode=%s] cleanup unlink: %s", piece_id, mode, e)
return {'ok': True}
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""
yopu.co 和弦谱抓取v2
跟旧 guitar 版相比UI 改了现在是分立的 row
- "谱面样式" "功能谱"
- "和弦样式" "级数名"
- "和弦图" 默认不动
抓取流程
1. /explore#q=<query> 搜索
2. 找第一个含和弦谱字样的结果 /view/<id>
3. row label = X 的行里 button.option 文本 = Y
4. 撑开 div.sheet-container 容器把 overflow / max-height 砍掉让全部内容渲染
5. 截图整个 container element
6. PIL 裁白边 + padding PNG
"""
import os
import time
import logging
from pathlib import Path
from urllib.parse import quote, urlparse, urljoin
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import TimeoutException
from PIL import Image
logger = logging.getLogger(__name__)
def setup_driver(window="1920,5000"):
o = Options()
o.add_argument('--headless=new')
o.add_argument('--no-sandbox')
o.add_argument('--disable-dev-shm-usage')
o.add_argument('--disable-gpu')
o.add_argument(f'--window-size={window}')
o.add_argument('--lang=zh-CN')
o.add_argument('user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
o.add_experimental_option('prefs', {'intl.accept_languages': 'zh-CN,zh,en-US,en'})
service = None
if cdp := os.getenv('CHROMEDRIVER_PATH'):
service = Service(cdp)
if cb := os.getenv('CHROME_BIN'):
o.binary_location = cb
return webdriver.Chrome(service=service, options=o)
def find_chart(driver, title: str, artist: str, prefer: str = 'functional'):
"""在 /song?title=&artist= 找最佳候选 view。
yopu 同一首歌一般有多个版本按搜索结果里 nier-snippet 内的
SVG <text> 数量区分
- svg_text > 0 chord 字母版G/Em7/C民间叫弹唱谱
- svg_text == 0 功能谱 / 数字级数版
`prefer` {'letters', 'functional'}按需求挑第一个匹配的
实在没匹配就 fallback 到第一个非空候选
"""
from urllib.parse import urlencode
base = 'https://yopu.co/song'
# /song 用 hash 传参(跟 yopu 前端约定一致)
search_url = f"{base}#title={quote(title)}&artist={quote(artist)}"
logger.info("loading /song: %s", search_url)
driver.get(search_url)
time.sleep(3)
hits = driver.execute_script("""
var out = [];
var posts = document.querySelectorAll('a.post-main');
for (var i = 0; i < posts.length; i++) {
var p = posts[i];
var titleEl = p.querySelector('.title-line .title, .title');
var subEl = p.querySelector('.title-line .subtitle, .subtitle');
var info = p.querySelector('.one-line-info');
var snippet = p.querySelector('.nier-snippet');
var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0;
// 任何子元素 class 'verified' 都算svelte 加了 hash class
var isVerified = p.querySelectorAll('[class*="verified"]').length > 0;
out.push({
href: p.href,
title: titleEl ? (titleEl.textContent || '').trim() : '',
subtitle: subEl ? (subEl.textContent || '').trim() : '',
info: info ? (info.textContent || '').trim() : '',
svgTextCount: svgTextCount,
isLetters: svgTextCount > 0,
isFunctional: svgTextCount === 0,
isVerified: isVerified,
});
}
return out;
""")
if not hits:
logger.warning("no a.post-main found at /song — fallback to /explore")
# fallback: yopu /song 偶尔没结果,回退到 /explore
from urllib.parse import quote as _q
q = (artist + ' ' + title).strip()
driver.get(f"https://yopu.co/explore#q={_q(q)}")
time.sleep(3)
hits = driver.execute_script("""
var out = [];
var posts = document.querySelectorAll('a.post-main');
for (var i = 0; i < posts.length; i++) {
var p = posts[i];
var titleEl = p.querySelector('.title-line .title, .title');
var subEl = p.querySelector('.title-line .subtitle, .subtitle');
var info = p.querySelector('.one-line-info');
var snippet = p.querySelector('.nier-snippet');
var svgTextCount = snippet ? snippet.querySelectorAll('svg text').length : 0;
out.push({
href: p.href,
title: titleEl ? (titleEl.textContent || '').trim() : '',
subtitle: subEl ? (subEl.textContent || '').trim() : '',
info: info ? (info.textContent || '').trim() : '',
svgTextCount: svgTextCount,
isLetters: svgTextCount > 0,
isFunctional: svgTextCount === 0,
isVerified: false,
});
}
return out;
""")
if not hits:
return None
# 优先匹配 prefer;同时优先 verified(虽然匿名访问大概率全是 false)
def _key(h):
match_pref = (prefer == 'letters' and h['isLetters']) or \
(prefer == 'functional' and h['isFunctional'])
# 数值越小越优先:first match_pref+verified, then match_pref, then verified, then all
return (0 if (match_pref and h['isVerified']) else
1 if match_pref else
2 if h['isVerified'] else 3)
sorted_hits = sorted(hits, key=_key)
chosen = sorted_hits[0]
matched = (prefer == 'letters' and chosen['isLetters']) or \
(prefer == 'functional' and chosen['isFunctional'])
kind = prefer if matched else f"{prefer}-fallback"
href = chosen['href']
if href.startswith('/'):
p = urlparse(driver.current_url)
href = f"{p.scheme}://{p.netloc}{href}"
elif not href.startswith('http'):
href = urljoin(driver.current_url, href)
logger.info("[%s] %s%s [%s] verified=%s (total %d, letters=%d, functional=%d, verified=%d)",
kind, chosen['title'], chosen['subtitle'], chosen['info'],
chosen['isVerified'], len(hits),
sum(1 for h in hits if h['isLetters']),
sum(1 for h in hits if h['isFunctional']),
sum(1 for h in hits if h['isVerified']))
return {
'url': href,
'title': chosen.get('title') or '',
'subtitle': chosen.get('subtitle') or '',
'text': chosen.get('info') or '',
'kind': kind,
}
def select_option_in_row(driver, row_label, button_text, timeout=10):
"""在 label 含 row_label 的 row 里,点 button.option 文本含 button_text 的按钮。
返回 True 表示点了False 表示找不到不算错误可能是 UI 文案变了"""
# 短 timeout:当前 yopu UI 普遍没这些 rowbest-effort 不卡流程
wait = WebDriverWait(driver, min(timeout, 3))
try:
row = wait.until(EC.presence_of_element_located((
By.XPATH,
f"//div[contains(@class, 'row')][.//div[contains(@class, 'label') "
f"and contains(normalize-space(.), '{row_label}')]]"
)))
except TimeoutException:
logger.debug("row '%s' not present (skipped)", row_label)
return False
buttons = row.find_elements(By.CSS_SELECTOR, "button.option, button")
for btn in buttons:
txt = (btn.text or '').strip()
if button_text in txt:
try:
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn)
time.sleep(0.3)
btn.click()
logger.info("clicked '%s' in row '%s'", button_text, row_label)
time.sleep(1.2)
return True
except Exception as e:
logger.debug("click failed in row '%s' / '%s': %s", row_label, button_text, e)
return False
logger.debug("button '%s' not found in row '%s'", button_text, row_label)
return False
def expand_sheet_container(driver, container):
"""把 sheet-container 跟它的祖先一起把 overflow / max-height 拆掉,
scrollHeight 全暴露截图能拿到完整谱面"""
return driver.execute_script("""
var c = arguments[0];
var origStyle = c.getAttribute('style') || '';
var modified = [];
var node = c;
while (node && node !== document.body) {
var cs = window.getComputedStyle(node);
if (cs.overflow === 'hidden' || cs.overflow === 'auto'
|| cs.overflowY === 'hidden' || cs.overflowY === 'auto'
|| cs.maxHeight !== 'none') {
modified.push({ el: node, orig: node.getAttribute('style') || '' });
node.style.overflow = 'visible';
node.style.overflowY = 'visible';
node.style.maxHeight = 'none';
node.style.height = 'auto';
}
node = node.parentElement;
}
c.style.overflow = 'visible';
c.style.maxHeight = 'none';
c.style.height = 'auto';
c.style.minHeight = c.scrollHeight + 'px';
c.offsetHeight; // force reflow
c.setAttribute('data-orig-style', origStyle);
window.__yopuModified = modified;
return { scrollHeight: c.scrollHeight, modified: modified.length };
""", container)
def crop_white(path, pad_top=20, pad_bottom=50, pad_left=20, pad_right=20, white_th=250):
"""裁掉四边的白边,加点 padding。"""
img = Image.open(path)
w, h = img.size
if img.mode != 'RGB':
img = img.convert('RGB')
px = img.load()
def row_white_ratio(y):
wp = 0
for x in range(w):
r, g, b = px[x, y]
if r > white_th and g > white_th and b > white_th:
wp += 1
return wp / w
def col_white_ratio(x, y0, y1):
wp = 0
rng = max(1, y1 - y0)
for y in range(y0, y1):
r, g, b = px[x, y]
if r > white_th and g > white_th and b > white_th:
wp += 1
return wp / rng
top = 0
for y in range(h):
if row_white_ratio(y) < 0.99:
top = y
break
bottom = h
for y in range(h - 1, -1, -1):
if row_white_ratio(y) < 0.99:
bottom = y + 1
break
if top >= bottom:
return # all white, give up
left = 0
for x in range(w):
if col_white_ratio(x, top, bottom) < 0.99:
left = x
break
right = w
for x in range(w - 1, -1, -1):
if col_white_ratio(x, top, bottom) < 0.99:
right = x + 1
break
if left >= right:
return
box = (
max(0, left - pad_left),
max(0, top - pad_top),
min(w, right + pad_right),
min(h, bottom + pad_bottom),
)
img.crop(box).save(path, 'PNG')
logger.info("cropped to %s", box)
DEBUG_DIR = Path('/data/chord-debug')
def _save_debug(driver, tag: str):
"""失败时 dump 当前 HTML + 截图到 /data/chord-debug 方便排查。"""
try:
DEBUG_DIR.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
(DEBUG_DIR / f'{tag}-{ts}.html').write_text(driver.page_source, encoding='utf-8')
driver.save_screenshot(str(DEBUG_DIR / f'{tag}-{ts}.png'))
logger.info("debug snapshot saved: %s/%s-%d.{html,png}", DEBUG_DIR, tag, ts)
except Exception as e:
logger.warning("debug snapshot failed: %s", e)
def fetch_chord_chart(title: str, artist: str, output_path: str, *,
mode: str = 'functional',
sheet_style: str = '功能谱',
chord_style: str = '级数名',
verbose: bool = False) -> tuple[bool, str]:
"""搜 yopu /song、按 mode 挑候选 view、截图。
mode='functional' 数字级数版mode='letters' 字母版弹唱谱
返回 (ok, msg)
"""
if verbose:
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s')
else:
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
driver = None
try:
driver = setup_driver()
result = find_chart(driver, title, artist, prefer=mode)
if not result:
_save_debug(driver, 'no-search-hit')
return False, '未找到和弦谱'
view_url = result['url']
logger.info("loading view: %s", view_url)
driver.get(view_url)
time.sleep(3)
# 旧 yopu UI 在 view 页有「谱面样式 / 和弦样式」row 可切;
# 新 yopu 已经下线了这些(要登录 APP 才能切),所以用搜索阶段
# 选「功能谱」版本绕过去。这里 best-effort 试一下,找不到不算错误。
select_option_in_row(driver, '谱面样式', sheet_style)
select_option_in_row(driver, '和弦样式', chord_style)
# 等内容刷新
time.sleep(1.5)
wait = WebDriverWait(driver, 15)
try:
sheet = wait.until(EC.presence_of_element_located(
(By.CSS_SELECTOR, "div.sheet-container")
))
except TimeoutException:
_save_debug(driver, 'no-sheet-container')
raise
driver.execute_script("arguments[0].scrollIntoView(true);", sheet)
time.sleep(0.5)
dims = expand_sheet_container(driver, sheet)
logger.debug("expanded scrollHeight=%s, modified=%s ancestors", dims['scrollHeight'], dims['modified'])
time.sleep(1.5)
# incrButton:放大字号 / chord size,跟旧版一样点 3 次
try:
buttons = driver.find_elements(By.CSS_SELECTOR, "button.incrButton")
if buttons:
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", buttons[0])
time.sleep(0.3)
for _ in range(3):
buttons[0].click()
time.sleep(0.4)
except Exception as e:
logger.warning("incrButton failed: %s", e)
time.sleep(1.0)
# 滚 sheet 内部回到顶部,截整个 container
driver.execute_script("arguments[0].scrollTop = 0;", sheet)
time.sleep(0.4)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
sheet.screenshot(str(out))
if not out.exists() or out.stat().st_size < 100:
return False, '截图为空'
logger.info("screenshot: %s (%d bytes)", out, out.stat().st_size)
try:
crop_white(str(out))
except Exception as e:
logger.warning("crop failed: %s", e)
return True, str(out)
except Exception as e:
logger.error("fetch failed: %s", e, exc_info=True)
return False, str(e)
finally:
if driver:
try:
driver.quit()
except Exception:
pass
@@ -2,9 +2,9 @@
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#0a0e1a">
<title>Piano Sheet</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f0f0f">
<title>Music · Euphon</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "music",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"sharp": "^0.34.5",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.5",
"workbox-build": "^7.1.0",
"workbox-window": "^7.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// 一次性脚本:把 icon-source.svg 渲染成 PWA 所需的各尺寸 PNG。
// 用法: node scripts/gen-icons.mjs (需先 npm i -D sharp
import sharp from 'sharp'
import { readFileSync, writeFileSync } from 'fs'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(resolve(__dirname, 'icon-source.svg'))
const pub = resolve(__dirname, '..', 'public')
async function render(out, size) {
await sharp(src).resize(size, size).png().toFile(resolve(pub, out))
console.log('wrote', out)
}
// Maskable 版本:留 ~10% safe-zone padding,避免 Android 圆形遮罩切到音符
async function renderMaskable(out, size) {
const pad = Math.round(size * 0.1)
const inner = size - pad * 2
const innerBuf = await sharp(src).resize(inner, inner).png().toBuffer()
await sharp({
create: { width: size, height: size, channels: 4, background: '#0f0f0f' },
})
.composite([{ input: innerBuf, top: pad, left: pad }])
.png()
.toFile(resolve(pub, out))
console.log('wrote', out)
}
await render('pwa-192x192.png', 192)
await render('pwa-512x512.png', 512)
await render('apple-touch-icon-180x180.png', 180)
await render('favicon-48x48.png', 48)
await renderMaskable('maskable-icon-512x512.png', 512)
console.log('done')
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" ry="96" fill="#0f0f0f"/>
<g fill="#f5b800">
<!-- stem (slight tilt) -->
<path d="M302 110 L322 105 L322 360 L302 365 Z"/>
<!-- flag: bezier swoop off the stem top -->
<path d="M322 105 C 400 130, 430 200, 380 270 C 410 210, 380 160, 322 145 Z"/>
<!-- note head: ellipse rotated -22° -->
<ellipse cx="250" cy="360" rx="64" ry="46" transform="rotate(-22 250 360)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 534 B

+65
View File
@@ -0,0 +1,65 @@
<template>
<router-view />
</template>
<style>
:root {
--bg: #0f0f0f;
--bg-elev: #161616;
--bg-card: #1a1a2e;
--bg-hover: #232342;
--bg-active: #2a1a3e;
--border: #2a2a3a;
--border-soft: #1f1f2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--text-mute: #666;
--accent: #c084fc;
--accent-strong: #7c5cbf;
--accent-cyan: #06b6d4;
--accent-green: #4ade80;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
overscroll-behavior: none;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
background: none;
color: inherit;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea {
font-family: var(--font-sans);
background: transparent;
border: none;
color: inherit;
outline: none;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
</style>

Some files were not shown because too many files have changed in this diff Show More