Compare commits
71 Commits
28713e489f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b87085ca9 | |||
| e99a032852 | |||
| ae9c08aa35 | |||
| 089de84396 | |||
| 83418c198f | |||
| 0756362d14 | |||
| adbd259a32 | |||
| 8991033f70 | |||
| bcf99ec454 | |||
| 1a62ec6658 | |||
| 915b91d986 | |||
| b2bec0406f | |||
| 85b55f2243 | |||
| 027921de0c | |||
| b2d70b2491 | |||
| 7b868852d2 | |||
| 9328c01c1b | |||
| f8a7f31427 | |||
| 3f742352e2 | |||
| 3e478228dd | |||
| e072109e91 | |||
| ca11a9bda7 | |||
| a8e5100380 | |||
| a5e97adf85 | |||
| bcc8c3f484 | |||
| 1859512976 | |||
| 857c0d5481 | |||
| 34fa47f95f | |||
| 674011ddf3 | |||
| e7912f3547 | |||
| d964b46dbe | |||
| 1ee35b4d19 | |||
| 688ccdc76f | |||
| e5a87cc65f | |||
| e56e2138a8 | |||
| 68671784f6 | |||
| 3a34fbdfd8 | |||
| eb7cd81395 | |||
| 93039457a7 | |||
| 44652eb398 | |||
| c2c0c6999d | |||
| 61abd3f560 | |||
| 802d5beae9 | |||
| af697ea6d0 | |||
| bcdf6c6ba4 | |||
| fbd6e3cb9c | |||
| 78f84d4225 | |||
| 0b22691b3d | |||
| cdbf8308d1 | |||
| 5674be1cfd | |||
| e5f3a95aa9 | |||
| 5c0d860666 | |||
| 26b99d7405 | |||
| ccb5ad05ce | |||
| f7fac352a5 | |||
| 9ce3b66810 | |||
| 9640abe102 | |||
| fd80116168 | |||
| f836c8dab7 | |||
| eed5e88dc0 | |||
| c0d6e37325 | |||
| 9623e298b7 | |||
| ceaa2cc839 | |||
| 05df371435 | |||
| e111398157 | |||
| 1a8f297302 | |||
| 58f344db85 | |||
| 1e04655003 | |||
| 1cf53316df | |||
| 538bbb7ecd | |||
| 09c3236b5b |
@@ -0,0 +1,60 @@
|
||||
name: deploy articulate
|
||||
# articulate.famzheng.me — 中英猜词派对游戏。host shell runner(fam 用户)。
|
||||
|
||||
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
|
||||
@@ -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 runner(fam 用户)。
|
||||
name: deploy karaoke
|
||||
# karaoke.famzheng.me — 卡拉OK 点歌单本地管理。host shell runner(fam 用户)。
|
||||
|
||||
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 }}"
|
||||
@@ -0,0 +1,52 @@
|
||||
name: deploy llm-proxy
|
||||
# llm.famzheng.me — gemma 反向代理。host shell runner(fam 用户)。
|
||||
|
||||
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
|
||||
@@ -0,0 +1,68 @@
|
||||
name: deploy music
|
||||
# music.famzheng.me — 听歌 + 练琴 曲目管理。host shell runner(fam 用户)。
|
||||
|
||||
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 sidecar:layer 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
|
||||
@@ -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-cache(cube 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-free,layer 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
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: deploy werewolf
|
||||
# werewolf.famzheng.me — 狼人杀单机发牌器。host shell runner(fam 用户)。
|
||||
|
||||
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
|
||||
@@ -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 dir;fam 已 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,4 +1,5 @@
|
||||
/target
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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 - 下甲板
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 + content;passphrase 鉴权。",
|
||||
"url": "https://notes.famzheng.me",
|
||||
"status": "live"
|
||||
}
|
||||
]
|
||||
@@ -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 +0,0 @@
|
||||
{"root":["./src/apps.ts","./src/main.ts","./src/App.vue","./src/components/AppCard.vue","./vite-env.d.ts"],"version":"5.7.3"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cube-cube
|
||||
@@ -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
|
||||
@@ -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.me,Fam 的小 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `/v1/chat/completions` 透传 — 替换 Authorization 头,把请求 body 原样 forward 到
|
||||
//! 上游 LLM gateway,把响应 body 原样回吐给客户端。
|
||||
//!
|
||||
//! 一期只支持非 streaming(force `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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <your-token>' \
|
||||
-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>
|
||||
@@ -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 |
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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 普遍没这些 row,best-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">
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 792 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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 |
@@ -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>
|
||||