添加动词拼接学习页
整合动词拼接沙盒,并扩充常见复合动词词库,方便在歌词学习之外进行动词组合练习。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+24
-21
@@ -10,6 +10,7 @@ import { LineItem } from './components/LineItem';
|
||||
import { Sidebar, SavedDocumentNote } from './components/Sidebar';
|
||||
import { AppNav, AppView } from './components/AppNav';
|
||||
import { VocabCards } from './components/VocabCards';
|
||||
import { CompoundVerbLab } from './components/CompoundVerbLab';
|
||||
import { adjustFuriganaItemsForTextChange, normalizeLine, normalizeLines } from './utils';
|
||||
import { apiUrl } from './api';
|
||||
import { Check, Copy, Plus, X } from 'lucide-react';
|
||||
@@ -980,7 +981,7 @@ export default function App() {
|
||||
{saveNotice && (
|
||||
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2 pointer-events-none">
|
||||
<div
|
||||
className={`rounded-full border px-4 py-1.5 text-xs font-semibold shadow-sm ${
|
||||
className={`border px-4 py-1.5 text-xs font-semibold ${
|
||||
saveNotice.type === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
@@ -1007,8 +1008,8 @@ export default function App() {
|
||||
<AppNav activeView={activeView} onChange={setActiveView} />
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeView === 'lyrics' && showSavedArea && (
|
||||
<div className="fixed left-1/2 top-20 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
|
||||
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-2 shadow-xl animate-in fade-in duration-200">
|
||||
<div className="fixed left-1/2 top-16 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
|
||||
<div className="bg-[#FDFCFB] border border-[#E8E4DE] p-1 animate-in fade-in duration-200">
|
||||
{serverSaveFiles.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-[min(62vh,420px)] overflow-y-auto">
|
||||
{serverSaveFiles.map((file) => (
|
||||
@@ -1016,7 +1017,7 @@ export default function App() {
|
||||
key={file.name}
|
||||
type="button"
|
||||
onClick={() => handleLoadServerSave(file)}
|
||||
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border border-[#E8E4DE]/70 hover:border-[#8B7E74]/40 rounded-lg px-3 py-2.5 transition cursor-pointer"
|
||||
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border-b border-[#E8E4DE]/70 px-3 py-2 transition cursor-pointer last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[#2D2926] truncate">{file.title}</div>
|
||||
@@ -1025,7 +1026,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
|
||||
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE]">
|
||||
还没有保存的歌词
|
||||
</div>
|
||||
)}
|
||||
@@ -1036,9 +1037,9 @@ export default function App() {
|
||||
{activeView === 'lyrics' ? (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* LEFT COMPARTMENT: The writing sheet */}
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1 md:px-4 md:py-4 flex flex-col items-center bg-[#FAF9F6]">
|
||||
<div className="flex-1 overflow-y-auto px-0 py-0 md:px-2 md:py-2 flex flex-col items-center bg-[#FAF9F6]">
|
||||
<div
|
||||
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] px-1 py-2 sm:p-5 md:p-8 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
|
||||
className="w-full bg-[#FDFCFB] min-h-[85vh] h-auto flex-shrink-0 px-1 py-2 sm:p-4 md:p-6 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
|
||||
>
|
||||
<div className="mt-1 mb-3 md:mt-2 md:mb-4 flex justify-center">
|
||||
<input
|
||||
@@ -1057,10 +1058,10 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{showImportArea && (
|
||||
<div className="mt-1 mb-3 text-xs select-none relative z-20 bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-4 shadow-sm animate-in fade-in duration-200">
|
||||
<div className="mt-1 mb-3 text-xs select-none relative z-20 bg-[#FDFCFB] border-y border-[#E8E4DE] p-3 animate-in fade-in duration-200">
|
||||
<p className="text-[11px] text-[#A69F92] mb-3 leading-relaxed">
|
||||
粘贴整段日语文本后,系统会按换行切成写字板的每一行;也支持类似
|
||||
<code className="bg-[#FAF9F6] px-1 py-0.5 mx-1 text-[#8B7E74] font-mono rounded">
|
||||
<code className="bg-[#FAF9F6] px-1 py-0.5 mx-1 text-[#8B7E74] font-mono">
|
||||
日本語{にほんご}
|
||||
</code>
|
||||
的注音格式。
|
||||
@@ -1070,7 +1071,7 @@ export default function App() {
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder="在此粘贴整段文本,每一行会变成写字板的一行。"
|
||||
className="w-full text-xs font-mono bg-[#FAF9F6] border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] placeholder-[#A69F92]/60 resize-y"
|
||||
className="w-full text-xs font-mono bg-[#FAF9F6] border border-[#E8E4DE] p-2.5 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] placeholder-[#A69F92]/60 resize-y"
|
||||
/>
|
||||
<div className="mt-3.5 flex flex-wrap items-center justify-between gap-3 border-t border-[#E8E4DE]/40 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px]">
|
||||
@@ -1102,13 +1103,13 @@ export default function App() {
|
||||
setImportText('');
|
||||
setShowImportArea(false);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-white border border-[#E8E4DE] hover:bg-[#FAF9F6] text-[#A69F92] hover:text-[#2D2926] font-semibold rounded-lg transition"
|
||||
className="px-3 py-1.5 bg-white border border-[#E8E4DE] hover:bg-[#FAF9F6] text-[#A69F92] hover:text-[#2D2926] font-semibold transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkImport}
|
||||
className="px-4 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-bold rounded-lg transition shadow-2xs hover:shadow-xs disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="px-4 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-bold transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={!importText.trim()}
|
||||
>
|
||||
确认导入
|
||||
@@ -1128,7 +1129,7 @@ export default function App() {
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn rounded-xl border-x border-[#8B7E74]/10 bg-[#FDFCFB]/70 shadow-[inset_4px_0_10px_-10px_rgba(139,126,116,0.45),inset_-4px_0_10px_-10px_rgba(139,126,116,0.45)] md:border-x-0 md:bg-transparent md:shadow-none"
|
||||
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn bg-[#FDFCFB]/70 md:bg-transparent"
|
||||
style={paperBackgroundStyle}
|
||||
>
|
||||
{lines.map((line, idx) => (
|
||||
@@ -1216,22 +1217,24 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : activeView === 'vocab' ? (
|
||||
<VocabCards />
|
||||
) : (
|
||||
<CompoundVerbLab />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Floating Sticky Drawer for Mobile / Tablet screens which normally hides the Sidebar */}
|
||||
{activeView === 'lyrics' && currentSelection && (
|
||||
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
|
||||
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setCurrentSelection(null);
|
||||
setFocusedNoteId(null);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
|
||||
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 hover:bg-[#FAF9F6] cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -1251,7 +1254,7 @@ export default function App() {
|
||||
showTransientSaveNotice('复制失败', 'error');
|
||||
}
|
||||
}}
|
||||
className="shrink-0 flex items-center gap-1 rounded-md border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
|
||||
className="shrink-0 flex items-center gap-1 border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
|
||||
title="复制选中文本"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
@@ -1289,7 +1292,7 @@ export default function App() {
|
||||
rows={4}
|
||||
id="comment-textarea-mobile-existing"
|
||||
defaultValue={mobileSelectedNote.comment}
|
||||
className="w-full text-sm bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
|
||||
className="w-full text-sm bg-white border border-[#E8E4DE] p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1308,7 +1311,7 @@ export default function App() {
|
||||
setFocusedNoteId(null);
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
>
|
||||
保存修改
|
||||
</button>
|
||||
@@ -1341,7 +1344,7 @@ export default function App() {
|
||||
rows={3}
|
||||
id="comment-textarea-mobile"
|
||||
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
|
||||
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
|
||||
className="w-full text-xs bg-white border border-[#E8E4DE] p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1353,7 +1356,7 @@ export default function App() {
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||
>
|
||||
快速保存
|
||||
</button>
|
||||
|
||||
+13
-12
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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="打开已保存歌词"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface Verb {
|
||||
dictionaryForm: string; // e.g. 思う
|
||||
masuStemForm: string; // e.g. 思い
|
||||
kanji: string; // e.g. 思う (if different, otherwise same)
|
||||
reading: string; // e.g. おもう
|
||||
romaji: string; // e.g. omou
|
||||
meaning: string; // e.g. 想、认为
|
||||
}
|
||||
|
||||
export interface SuffixVerb {
|
||||
id: string; // e.g. "dasu"
|
||||
kanji: string; // e.g. 出す
|
||||
reading: string; // e.g. だす
|
||||
meaning: string; // e.g. 动作开始、突然发生、向外
|
||||
description: string; // Comprehensive explanation of what this suffix adds to a verb
|
||||
exampleInEnglish: string;
|
||||
}
|
||||
|
||||
export interface CompoundVerb {
|
||||
id: string; // Unique identifier, e.g., "omoidasu"
|
||||
kanji: string; // e.g. 思い出す
|
||||
reading: string; // e.g. おもいだす
|
||||
romaji: string; // e.g. omoidasu
|
||||
prefix: Verb; // The first verb (zenkou doushi)
|
||||
suffix: SuffixVerb; // The second verb (koukou doushi)
|
||||
meaning: string; // e.g. 想起、回忆起
|
||||
jlpt: 'N3' | 'N2' | 'N1'; // JLPT Level
|
||||
exampleSentence: {
|
||||
japanese: string; // e.g. 昔の友人の名前を思い出した。
|
||||
reading: string; // e.g. むかしのゆうじんのなまえをおもいだした。
|
||||
translation: string; // e.g. 我想起了昔日好友的名字。
|
||||
};
|
||||
notes?: string; // Nuance or usage notes
|
||||
}
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: string;
|
||||
type: 'match' | 'fill_stem' | 'select_meaning';
|
||||
compoundVerb: CompoundVerb;
|
||||
options: string[];
|
||||
correctAnswer: string;
|
||||
prompt: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user