更新至 v0.9.1_20251225 版本
This commit is contained in:
9
App.tsx
9
App.tsx
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
releases/HTY1024-APP-SKR-0.9.0_20251225.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.9.0_20251225.zip
Normal file
Binary file not shown.
BIN
releases/HTY1024-APP-SKR-0.9.1_20251225.zip
Normal file
BIN
releases/HTY1024-APP-SKR-0.9.1_20251225.zip
Normal file
Binary file not shown.
1
types.ts
1
types.ts
@@ -38,6 +38,7 @@ export interface ChatSession {
|
||||
}
|
||||
|
||||
export enum AppMode {
|
||||
HOME = 'home', // Homepage
|
||||
CHAT = 'chat',
|
||||
READING = 'reading',
|
||||
LISTENING = 'listening', // New Listening Mode
|
||||
|
||||
@@ -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
152
views/HomeView.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user