添加动词拼接学习页

整合动词拼接沙盒,并扩充常见复合动词词库,方便在歌词学习之外进行动词组合练习。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
TU
2026-07-20 11:45:01 +08:00
parent bf127782f4
commit 550b5743f6
9 changed files with 2292 additions and 120 deletions
+13 -12
View File
@@ -4,9 +4,9 @@
*/
import React from 'react';
import { BookOpen, Layers3 } from 'lucide-react';
import { BookOpen, GitMerge, Layers3 } from 'lucide-react';
export type AppView = 'lyrics' | 'vocab';
export type AppView = 'lyrics' | 'vocab' | 'compound';
interface AppNavProps {
activeView: AppView;
@@ -16,12 +16,13 @@ interface AppNavProps {
const NAV_ITEMS = [
{ id: 'lyrics' as const, label: '歌词', icon: BookOpen },
{ id: 'vocab' as const, label: '单词卡片', icon: Layers3 },
{ id: 'compound' as const, label: '动词拼接', icon: GitMerge },
];
export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
return (
<>
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-2 border-r border-[#E8E4DE] bg-[#FDFCFB] p-2 shadow-[8px_0_24px_-24px_rgba(45,41,38,0.45)]">
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-1 border-r border-[#E8E4DE] bg-[#F5F1EA] p-2">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
@@ -30,20 +31,20 @@ export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`flex w-full flex-col items-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition ${
className={`flex w-full flex-col items-center gap-1 rounded-xl px-2 py-3 text-xs font-semibold transition-colors ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] hover:bg-[#FAF9F6] hover:text-[#2D2926]'
? 'bg-[#FDFCFB] text-[#2D2926]'
: 'text-[#8B7E74] hover:bg-[#EFECE7] hover:text-[#2D2926]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
<Icon className={`h-4 w-4 ${isActive ? 'text-[#2D2926]' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
})}
</nav>
<nav className="order-last grid shrink-0 grid-cols-2 border-t border-[#E8E4DE] bg-[#FDFCFB] px-3 py-2 shadow-[0_-8px_24px_-18px_rgba(45,41,38,0.4)] lg:hidden">
<nav className="order-last grid shrink-0 grid-cols-3 gap-2 border-t border-[#E8E4DE] bg-[#F5F1EA] p-2 lg:hidden">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
@@ -52,13 +53,13 @@ export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`mx-1 flex items-center justify-center gap-2 rounded-lg py-2 text-xs font-semibold transition ${
className={`flex items-center justify-center gap-2 rounded-xl py-2 text-xs font-semibold transition-colors ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] border border-[#E8E4DE]'
? 'bg-[#FDFCFB] text-[#2D2926]'
: 'text-[#8B7E74]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
<Icon className={`h-4 w-4 ${isActive ? 'text-[#2D2926]' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
+450
View File
@@ -0,0 +1,450 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Bookmark, BookmarkCheck, CheckCircle, HelpCircle, RotateCcw, Volume2 } from 'lucide-react';
import { CompoundVerb, SuffixVerb, Verb } from '../features/compoundVerbs/types';
import {
COMPOUND_VERBS,
PREFIX_VERBS,
SUFFIX_VERBS,
VALID_COMBINATIONS_MAP,
} from '../features/compoundVerbs/verbsData';
type FusionState = 'idle' | 'success' | 'failed';
const SAVED_COMPOUND_VERBS_KEY = 'kotonoha_compound_verbs_saved_ids';
const getDanRow = (char: string) => {
const rows: Record<string, string> = {
: 'う',
: 'く',
: 'ぐ',
: 'す',
: 'つ',
: 'ぬ',
: 'ぶ',
: 'む',
: 'う',
};
return rows[char] || 'う';
};
const conjugationRule = (verb: Verb) => {
const lastChar = verb.dictionaryForm.slice(-1);
if (verb.dictionaryForm === 'する') {
return '不规则动词:する 变为连用形「し」。';
}
if (verb.dictionaryForm === '来る') {
return '不规则动词:来る(くる)变为连用形「来」(き)。';
}
if (
verb.dictionaryForm.endsWith('る') &&
['落ちる', '食べる', '信じる', '疲れる', '逃げる', '生きる', '見る', '考える', '助ける', '分ける', '鍛える', '燃える'].includes(
verb.dictionaryForm
)
) {
return '二类动词(一段动词):去掉词尾「る」,直接拼接。';
}
return `一类动词(五段动词):词尾「${lastChar}」属于「${getDanRow(lastChar)}段」,变为对应い段后拼接。`;
};
const speakJapanese = (text: string) => {
if (!('speechSynthesis' in window)) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'ja-JP';
utterance.rate = 0.86;
const voices = window.speechSynthesis.getVoices();
const jaVoice = voices.find((voice) => voice.lang.includes('ja') || voice.lang.includes('JP'));
if (jaVoice) utterance.voice = jaVoice;
window.speechSynthesis.speak(utterance);
};
export const CompoundVerbLab: React.FC = () => {
const [selectedPrefix, setSelectedPrefix] = useState<Verb | null>(null);
const [selectedSuffix, setSelectedSuffix] = useState<SuffixVerb | null>(null);
const [fusionState, setFusionState] = useState<FusionState>('idle');
const [foundVerb, setFoundVerb] = useState<CompoundVerb | null>(null);
const [savedVerbIds, setSavedVerbIds] = useState<string[]>([]);
useEffect(() => {
try {
const saved = localStorage.getItem(SAVED_COMPOUND_VERBS_KEY);
if (saved) setSavedVerbIds(JSON.parse(saved));
} catch (error) {
console.error('Error loading saved compound verbs', error);
}
}, []);
const recommendedSuffixes = useMemo(() => {
if (!selectedPrefix) return [];
return SUFFIX_VERBS.filter((suffix) => {
const key = `${selectedPrefix.dictionaryForm}-${suffix.id}`;
return VALID_COMBINATIONS_MAP[key]?.length > 0;
});
}, [selectedPrefix]);
const recommendedPrefixes = useMemo(() => {
if (!selectedSuffix) return [];
return PREFIX_VERBS.filter((prefix) => {
const key = `${prefix.dictionaryForm}-${selectedSuffix.id}`;
return VALID_COMBINATIONS_MAP[key]?.length > 0;
});
}, [selectedSuffix]);
const toggleSaveVerb = (id: string) => {
const next = savedVerbIds.includes(id)
? savedVerbIds.filter((verbId) => verbId !== id)
: [...savedVerbIds, id];
setSavedVerbIds(next);
localStorage.setItem(SAVED_COMPOUND_VERBS_KEY, JSON.stringify(next));
};
const resolveFusion = (prefix: Verb | null, suffix: SuffixVerb | null) => {
if (!prefix || !suffix) {
setFusionState('idle');
setFoundVerb(null);
return;
}
const comboKey = `${prefix.dictionaryForm}-${suffix.id}`;
const foundIds = VALID_COMBINATIONS_MAP[comboKey] || [];
const verb = COMPOUND_VERBS.find((item) => item.id === foundIds[0]) || null;
setFoundVerb(verb);
setFusionState(verb ? 'success' : 'failed');
if (verb) speakJapanese(verb.kanji);
};
const selectPrefix = (prefix: Verb) => {
setSelectedPrefix(prefix);
resolveFusion(prefix, selectedSuffix);
};
const selectSuffix = (suffix: SuffixVerb) => {
setSelectedSuffix(suffix);
resolveFusion(selectedPrefix, suffix);
};
const reset = () => {
setSelectedPrefix(null);
setSelectedSuffix(null);
setFusionState('idle');
setFoundVerb(null);
};
return (
<div className="flex-1 overflow-y-auto bg-[#F5F1EA] px-3 py-4 lg:px-8 lg:py-6">
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<header className="flex flex-col gap-1 px-1">
<h2 className="font-serif text-[clamp(1.7rem,3vw,2.4rem)] font-bold leading-tight tracking-wide text-[#2D2926]">
</h2>
<p className="text-sm leading-6 text-[#8B7E74]">
</p>
</header>
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid gap-3 md:grid-cols-2">
<VerbColumn
title="前项动词"
caption="变为连用形后拼接"
items={PREFIX_VERBS}
selectedKey={selectedPrefix?.dictionaryForm}
getKey={(verb) => verb.dictionaryForm}
getTitle={(verb) => verb.dictionaryForm}
getMeta={(verb) => `${verb.reading} / 连用形 ${verb.masuStemForm}`}
getMeaning={(verb) => verb.meaning}
recommendedKeys={recommendedPrefixes.map((verb) => verb.dictionaryForm)}
onSelect={selectPrefix}
/>
<VerbColumn
title="后项动词"
caption="决定复合动词的方向和语义"
items={SUFFIX_VERBS}
selectedKey={selectedSuffix?.id}
getKey={(suffix) => suffix.id}
getTitle={(suffix) => `${suffix.kanji}`}
getMeta={(suffix) => `${suffix.reading} / ${suffix.exampleInEnglish}`}
getMeaning={(suffix) => suffix.meaning}
recommendedKeys={recommendedSuffixes.map((suffix) => suffix.id)}
onSelect={selectSuffix}
/>
</div>
<aside className="flex flex-col gap-3">
<div className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-xs font-semibold text-[#8B7E74]"></div>
{(selectedPrefix || selectedSuffix) && (
<button
type="button"
onClick={reset}
className="flex items-center gap-1 rounded-full bg-[#E8E1D8] px-2.5 py-1 text-xs font-semibold text-[#6F6258] active:bg-[#DCD2C7]"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<FusionPart label="前项" value={selectedPrefix?.masuStemForm || '选择前项'} subValue={selectedPrefix?.dictionaryForm} />
<div className="text-lg font-bold text-[#8B7E74]">+</div>
<FusionPart label="后项" value={selectedSuffix?.kanji ? `${selectedSuffix.kanji}` : '选择后项'} subValue={selectedSuffix?.meaning} />
</div>
{selectedPrefix && (
<div className="mt-4 border-l-4 border-[#8B7E74]/40 pl-3 text-xs leading-5 text-[#6F6258]">
{conjugationRule(selectedPrefix)}
</div>
)}
</div>
<ResultPanel
fusionState={fusionState}
foundVerb={foundVerb}
selectedPrefix={selectedPrefix}
selectedSuffix={selectedSuffix}
savedVerbIds={savedVerbIds}
recommendedPrefixes={recommendedPrefixes}
recommendedSuffixes={recommendedSuffixes}
onSelectPrefix={selectPrefix}
onSelectSuffix={selectSuffix}
onToggleSave={toggleSaveVerb}
/>
</aside>
</section>
</div>
</div>
);
};
interface VerbColumnProps<T> {
title: string;
caption: string;
items: T[];
selectedKey?: string;
recommendedKeys: string[];
getKey: (item: T) => string;
getTitle: (item: T) => string;
getMeta: (item: T) => string;
getMeaning: (item: T) => string;
onSelect: (item: T) => void;
}
const VerbColumn = <T,>({
title,
caption,
items,
selectedKey,
recommendedKeys,
getKey,
getTitle,
getMeta,
getMeaning,
onSelect,
}: VerbColumnProps<T>) => (
<section className="flex min-h-[460px] flex-col rounded-2xl bg-[#FDFCFB]">
<div className="border-b border-[#E8E4DE] px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-[#2D2926]">{title}</div>
<div className="text-xs text-[#8B7E74]">{items.length} </div>
</div>
<div className="mt-0.5 text-xs text-[#8B7E74]">{caption}</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{items.map((item) => {
const key = getKey(item);
const isSelected = selectedKey === key;
const isRecommended = recommendedKeys.includes(key);
return (
<button
key={key}
type="button"
onClick={() => onSelect(item)}
className={`w-full rounded-xl px-3 py-2 text-left transition-colors ${
isSelected
? 'bg-[#E8E1D8] text-[#2D2926]'
: isRecommended
? 'bg-[#F5F1EA] text-[#2D2926]'
: 'text-[#2D2926] hover:bg-[#F5F1EA]'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-serif text-lg font-semibold leading-tight">{getTitle(item)}</div>
<div className="mt-0.5 truncate text-[11px] text-[#8B7E74]">{getMeta(item)}</div>
</div>
{isRecommended && !isSelected && (
<span className="shrink-0 rounded-full bg-[#8B7E74]/10 px-2 py-0.5 text-[10px] font-semibold text-[#8B7E74]">
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-[#6F6258]">{getMeaning(item)}</div>
</button>
);
})}
</div>
</section>
);
const FusionPart: React.FC<{ label: string; value: string; subValue?: string }> = ({ label, value, subValue }) => (
<div className="rounded-xl bg-[#F5F1EA] px-2 py-3">
<div className="text-[10px] font-semibold text-[#8B7E74]">{label}</div>
<div className="mt-1 font-serif text-lg font-bold text-[#2D2926]">{value}</div>
{subValue && <div className="mt-0.5 truncate text-[10px] text-[#8B7E74]">{subValue}</div>}
</div>
);
interface ResultPanelProps {
fusionState: FusionState;
foundVerb: CompoundVerb | null;
selectedPrefix: Verb | null;
selectedSuffix: SuffixVerb | null;
savedVerbIds: string[];
recommendedPrefixes: Verb[];
recommendedSuffixes: SuffixVerb[];
onSelectPrefix: (prefix: Verb) => void;
onSelectSuffix: (suffix: SuffixVerb) => void;
onToggleSave: (id: string) => void;
}
const ResultPanel: React.FC<ResultPanelProps> = ({
fusionState,
foundVerb,
selectedPrefix,
selectedSuffix,
savedVerbIds,
recommendedPrefixes,
recommendedSuffixes,
onSelectPrefix,
onSelectSuffix,
onToggleSave,
}) => {
if (fusionState === 'success' && foundVerb) {
const isSaved = savedVerbIds.includes(foundVerb.id);
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<CheckCircle className="h-4 w-4" />
</div>
<h3 className="mt-2 font-serif text-3xl font-bold text-[#2D2926]">{foundVerb.kanji}</h3>
<div className="mt-1 text-xs text-[#8B7E74]">
{foundVerb.reading} / {foundVerb.romaji} / JLPT {foundVerb.jlpt}
</div>
</div>
<div className="flex shrink-0 gap-1">
<button
type="button"
onClick={() => speakJapanese(foundVerb.kanji)}
className="rounded-full bg-[#E8E1D8] p-2 text-[#6F6258] active:bg-[#DCD2C7]"
title="朗读"
>
<Volume2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onToggleSave(foundVerb.id)}
className="rounded-full bg-[#E8E1D8] p-2 text-[#6F6258] active:bg-[#DCD2C7]"
title={isSaved ? '取消收藏' : '收藏'}
>
{isSaved ? <BookmarkCheck className="h-4 w-4" /> : <Bookmark className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-xs font-semibold text-[#8B7E74]"></div>
<div className="mt-1 text-sm font-semibold leading-6 text-[#2D2926]">{foundVerb.meaning}</div>
</div>
<div>
<div className="text-xs font-semibold text-[#8B7E74]"></div>
<div className="mt-1 font-serif text-sm leading-6 text-[#2D2926]">{foundVerb.exampleSentence.japanese}</div>
<div className="mt-1 text-xs leading-5 text-[#8B7E74]">{foundVerb.exampleSentence.translation}</div>
</div>
{foundVerb.notes && (
<div className="border-l-4 border-[#8B7E74]/40 pl-3 text-xs leading-5 text-[#6F6258]">{foundVerb.notes}</div>
)}
</div>
</section>
);
}
if (fusionState === 'failed' && selectedPrefix && selectedSuffix) {
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-rose-700">
<HelpCircle className="h-4 w-4" />
</div>
<p className="mt-2 text-sm leading-6 text-[#2D2926]">
{selectedPrefix.masuStemForm} + {selectedSuffix.kanji}
</p>
{(recommendedSuffixes.length > 0 || recommendedPrefixes.length > 0) && (
<div className="mt-4 space-y-3">
{recommendedSuffixes.length > 0 && (
<RecommendationGroup title={`可搭配「${selectedPrefix.dictionaryForm}」的后项`}>
{recommendedSuffixes.slice(0, 6).map((suffix) => (
<button
key={suffix.id}
type="button"
onClick={() => onSelectSuffix(suffix)}
className="rounded-full bg-[#F5F1EA] px-2.5 py-1 text-xs font-semibold text-[#6F6258]"
>
{suffix.kanji}
</button>
))}
</RecommendationGroup>
)}
{recommendedPrefixes.length > 0 && (
<RecommendationGroup title={`可搭配「〜${selectedSuffix.kanji}」的前项`}>
{recommendedPrefixes.slice(0, 6).map((prefix) => (
<button
key={prefix.dictionaryForm}
type="button"
onClick={() => onSelectPrefix(prefix)}
className="rounded-full bg-[#F5F1EA] px-2.5 py-1 text-xs font-semibold text-[#6F6258]"
>
{prefix.dictionaryForm}
</button>
))}
</RecommendationGroup>
)}
</div>
)}
</section>
);
}
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4 text-sm leading-6 text-[#8B7E74]">
</section>
);
};
const RecommendationGroup: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div>
<div className="mb-1.5 text-xs font-semibold text-[#8B7E74]">{title}</div>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);
+2 -2
View File
@@ -350,7 +350,7 @@ export const LineItem: React.FC<LineItemProps> = ({
e.stopPropagation();
onNoteClick(earliestNote!);
}}
className={`${colorClass} px-1 rounded border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`}
className={`${colorClass} px-1 border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`}
title={`点击查看注释: ${earliestNote.comment}`}
>
{matchText}
@@ -456,7 +456,7 @@ export const LineItem: React.FC<LineItemProps> = ({
onDoubleClick(line.id);
}
}}
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 rounded px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
canEdit ? 'cursor-text' : 'cursor-default'
}`}
>
+20 -20
View File
@@ -197,20 +197,20 @@ export const Sidebar: React.FC<SidebarProps> = ({
{selectedFurigana && selectedFuriganaLine && selectedFuriganaItem ? (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border-l-4 border-[#8B7E74] px-2 py-0.5 flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" />
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
className="text-[#A69F92] hover:text-[#2D2926] hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="关闭假名操作区"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
@@ -221,14 +221,14 @@ export const Sidebar: React.FC<SidebarProps> = ({
onUpdateFurigana(selectedFurigana.lineId, selectedFurigana.itemId, e.target.value)
}
placeholder="在此修改假名"
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] rounded px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
/>
<div className="mt-1 text-[11px] text-[#A69F92] font-mono">
{(selectedFuriganaLineIndex + 1).toString().padStart(2, '0')}
</div>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] text-[#8B7E74] font-bold uppercase font-mono tracking-wider">
X
@@ -262,14 +262,14 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button
type="button"
onClick={() => onJumpToLine(selectedFurigana.lineId)}
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 rounded shadow-xs transition-all cursor-pointer"
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={() => onDeleteFurigana(selectedFurigana.lineId, selectedFurigana.itemId)}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 transition-colors flex items-center justify-center gap-1 cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -280,13 +280,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
/* ACTIVE NEW NOTE FORM */
<form onSubmit={handleSaveNote} className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border-l-4 border-[#8B7E74] px-2 py-0.5 flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" /> {isEditing ? '修改注释' : '新建注释'}
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
className="text-[#A69F92] hover:text-[#2D2926] hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="取消选择"
>
<X className="w-4 h-4" />
@@ -294,7 +294,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div>
{/* Selected Text Showcase */}
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
@@ -317,7 +317,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
title={c.label}
className={`w-6 h-6 rounded-full border-2 transition-all flex items-center justify-center ${c.bg} ${
selectedColor === c.value
? 'border-[#8B7E74] scale-110 shadow-xs'
? 'border-[#8B7E74] scale-110'
: 'border-transparent hover:scale-105'
}`}
>
@@ -340,7 +340,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="在此录入释义、语法要点、生词短语等注释..."
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed shadow-inner"
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] p-2.5 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed"
required
/>
</div>
@@ -355,7 +355,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClearSelection();
}
}}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 transition-colors flex items-center justify-center gap-1 cursor-pointer"
title="删除此条注释"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -364,7 +364,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
)}
<button
type="submit"
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 rounded shadow-xs hover:bg-[#786C63] active:bg-[#665B53] transition-all font-sans cursor-pointer text-center`}
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 hover:bg-[#786C63] active:bg-[#665B53] transition-colors font-sans cursor-pointer text-center`}
>
{isEditing ? '保存修改' : '保存此条注释'}
</button>
@@ -387,7 +387,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
<>
{exactMatchedNotes.length > 0 && (
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE]">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 shadow-xs">
<div className="bg-amber-50 border-l-4 border-amber-300 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm text-amber-900 font-bold font-sans tracking-wide">
@@ -401,7 +401,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
{exactMatchedNotes.map((note) => (
<div
key={`${note.lineId}-${note.id}`}
className="rounded border border-amber-200 bg-white p-2.5 shadow-xs"
className="border-l-4 border-amber-200 bg-white p-2.5"
>
<div className="mb-1.5 flex items-center justify-between gap-2">
{note.source === 'current' ? (
@@ -464,7 +464,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onFocusNote(note.id);
}
}}
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
className={`group border-b p-3 bg-white transition-colors flex flex-col gap-2 relative cursor-pointer ${
isFocused
? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30'
: 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
@@ -474,7 +474,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
<div className="flex items-center justify-between">
{/* Highlight text indicator */}
<span
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
className={`text-xs font-semibold px-2 py-0.5 border ${badgeColorClass} font-serif max-w-[70%] truncate`}
title={note.text}
>
{note.text}
@@ -486,7 +486,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
e.stopPropagation();
onJumpToLine(note.lineId);
}}
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 font-mono text-[10px] flex items-center gap-0.5 transition-colors cursor-pointer"
title="点击跳转并定位此行"
>
{note.lineNumber.toString().padStart(2, '0')}
@@ -506,7 +506,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onDeleteNote(note.lineId, note.id);
}}
title="删除此注释"
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 transition-colors md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
</button>
+11 -11
View File
@@ -34,10 +34,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
canEditDocument,
}) => {
return (
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-3 py-2 sm:px-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 select-none">
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-2 py-1.5 sm:px-3 flex flex-col sm:flex-row sm:items-center justify-between gap-1.5 select-none">
{/* Brand / Logo */}
<div className="flex items-center gap-2 min-w-0">
<div className="bg-[#8B7E74] text-white rounded-lg p-2 shadow-sm">
<div className="bg-[#8B7E74] text-white p-1.5">
<BookOpen className="w-4 h-4" />
</div>
<div>
@@ -48,10 +48,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</div>
{/* Document actions */}
<div className="flex flex-wrap items-center gap-1.5 text-xs select-none font-sans">
<div className="flex flex-wrap items-center gap-px text-xs select-none font-sans">
<button
onClick={onSaveDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium transition-colors cursor-pointer"
title="按当前文档标题保存到项目 saves 目录"
>
<Save className="w-3.5 h-3.5 text-white" />
@@ -61,10 +61,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{canEditDocument && (
<button
onClick={onToggleBulkImport}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
className={`flex items-center gap-1 px-3 py-1.5 font-medium transition-colors cursor-pointer ${
showImportArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
? 'bg-[#8B7E74] text-white'
: 'bg-white hover:bg-[#EFECE7] text-[#2D2926]'
}`}
title="粘贴整段文本并按行导入"
>
@@ -76,7 +76,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{canEditDocument && (
<button
onClick={onNewDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#EFECE7] text-[#2D2926] font-medium transition-colors cursor-pointer"
title="新建空白文档"
>
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
@@ -86,10 +86,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<button
onClick={onViewSavedDocuments}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
className={`flex items-center gap-1 px-3 py-1.5 font-medium transition-colors cursor-pointer ${
showSavedArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
? 'bg-[#8B7E74] text-white'
: 'bg-white hover:bg-[#EFECE7] text-[#2D2926]'
}`}
title="打开已保存歌词"
>
+62 -54
View File
@@ -4,7 +4,7 @@
*/
import React, { useEffect, useState } from 'react';
import { ArrowLeft, BookOpen, Layers3 } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { apiUrl } from '../api';
interface VocabEntry {
@@ -29,11 +29,11 @@ interface VocabCard {
}
const colorClassFor = (color: string) => {
if (color === 'rose') return 'bg-rose-100 text-rose-950 border-rose-200';
if (color === 'sky') return 'bg-sky-100 text-sky-950 border-sky-200';
if (color === 'emerald') return 'bg-emerald-100 text-emerald-950 border-emerald-200';
if (color === 'violet') return 'bg-purple-100 text-purple-950 border-purple-200';
return 'bg-[#E8E4DE] text-[#2D2926] border-[#8B7E74]/30';
if (color === 'rose') return 'border-rose-300 text-rose-950';
if (color === 'sky') return 'border-sky-300 text-sky-950';
if (color === 'emerald') return 'border-emerald-300 text-emerald-950';
if (color === 'violet') return 'border-purple-300 text-purple-950';
return 'border-[#8B7E74]/40 text-[#2D2926]';
};
export const VocabCards: React.FC = () => {
@@ -67,17 +67,16 @@ export const VocabCards: React.FC = () => {
}, []);
return (
<div className="flex-1 overflow-y-auto bg-[#FAF9F6] px-2 py-3 lg:px-6 lg:pb-6">
<div className="flex-1 overflow-y-auto bg-[#F5F1EA] px-3 py-4 lg:px-8 lg:py-6">
<div className="mx-auto max-w-5xl">
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-4 py-3 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-4 px-1">
<div>
<div className="flex items-center gap-2 font-serif text-lg font-bold text-[#2D2926]">
<Layers3 className="h-5 w-5 text-[#8B7E74]" />
<div className="font-serif text-[clamp(1.7rem,3vw,2.3rem)] font-bold leading-tight tracking-wide text-[#2D2926]">
{selectedCard ? selectedCard.text : '单词卡片'}
</div>
{!selectedCard && (
<div className="mt-1 text-xs text-[#A69F92]">
<div className="mt-1.5 text-sm text-[#8B7E74]">
{cards.length}
</div>
)}
</div>
@@ -85,76 +84,85 @@ export const VocabCards: React.FC = () => {
<button
type="button"
onClick={() => setSelectedCard(null)}
className="flex items-center gap-1 rounded-full border border-[#E8E4DE] bg-white px-3 py-1.5 text-xs font-semibold text-[#8B7E74] hover:bg-[#FAF9F6]"
className="flex items-center gap-1 rounded-full bg-[#E8E1D8] px-3 py-1.5 text-sm font-semibold text-[#6F6258] active:bg-[#DCD2C7]"
>
<ArrowLeft className="h-3.5 w-3.5" />
<ArrowLeft className="h-4 w-4" />
</button>
) : (
<div className="rounded-full bg-[#8B7E74]/10 px-3 py-1 text-xs font-semibold text-[#8B7E74]">
{cards.length}
</div>
)}
) : null}
</div>
{status === 'loading' && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
<div className="py-16 text-center text-sm text-[#8B7E74]">
...
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-rose-200 bg-rose-50 py-10 text-center text-sm text-rose-700">
<div className="py-16 text-center text-sm text-rose-700">
</div>
)}
{status === 'ready' && cards.length === 0 && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
<div className="rounded-2xl bg-[#FDFCFB] px-4 py-12 text-center text-sm text-[#8B7E74]">
</div>
)}
{status === 'ready' && selectedCard && (
<div className="space-y-3">
<div className={`rounded-xl border p-3 text-sm leading-relaxed ${colorClassFor(selectedCard.latestColor)}`}>
{selectedCard.latestComment || '无释义内容'}
</div>
<div className="space-y-4">
<section className="rounded-2xl bg-[#FDFCFB] px-4 py-4">
<div className="mb-2 text-xs font-semibold text-[#8B7E74]"></div>
<div className={`border-l-4 pl-3 text-base leading-7 ${colorClassFor(selectedCard.latestColor)}`}>
{selectedCard.latestComment || '无释义内容'}
</div>
<div className="mt-3 text-xs leading-6 text-[#8B7E74]">
{selectedCard.noteCount} / {selectedCard.documentTitles.length}
</div>
</section>
<div className="space-y-2">
{selectedCard.entries.map((entry, index) => (
<div
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
className="rounded-xl border border-[#E8E4DE]/70 bg-white p-3 shadow-sm"
>
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[#8B7E74]">
<BookOpen className="h-3.5 w-3.5" />
{entry.documentTitle} · {entry.lineNumber}
</div>
<div className="text-sm leading-relaxed text-[#2D2926]">
{entry.lineText}
</div>
<div className={`mt-2 rounded-lg border px-2 py-1.5 text-sm ${colorClassFor(entry.color)}`}>
{entry.comment}
</div>
</div>
))}
</div>
<section className="overflow-hidden rounded-2xl bg-[#FDFCFB]">
<div className="px-4 py-3 text-xs font-semibold text-[#8B7E74]"></div>
<div className="divide-y divide-[#E8E4DE]">
{selectedCard.entries.map((entry, index) => (
<section
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
className="grid gap-2 px-4 py-3 md:grid-cols-[150px_minmax(0,1fr)] md:gap-5"
>
<div className="text-xs leading-5 text-[#8B7E74]">
<div className="font-semibold text-[#2D2926]">{entry.documentTitle}</div>
<div> {entry.lineNumber}</div>
</div>
<div>
<div className="font-serif text-base leading-7 text-[#2D2926]">
{entry.lineText}
</div>
<div className={`mt-2 border-l-4 pl-3 text-sm leading-6 ${colorClassFor(entry.color)}`}>
{entry.comment}
</div>
</div>
</section>
))}
</div>
</section>
</div>
)}
{status === 'ready' && !selectedCard && (
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
<div className="rounded-2xl bg-[#FDFCFB] p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{cards.map((card) => (
<button
key={card.normalizedText}
type="button"
onClick={() => setSelectedCard(card)}
className="min-h-14 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-2 py-2 text-center font-serif text-base font-bold leading-snug text-[#2D2926] shadow-sm transition hover:border-[#8B7E74]/50 hover:bg-white active:bg-[#E8E4DE]/30 sm:text-lg"
>
{card.text}
</button>
<button
key={card.normalizedText}
type="button"
onClick={() => setSelectedCard(card)}
className="min-h-12 rounded-xl bg-[#F5F1EA] px-3 py-2 text-center font-serif text-[clamp(1rem,2vw,1.18rem)] font-semibold leading-tight tracking-wide text-[#2D2926] transition-colors hover:bg-[#EFE7DD] active:bg-[#E8E1D8]"
>
{card.text}
</button>
))}
</div>
</div>
)}
</div>