更新至 v0.9.1_20251225 版本

This commit is contained in:
2025-12-25 17:03:52 +08:00
parent 7a4d1c75a2
commit a1f9e76a13
7 changed files with 228 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import HomeView from './views/HomeView';
import ChatView from './views/ChatView';
import CreativeStudio from './views/CreativeStudio';
import SpeakingPracticeView from './views/SpeakingPracticeView';
@@ -11,7 +12,7 @@ import ListeningView from './views/ListeningView';
import ToastContainer, { ToastMessage } from './components/Toast';
import ConfirmModal from './components/ConfirmModal';
import Onboarding from './components/Onboarding';
import { MessageCircle, Palette, Mic2, Settings, Globe, Sparkles, BookOpen, Languages, Download, Upload, FileText, X, ScanText, Key, Save, Trash2, Menu, BrainCircuit, Link, Headphones, AlertTriangle } from 'lucide-react';
import { MessageCircle, Palette, Mic2, Settings, Globe, Sparkles, BookOpen, Languages, Download, Upload, FileText, X, ScanText, Key, Save, Trash2, Menu, BrainCircuit, Link, Headphones, AlertTriangle, Home } from 'lucide-react';
import { AppMode, Language, ChatMessage, TranslationRecord, AppDataBackup, Role, MessageType, ReadingLessonRecord, AVAILABLE_CHAT_MODELS, ChatSession, OCRRecord, ListeningLessonRecord } from './types';
import { translations } from './utils/localization';
import { USER_API_KEY_STORAGE, USER_BASE_URL_STORAGE } from './services/geminiService';
@@ -47,7 +48,7 @@ const safeJSONParse = <T,>(key: string, fallback: T): T => {
};
const App: React.FC = () => {
const [currentView, setCurrentView] = useState<AppMode>(AppMode.CHAT);
const [currentView, setCurrentView] = useState<AppMode>(AppMode.HOME);
// Safe Language Initialization
const [language, setLanguage] = useState<Language>(() => {
@@ -458,6 +459,9 @@ const App: React.FC = () => {
<nav className="px-3 pb-4 space-y-1 overflow-y-auto flex-1 scrollbar-hide">
{/* Main */}
<NavButton mode={AppMode.HOME} icon={Home} label={t.nav.home} colorClass="from-slate-600 to-slate-800" />
{/* Study & Input */}
<div className="px-3 mt-4 mb-2 flex items-center gap-2 opacity-70">
<div className="w-1 h-1 rounded-full bg-indigo-400"></div>
@@ -504,6 +508,7 @@ const App: React.FC = () => {
<div className="w-8" />
</div>
<div className="flex-1 relative overflow-hidden">
{currentView === AppMode.HOME && <HomeView language={language} onNavigate={handleViewChange} />}
{currentView === AppMode.CHAT && <ChatView language={language} sessions={chatSessions} activeSessionId={activeSessionId} onNewSession={createNewSession} onSelectSession={setActiveSessionId} onDeleteSession={deleteSession} onClearAllSessions={clearAllChatSessions} onUpdateSession={updateSessionMessages} selectedModel={selectedModel} addToast={addToast} />}
{currentView === AppMode.TRANSLATION && <TranslationView language={language} history={translationHistory} addToHistory={(rec) => setTranslationHistory(prev => [...prev, rec])} clearHistory={clearTranslationHistory} onDeleteHistoryItem={deleteTranslationRecord} />}
{currentView === AppMode.SPEAKING && <SpeakingPracticeView language={language} />}

View File

@@ -1,4 +1,4 @@
import React, { ErrorInfo, ReactNode } from "react";
import React, { Component, ErrorInfo, ReactNode } from "react";
import { AlertCircle, RefreshCw, Trash2 } from 'lucide-react';
interface Props {
@@ -10,7 +10,7 @@ interface State {
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null

Binary file not shown.

Binary file not shown.

View File

@@ -38,6 +38,7 @@ export interface ChatSession {
}
export enum AppMode {
HOME = 'home', // Homepage
CHAT = 'chat',
READING = 'reading',
LISTENING = 'listening', // New Listening Mode

View File

@@ -176,6 +176,7 @@ export const translations = {
en: {
appTitle: "Sakura Sensei 🌸",
nav: {
home: "Home",
sectionStudy: "Study & Input",
sectionPractice: "Practice & Output",
sectionTools: "Toolbox",
@@ -221,6 +222,27 @@ export const translations = {
storageOptimized: "Storage full. Cleared audio cache to save text.",
dataCleared: "All data has been cleared."
},
home: {
welcome: "Welcome back!",
subtitle: "What would you like to learn today?",
dailyPhrase: "Daily Phrase",
playAudio: "Play",
start: "Start",
features: {
chatTitle: "Chat Tutor",
chatDesc: "Practice conversation freely with Sakura.",
readingTitle: "Reading Hall",
readingDesc: "Generate stories and articles for your level.",
listeningTitle: "Listening Lab",
listeningDesc: "Train your ears with quizzes.",
speakingTitle: "Roleplay",
speakingDesc: "Real-world scenarios with feedback.",
creativeTitle: "Creative",
creativeDesc: "Generate images & videos.",
toolsTitle: "Tools",
toolsDesc: "Scanner & Translator."
}
},
onboarding: {
welcome: "Welcome to Sakura Sensei!",
desc1: "Your AI-powered companion for mastering Japanese.",
@@ -435,6 +457,7 @@ export const translations = {
ja: {
appTitle: "さくら先生 🌸",
nav: {
home: "ホーム",
sectionStudy: "学習とインプット",
sectionPractice: "練習とアウトプット",
sectionTools: "ツールボックス",
@@ -480,6 +503,27 @@ export const translations = {
storageOptimized: "保存容量がいっぱいのため、音声キャッシュを削除してテキストのみ保存しました。",
dataCleared: "すべてのデータが消去されました。"
},
home: {
welcome: "おかえりなさい!",
subtitle: "今日は何を学びますか?",
dailyPhrase: "今日のフレーズ",
playAudio: "再生",
start: "開始",
features: {
chatTitle: "AIチューター",
chatDesc: "さくら先生と自由に会話。",
readingTitle: "読書の間",
readingDesc: "レベルに合わせた読み物を生成。",
listeningTitle: "聴解ラボ",
listeningDesc: "クイズ形式でリスニング力アップ。",
speakingTitle: "会話道場",
speakingDesc: "リアルな場面でロールプレイ。",
creativeTitle: "アトリエ",
creativeDesc: "画像や動画で学習を楽しく。",
toolsTitle: "ツール",
toolsDesc: "スキャン&翻訳機能。"
}
},
onboarding: {
welcome: "さくら先生へようこそ!",
desc1: "あなたのためのAI日本語学習パートナーです。",
@@ -694,6 +738,7 @@ export const translations = {
zh: {
appTitle: "樱花老师 🌸",
nav: {
home: "首页",
sectionStudy: "学习与输入",
sectionPractice: "练习与输出",
sectionTools: "工具箱",
@@ -739,6 +784,27 @@ export const translations = {
storageOptimized: "存储空间已满,已自动清除音频缓存以保存文本。",
dataCleared: "所有数据已清除。"
},
home: {
welcome: "欢迎回来!",
subtitle: "今天想学点什么?",
dailyPhrase: "每日一句",
playAudio: "播放",
start: "开始",
features: {
chatTitle: "AI 导师",
chatDesc: "与樱花老师自由对话。",
readingTitle: "阅读室",
readingDesc: "生成适合您水平的文章。",
listeningTitle: "听力实验室",
listeningDesc: "通过测验提高听力。",
speakingTitle: "对话道场",
speakingDesc: "真实场景角色扮演。",
creativeTitle: "工作室",
creativeDesc: "用图像和视频辅助学习。",
toolsTitle: "工具箱",
toolsDesc: "扫描翻译功能。"
}
},
onboarding: {
welcome: "欢迎来到樱花老师!",
desc1: "您的AI日语学习伙伴。",

152
views/HomeView.tsx Normal file
View File

@@ -0,0 +1,152 @@
import React, { useState, useRef, useEffect } from 'react';
import { AppMode, Language } from '../types';
import { MessageCircle, Mic2, BookOpen, Headphones, ScanText, Languages, Palette, Play, Square, Loader2, Sparkles, Home } from 'lucide-react';
import { translations } from '../utils/localization';
import { geminiService, decodeAudioData } from '../services/geminiService';
interface HomeViewProps {
language: Language;
onNavigate: (mode: AppMode) => void;
}
const PHRASES = [
{ ja: "継続は力なり", reading: "Keizoku wa chikara nari", en: "Perseverance is power.", zh: "坚持就是力量。" },
{ ja: "一期一会", reading: "Ichigo ichie", en: "Treasure every meeting, for it will never recur.", zh: "一期一会 (珍惜每次相遇)。" },
{ ja: "千里の道も一歩から", reading: "Senri no michi mo ippo kara", en: "A journey of a thousand miles begins with a single step.", zh: "千里之行,始于足下。" },
{ ja: "七転び八起き", reading: "Nanakorobi yaoki", en: "Fall seven times, stand up eight.", zh: "七颠八起 (百折不挠)。" },
{ ja: "知らぬが仏", reading: "Shiranu ga hotoke", en: "Ignorance is bliss.", zh: "不知者是佛 (眼不见为净)。" },
{ ja: "花鳥風月", reading: "Kachou fuugetsu", en: "Experience the beauties of nature.", zh: "花鸟风月 (大自然的美景)。" },
{ ja: "以心伝心", reading: "Ishin denshin", en: "Telepathy / Tacit understanding.", zh: "以心传心 (心领神会)。" }
];
const HomeView: React.FC<HomeViewProps> = ({ language, onNavigate }) => {
const t = translations[language].home;
const tNav = translations[language].nav;
const [phrase, setPhrase] = useState(PHRASES[0]);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
useEffect(() => {
// Select phrase based on day of year to change daily
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000);
setPhrase(PHRASES[dayOfYear % PHRASES.length]);
}, []);
useEffect(() => {
return () => {
if (audioSourceRef.current) audioSourceRef.current.stop();
};
}, []);
const playAudio = async () => {
if (isPlaying) {
if (audioSourceRef.current) audioSourceRef.current.stop();
setIsPlaying(false);
return;
}
setIsLoadingAudio(true);
try {
const audioBase64 = await geminiService.generateSpeech(phrase.ja);
if (audioBase64) {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (ctx.state === 'suspended') await ctx.resume();
const buffer = await decodeAudioData(audioBase64, ctx);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.onended = () => setIsPlaying(false);
source.start();
audioSourceRef.current = source;
setIsPlaying(true);
}
} catch (e) {
console.error("Audio playback failed", e);
} finally {
setIsLoadingAudio(false);
}
};
const features = [
{ mode: AppMode.CHAT, title: t.features.chatTitle, desc: t.features.chatDesc, icon: MessageCircle, color: 'text-indigo-500', bg: 'bg-indigo-50 border-indigo-100' },
{ mode: AppMode.READING, title: t.features.readingTitle, desc: t.features.readingDesc, icon: BookOpen, color: 'text-emerald-500', bg: 'bg-emerald-50 border-emerald-100' },
{ mode: AppMode.LISTENING, title: t.features.listeningTitle, desc: t.features.listeningDesc, icon: Headphones, color: 'text-sky-500', bg: 'bg-sky-50 border-sky-100' },
{ mode: AppMode.SPEAKING, title: t.features.speakingTitle, desc: t.features.speakingDesc, icon: Mic2, color: 'text-orange-500', bg: 'bg-orange-50 border-orange-100' },
{ mode: AppMode.TRANSLATION, title: t.features.toolsTitle, desc: t.features.toolsDesc, icon: Languages, color: 'text-blue-500', bg: 'bg-blue-50 border-blue-100' },
{ mode: AppMode.CREATIVE, title: t.features.creativeTitle, desc: t.features.creativeDesc, icon: Palette, color: 'text-purple-500', bg: 'bg-purple-50 border-purple-100' },
];
return (
<div className="h-full overflow-y-auto bg-slate-50/50 p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-10 pb-20">
{/* Header */}
<div className="animate-fade-in-up">
<h1 className="text-3xl md:text-4xl font-extrabold text-slate-800 mb-2">{t.welcome}</h1>
<p className="text-slate-500 text-lg">{t.subtitle}</p>
</div>
{/* Daily Phrase Card */}
<div className="bg-white rounded-3xl p-8 border border-slate-100 shadow-xl shadow-slate-200/50 relative overflow-hidden animate-scale-in group">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-bl from-pink-50 to-transparent -mr-20 -mt-20 rounded-full opacity-70 group-hover:scale-110 transition-transform duration-700 pointer-events-none"></div>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="text-pink-400 animate-pulse" size={20} />
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">{t.dailyPhrase}</h3>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<p className="text-4xl md:text-5xl font-black text-slate-800 mb-3 font-serif leading-tight">{phrase.ja}</p>
<p className="text-lg text-slate-500 font-medium mb-1">{phrase.reading}</p>
<p className="text-slate-400 italic">{language === 'zh' ? phrase.zh : phrase.en}</p>
</div>
<button
onClick={playAudio}
disabled={isLoadingAudio}
className={`w-16 h-16 rounded-full flex items-center justify-center transition-all shadow-lg hover:scale-105 active:scale-95 flex-shrink-0 ${
isPlaying
? 'bg-pink-500 text-white shadow-pink-200'
: 'bg-white border-2 border-slate-100 text-slate-400 hover:border-pink-200 hover:text-pink-500'
}`}
>
{isLoadingAudio ? <Loader2 size={24} className="animate-spin" /> : isPlaying ? <Square size={24} fill="currentColor" /> : <Play size={28} fill="currentColor" className="ml-1" />}
</button>
</div>
</div>
</div>
{/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feat, idx) => (
<button
key={idx}
onClick={() => onNavigate(feat.mode)}
className={`p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-lg border group ${feat.bg} animate-fade-in-up`}
style={{ animationDelay: `${idx * 100}ms` }}
>
<div className={`w-12 h-12 rounded-xl bg-white shadow-sm flex items-center justify-center mb-4 ${feat.color} group-hover:scale-110 transition-transform`}>
<feat.icon size={24} />
</div>
<h3 className="text-lg font-bold text-slate-800 mb-1 group-hover:text-indigo-600 transition-colors">{feat.title}</h3>
<p className="text-sm text-slate-500 leading-relaxed">{feat.desc}</p>
</button>
))}
</div>
</div>
</div>
);
};
export default HomeView;