Compare commits
68 Commits
1cf53316df
...
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 |
@@ -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 build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||||
docker push "$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
|
- name: Roll out to k3s
|
||||||
# runner 是 gnoc 用户 host shell 模式,直接用 ~/.kube/config(已配好),无需 secret
|
|
||||||
run: |
|
run: |
|
||||||
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||||
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
|
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
name: deploy piano-sheet
|
name: deploy karaoke
|
||||||
# piano.famzheng.me — 钢琴谱管理 / 阅读。host shell runner(fam 用户)。
|
# karaoke.famzheng.me — 卡拉OK 点歌单本地管理。host shell runner(fam 用户)。
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'apps/piano-sheet/**'
|
- 'apps/karaoke/**'
|
||||||
- 'crates/cube-core/**'
|
- 'crates/cube-core/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.gitea/workflows/deploy-piano-sheet.yml'
|
- '.gitea/workflows/deploy-karaoke.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
APP: piano-sheet
|
APP: karaoke
|
||||||
NS: cube-piano
|
IMAGE: registry.famzheng.me/mochi/karaoke
|
||||||
IMAGE: registry.famzheng.me/mochi/piano-sheet
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -38,19 +37,24 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
cd "apps/$APP/frontend"
|
||||||
|
npm test -- --run
|
||||||
|
|
||||||
- name: Build & push image
|
- name: Build & push image
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
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 }}"
|
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||||
|
|
||||||
- name: Initialize K8s resources
|
- name: Initialize K8s resources
|
||||||
run: |
|
run: |
|
||||||
kubectl apply -f apps/piano-sheet/k8s/
|
kubectl apply -f "apps/$APP/k8s/all.yaml"
|
||||||
|
|
||||||
- name: Roll out to k3s
|
- name: Roll out to k3s
|
||||||
run: |
|
run: |
|
||||||
kubectl -n "$NS" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||||
kubectl -n "$NS" rollout status "deploy/$APP" --timeout=120s
|
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
|
||||||
@@ -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 build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||||
docker push "$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
|
- name: Roll out to k3s
|
||||||
run: |
|
run: |
|
||||||
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
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
|
/target
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
|
**/tsconfig.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ members = [
|
|||||||
"crates/cube-core",
|
"crates/cube-core",
|
||||||
"apps/cube",
|
"apps/cube",
|
||||||
"apps/simpleasm",
|
"apps/simpleasm",
|
||||||
"apps/piano-sheet",
|
"apps/music",
|
||||||
|
"apps/werewolf",
|
||||||
|
"apps/articulate",
|
||||||
|
"apps/karaoke",
|
||||||
|
"apps/notes",
|
||||||
|
"apps/llm-proxy",
|
||||||
|
"apps/write",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -22,6 +28,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
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]
|
[profile.release]
|
||||||
opt-level = "z"
|
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]
|
[dependencies]
|
||||||
cube-core = { path = "../../crates/cube-core" }
|
cube-core = { path = "../../crates/cube-core" }
|
||||||
|
axum = { workspace = true }
|
||||||
tokio = { 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">
|
<script setup lang="ts">
|
||||||
import AppCard from './components/AppCard.vue'
|
import AppCard from './components/AppCard.vue'
|
||||||
|
import Chatbot from './components/Chatbot.vue'
|
||||||
import { apps } from './apps'
|
import { apps } from './apps'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ import { apps } from './apps'
|
|||||||
<span>cube · monorepo at</span>
|
<span>cube · monorepo at</span>
|
||||||
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
|
<a href="https://famzheng.me/gitea/fam/cube" target="_blank" rel="noopener">famzheng.me/gitea/fam/cube</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<Chatbot />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</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 type AppStatus = 'live' | 'pending' | 'tbd'
|
||||||
|
|
||||||
export interface App {
|
export interface App {
|
||||||
@@ -8,47 +13,4 @@ export interface App {
|
|||||||
status: AppStatus
|
status: AppStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apps: App[] = [
|
export const apps: App[] = data as 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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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]
|
#[tokio::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
cube_core::init_tracing();
|
cube_core::init_tracing();
|
||||||
let dist = std::env::var("CUBE_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
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
|
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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: cube
|
name: karaoke
|
||||||
namespace: cube-cube
|
namespace: cube-karaoke
|
||||||
labels:
|
labels:
|
||||||
app: cube
|
app: karaoke
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cube
|
app: karaoke
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cube
|
app: karaoke
|
||||||
spec:
|
spec:
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: registry-creds
|
- name: registry-creds
|
||||||
containers:
|
containers:
|
||||||
- name: cube
|
- name: karaoke
|
||||||
# tag 由 CI 通过 `kubectl set image` 替换;初次 apply 由 README 部署 runbook 指定
|
image: registry.famzheng.me/mochi/karaoke:latest
|
||||||
image: registry.famzheng.me/mochi/cube:latest
|
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
@@ -44,3 +48,35 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: 200m
|
cpu: 200m
|
||||||
memory: 64Mi
|
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">
|
<html lang="zh">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#0a0e1a">
|
<meta name="theme-color" content="#0f0f0f">
|
||||||
<title>Piano Sheet</title>
|
<title>Music · Euphon</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
||||||