Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .yarn/install-state.gz
Binary file not shown.
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Repository Guidelines

## Project Structure & Module Organization
`src/` contains the Vue 3 application. Entry wiring lives in `src/main.ts`, routes in `src/router.ts`, shared state in `src/store.ts`, page-level views in `src/pages/`, reusable UI in `src/components/`, and typing logic plus data files in `src/utils/`. Global Less styles live in `src/styles/` and `src/app.less`. Static assets and PWA icons are under `public/`; screenshots used in documentation are in `screenshots/`. One-off data scripts live in `scripts/`.

## Build, Test, and Development Commands
Use Yarn for consistency because the repo includes `yarn.lock`.

- `yarn dev` starts the Vite dev server.
- `yarn build` runs `vue-tsc --noEmit` and then creates the production bundle.
- `yarn preview` serves the built app locally for a final check.
- `yarn lint` runs ESLint on `.ts` and `.vue` files in `src/`.
- `yarn fix` applies auto-fixable lint changes.
- `yarn test` starts Vitest.

## Coding Style & Naming Conventions
Write TypeScript and Vue SFCs with 2-space indentation, double quotes, and trailing commas where the existing code uses them. Keep page components in `src/pages/` with PascalCase names such as `RandomMode.vue`; shared components follow the same pattern. Utility modules use lowercase filenames such as `summary.ts` and JSON config files stay in `src/utils/`. Prefer small, focused modules over large mixed-purpose files.

## Testing Guidelines
Vitest is the test runner. Place tests near the feature they cover, following the existing pattern `src/utils/test/*.test.ts`. Name files `*.test.ts` and keep descriptions specific to the behavior under test. Run `yarn test` before opening a PR; add coverage for changes to typing rules, summary logic, or config parsing.

## Commit & Pull Request Guidelines
Recent history mixes short `fix:` commits with direct update messages. Prefer concise, imperative subjects, ideally with a scope prefix such as `fix:`, `feat:`, or `docs:`. Keep each commit focused. Pull requests should explain the user-visible change, note any config or data-file edits such as `src/utils/spconfig.json`, link related issues, and include screenshots for UI changes. The Husky pre-commit hook runs `yarn lint`, so fix lint failures before pushing.

## Configuration Notes
When adding a new shuangpin scheme, update `src/utils/spconfig.json` and document the source in the PR. Avoid committing generated files unless they are required runtime assets.
139 changes: 114 additions & 25 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,56 +1,91 @@
<script setup lang="ts">
import Menu from "./components/MenuList.vue";
import Bg from "./components/Background.vue";
import { routes } from "./router";
import { shuangpinRoutes, xhyxRoutes } from "./router";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "./store";
import { computed, watchPostEffect } from "vue";
import { computed, watch, watchPostEffect } from "vue";
import { ref, effect } from "vue";
import { getPinyinOf } from "./utils/hanzi";

const store = useStore();
const router = useRouter();
const route = useRoute();
const menuItems = routes.map((v) => v.name as string);
const menuIndex = ref(0);
const lastShuangpinPath = ref((shuangpinRoutes.at(0)?.path as string) ?? "/");

const isXhyxMode = computed(() =>
xhyxRoutes.some((item) => item.path === route.path)
);
const currentRoutes = computed(() =>
isXhyxMode.value ? xhyxRoutes : shuangpinRoutes
);
const menuItems = computed(() =>
currentRoutes.value.map((v) => v.name as string)
);

effect(() => {
const index = routes.findIndex((v) => v.path === route.path);
const index = currentRoutes.value.findIndex((v) => v.path === route.path);

if (index >= 0) {
menuIndex.value = index;
} else {
router.replace(routes.at(0)?.path ?? "/");
router.replace(shuangpinRoutes.at(0)?.path ?? "/");
}
});

