添加动词拼接学习页
整合动词拼接沙盒,并扩充常见复合动词词库,方便在歌词学习之外进行动词组合练习。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user