const spMode = computed(() => {
const mode = store.mode();
const name = mode.name.split("").slice(0, 2);
const full = name.concat(["双", "拼"]).map((v) => {
const pinyin = getPinyinOf(v).at(0) ?? "";
const sp = mode.py2sp.get(pinyin) ?? "";
return [v, sp];
});

const left = full.slice(0, 2);
const right = full.slice(2, 4);
const getBgItem = (item: typeof left) => ({
chars: item.map((v) => v[0]).join(""),
shuangpins: item
.map((v) => v[1])
watch(
() => route.path,
(path) => {
if (shuangpinRoutes.some((item) => item.path === path)) {
lastShuangpinPath.value = path;
}
},
{ immediate: true }
);

function buildBgItem(chars: string, mode: ShuangpinMode) {
return {
chars,
shuangpins: chars
.split("")
.map((char) => {
const pinyin = getPinyinOf(char).at(0) ?? "";
return mode.py2sp.get(pinyin) ?? "";
})
.join("")
.toUpperCase(),
});
};
}

const spMode = computed(() => {
if (isXhyxMode.value) {
const mode = store.practiceMode("xhyx");
return {
left: buildBgItem("小鹤", mode),
right: buildBgItem("音形", mode),
};
}

const mode = store.mode();
const name = mode.name.slice(0, 2);
return {
left: getBgItem(left),
right: getBgItem(right),
left: buildBgItem(name, mode),
right: buildBgItem("双拼", mode),
};
});

function onMenuChange(i: number) {
router.push(routes[i]);
router.push(currentRoutes.value[i]);
}

function switchMode(mode: "shuangpin" | "xhyx") {
if (mode === "xhyx") {
router.push(xhyxRoutes.at(0)?.path ?? "/xhyx");
return;
}

router.push(lastShuangpinPath.value);
}

watchPostEffect(() => {
Expand All @@ -69,7 +104,24 @@ watchPostEffect(() => {

<template>
<div class="content">
<div class="main-menu">
<div class="mode-switch">
<button
class="mode-switch-btn"
:class="!isXhyxMode && 'active'"
@click="switchMode('shuangpin')"
>
双拼
</button>
<button
class="mode-switch-btn"
:class="isXhyxMode && 'active'"
@click="switchMode('xhyx')"
>
音形
</button>
</div>

<div v-if="!isXhyxMode" class="main-menu">
<Menu
default-show-item
enable-arrow
Expand Down Expand Up @@ -121,6 +173,43 @@ watchPostEffect(() => {
overflow: hidden;
}

.mode-switch {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: inline-flex;
border: 1px solid var(--gray-010);
background-color: var(--white);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);

@media (max-width: 576px) {
top: 12px;
}
}

.mode-switch-btn {
border: 0;
background: transparent;
color: var(--black);
padding: 8px 14px;
font-family: inherit;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: all ease 0.2s;

&:hover {
background-color: var(--gray-002);
}

&.active {
background-color: @primary-color;
color: white;
}
}

.main-menu {
color: @primary-color;
position: absolute;
Expand Down
4 changes: 2 additions & 2 deletions src/components/Hanzi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import { effect, ref } from "vue";
import { useStore } from "../store";
import { getPinyinOf } from "../utils/hanzi";
import { randomChoice } from "../utils/number";

const props = defineProps<{
hanziSeq: string[];
hintText?: string;
}>();

const pinyin = ref("");
Expand Down Expand Up @@ -44,7 +44,7 @@ effect(() => {
<div class="current-item">
<img class="mi-bg" src="../assets/mi-bg.svg" />
<div v-show="settings.enablePinyinHint || showPinyin" class="pinyin">
{{ pinyin }}
{{ props.hintText ?? pinyin }}
</div>
<div :key="currentHanzi" class="hanzi">
{{ currentHanzi }}
Expand Down
24 changes: 18 additions & 6 deletions src/components/Keyboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ const settings = storeToRefs(store).settings;

const props = defineProps<{
hints?: string[];
validSeq?: (_: [string?, string?]) => boolean;
inputLength?: number;
modeConfig?: ShuangpinMode;
validSeq?: (_: string[]) => InputMatchResult;
}>();

const activeMode = computed(() => props.modeConfig ?? store.mode());

const pressingKeys = ref(new Set<string>());
const keySeq = ref<string[]>([]);
const scale = ref(1);
Expand Down Expand Up @@ -47,7 +51,13 @@ function pressKey(key: string) {
}

function send() {
if (props.validSeq?.([keySeq.value.at(0), keySeq.value.at(1)])) {
const result = props.validSeq?.([...keySeq.value]) ?? {
valid: false,
completed: false,
display: [],
};

if (result.completed && (result.valid || settings.value.enableAutoClear)) {
keySeq.value = [];
}
}
Expand All @@ -60,15 +70,17 @@ function releaseKey(key: string, shouldSend = true) {
return send();
}

if (!shouldSend || !store.mode().groupByKey.has(key as Char)) {
if (!shouldSend || !activeMode.value.groupByKey.has(key as Char)) {
return;
}

if (keySeq.value.length <= 2) {
const inputLength = props.inputLength ?? 2;

if (keySeq.value.length <= inputLength) {
keySeq.value.push(key);
}

if (keySeq.value.length > 2) {
if (keySeq.value.length > inputLength) {
if (settings.value.enableAutoClear) {
keySeq.value = [key];
} else {
Expand All @@ -80,7 +92,7 @@ function releaseKey(key: string, shouldSend = true) {
}

const keyLayout = computed(() => {
return mapConfigToLayout(store.mode());
return mapConfigToLayout(activeMode.value);
});

function keyItemClass(key: string) {
Expand Down
Loading