type TopicType = "经历" | "观点" | "日常" | "作品感想" | "工作表达";
type MasteryStatus = "未练习" | "练习中" | "已掌握";
type ViewKey = "dashboard" | "quick" | "translation" | "monologue" | "podcast" | "weekly" | "topic" | "practice" | "corpus" | "daily" | "review" | "tasks" | "settings";

interface Topic {
  id: string;
  title: string;
  type: TopicType;
  createdAt: string;
  currentDay: number;
}

interface PracticeSession {
  id: string;
  day: number;
  rawTranscript: string;
  selfCorrection: string;
  aiCorrection: string;
  notes: string;
  createdAt: string;
}

interface CorpusItem {
  id: string;
  chunk: string;
  meaningZh: string;
  usageScene: string;
  exampleJa: string;
  masteryStatus: MasteryStatus;
  tags?: string[];
  status?: "active" | "archived";
  sourceType?: "manual" | "quick" | "weekly" | "translation" | "listening" | "monologue" | string;
  sourceLabel?: string;
  sourceRef?: string;
  detailStatus?: "pending" | "processing" | "ready" | "error";
  detailError?: string;
  explanation?: string;
  partOfSpeech?: string;
  reading?: string;
  examples?: string[];
  createdAt?: string;
  updatedAt?: string;
}

interface AIResponse {
  improvedVersion: string;
  explanation: string;
  corpusItems: CorpusItem[];
}

interface AssistResponse {
  suggestions: CorpusItem[];
  answer: string;
}

interface TranslationItem {
  id: string;
  sourceSentence?: string;
  zhSentence: string;
  recommendedJa: string;
  structureNotes: string;
  keyExpressions: string[];
  keyExpressionReadings: Record<string, string>;
  focusExpressions: string[];
  tags: string[];
  status: "active" | "archived";
  listeningMastered: boolean;
  listeningTokens: string[];
  attemptCount?: number;
  lastAttemptAt?: string;
  nextDueAt: string;
  deletedAt?: string;
  createdAt: string;
  updatedAt: string;
}

interface TranslationEvaluation {
  score: number;
  feedback: string;
  correctedJa: string;
  missedExpressions: string[];
}

interface TranslationAttempt {
  id: string;
  itemId: string;
  userAnswer: string;
  score: number;
  feedback: string;
  correctedJa: string;
  missedExpressions: string[];
  note: string;
  createdAt: string;
}

interface MonologueAttempt {
  id: string;
  sentenceId: string;
  rawTranscript: string;
  improvedText: string;
  explanation: string;
  pronunciationTips: string[];
  createdAt: string;
}

interface MonologueSentence {
  id: string;
  taskId: string;
  position: number;
  blockType: "sentence" | "note" | "heading";
  content: string;
  targetText: string;
  collapsed: boolean;
  status: "draft" | "optimized" | "done";
  finalText: string;
  recordingUrl?: string;
  createdAt: string;
  updatedAt: string;
  attempts: MonologueAttempt[];
}

interface MonologueTask {
  id: string;
  title: string;
  taskTime: string;
  tags: string[];
  description: string;
  status: "active" | "completed" | "closed";
  summaryText: string;
  finalTranscript: string;
  createdAt: string;
  updatedAt: string;
  sentences: MonologueSentence[];
}

interface PodcastSentence {
  id: string;
  episodeId: string;
  position: number;
  text: string;
  tokens: string[];
  startSeconds: number;
  endSeconds: number;
  mastered: boolean;
  favorite: boolean;
  createdAt: string;
  updatedAt: string;
}

interface PodcastEpisode {
  id: string;
  title: string;
  sourceUrl: string;
  language: string;
  audioUrl: string;
  coverUrl: string;
  podcastTitle: string;
  tags: string[];
  transcript: string;
  status: string;
  sentenceCount?: number;
  deletedAt?: string;
  createdAt: string;
  updatedAt: string;
  sentences: PodcastSentence[];
}

interface PodcastMetadata {
  title: string;
  podcastTitle: string;
  coverUrl: string;
  tags: string[];
  releaseDate?: string;
}

interface ListeningProgress {
  itemId: string;
  nextDueAt: string;
  lastScore: number;
  revealedCount: number;
  tokenCount: number;
  reviewCount?: number;
  averageScore?: number;
  recentScores?: number[];
  updatedAt: string;
}

interface ListeningScoreSettings {
  revealPenaltyMax: number;
  replayPenaltyPerExtraPlay: number;
  replayPenaltyMax: number;
  promptHintPenalty: number;
  systemPlayPenalty: number;
  systemPlayPenaltyMax: number;
  analysisPenalty: number;
}

interface AppPreferences {
  listeningPlaybackRate: number;
  listeningScoreSettings: ListeningScoreSettings;
  exists?: boolean;
}

interface ListeningSentenceAnalysis {
  itemId: string;
  targetJa: string;
  meaningZh: string;
  reading: string;
  structureNotes: string;
  grammarPoints: string[];
  pronunciationTips: string[];
  wordMeanings?: {
    text: string;
    reading: string;
    meaningZh: string;
    note: string;
  }[];
  createdAt: string;
  updatedAt: string;
}

interface DrillSession {
  id: string;
  mode: "translation" | "listening";
  title: string;
  itemIds: string[];
  currentIndex: number;
  status: "active" | "stopped" | "completed" | "archived";
  filters: Record<string, string | string[]>;
  source: string;
  plannedDueAt: string;
  createdAt: string;
  updatedAt: string;
  endedAt: string;
}

interface DrillUndoEntry {
  mode: "translation" | "listening";
  item: TranslationItem;
  progress?: ListeningProgress | null;
  session?: DrillSession | null;
  message: string;
}

type DrillPriority = "old" | "new";
type ParseTaskStatus = "queued" | "running" | "canceling" | "completed" | "failed" | "canceled";

interface ParseTask {
  id: string;
  type: "translation_import" | string;
  title: string;
  status: ParseTaskStatus;
  totalCount: number;
  processedCount: number;
  succeededCount: number;
  skippedCount: number;
  failedCount: number;
  currentLabel: string;
  resultItems: any[];
  skippedItems: { sourceSentence: string; reason: string }[];
  errors: { sourceSentence: string; phase?: string; error: string }[];
  cancelRequested: boolean;
  createdAt: string;
  startedAt: string;
  updatedAt: string;
  finishedAt: string;
  elapsedSeconds: number;
}

type DifficultWordMap = Record<string, string[]>;

interface TranslationImportResult {
  task: ParseTask;
}

interface TranslationInspirationResult {
  title: string;
  sentences: string[];
  tags: string[];
}

interface AppState {
  topic: Topic | null;
  sessions: PracticeSession[];
  corpusItems: CorpusItem[];
  finalReview: string;
}

const { useEffect, useMemo, useRef, useState } = React;
const listeningRateOptions = [0.75, 1, 1.25, 1.5, 2];
const defaultListeningScoreSettings: ListeningScoreSettings = {
  revealPenaltyMax: 80,
  replayPenaltyPerExtraPlay: 6,
  replayPenaltyMax: 30,
  promptHintPenalty: 10,
  systemPlayPenalty: 4,
  systemPlayPenaltyMax: 16,
  analysisPenalty: 100
};

type ModeThemeKey = "dashboard" | "quick" | "translation" | "monologue" | "podcast" | "weekly" | "corpus" | "tasks" | "settings";

const modeThemes: Record<ModeThemeKey, {
  icon: string;
  accentText: string;
  primary: string;
  primaryHover: string;
  primaryShadow: string;
  secondary: string;
  secondaryHover: string;
  secondaryText: string;
  activeBg: string;
  activeText: string;
  softBg: string;
  hoverBg: string;
  border: string;
  ring: string;
}> = {
  dashboard: { icon: "⌂", accentText: "text-[#65717a]", primary: "#65717a", primaryHover: "#546068", primaryShadow: "rgba(101,113,122,0.18)", secondary: "#eef1f1", secondaryHover: "#e2e8e5", secondaryText: "#364247", activeBg: "bg-[#eef1f1]", activeText: "text-[#364247]", softBg: "bg-[#f6f7f5]", hoverBg: "hover:bg-[#eef1f1]", border: "border-[#d7ddd9]", ring: "ring-[#cfd8d2]" },
  quick: { icon: "↻", accentText: "text-[#64775f]", primary: "#64775f", primaryHover: "#52654f", primaryShadow: "rgba(100,119,95,0.18)", secondary: "#edf3ea", secondaryHover: "#dfe8db", secondaryText: "#3f553d", activeBg: "bg-[#edf3ea]", activeText: "text-[#3f553d]", softBg: "bg-[#f6f8f2]", hoverBg: "hover:bg-[#edf3ea]", border: "border-[#d7dfd0]", ring: "ring-[#cbd8c2]" },
  translation: { icon: "文", accentText: "text-[#5d6e7a]", primary: "#5d6e7a", primaryHover: "#4b5c68", primaryShadow: "rgba(93,110,122,0.18)", secondary: "#eef2f4", secondaryHover: "#dfe7eb", secondaryText: "#3f515e", activeBg: "bg-[#eef2f4]", activeText: "text-[#3f515e]", softBg: "bg-[#f7f8f7]", hoverBg: "hover:bg-[#eef2f4]", border: "border-[#d5dde0]", ring: "ring-[#c8d4d9]" },
  monologue: { icon: "話", accentText: "text-[#78665a]", primary: "#78665a", primaryHover: "#655348", primaryShadow: "rgba(120,102,90,0.18)", secondary: "#f3eee8", secondaryHover: "#e8ded3", secondaryText: "#59473d", activeBg: "bg-[#f3eee8]", activeText: "text-[#59473d]", softBg: "bg-[#f8f5f0]", hoverBg: "hover:bg-[#f3eee8]", border: "border-[#e1d8ce]", ring: "ring-[#d9cbbd]" },
  podcast: { icon: "♫", accentText: "text-[#71724d]", primary: "#71724d", primaryHover: "#5f6040", primaryShadow: "rgba(113,114,77,0.2)", secondary: "#f1f2e8", secondaryHover: "#e4e6d4", secondaryText: "#555738", activeBg: "bg-[#f1f2e8]", activeText: "text-[#555738]", softBg: "bg-[#f8f8f1]", hoverBg: "hover:bg-[#f1f2e8]", border: "border-[#dddfc4]", ring: "ring-[#d2d6b5]" },
  weekly: { icon: "週", accentText: "text-[#7a6a57]", primary: "#7a6a57", primaryHover: "#665744", primaryShadow: "rgba(122,106,87,0.18)", secondary: "#f3efe6", secondaryHover: "#e7dece", secondaryText: "#594b3b", activeBg: "bg-[#f3efe6]", activeText: "text-[#594b3b]", softBg: "bg-[#f8f5ee]", hoverBg: "hover:bg-[#f3efe6]", border: "border-[#e2d8c8]", ring: "ring-[#dacdb8]" },
  corpus: { icon: "◇", accentText: "text-[#677260]", primary: "#677260", primaryHover: "#55604f", primaryShadow: "rgba(103,114,96,0.18)", secondary: "#eff2ea", secondaryHover: "#e2e7d9", secondaryText: "#4b5545", activeBg: "bg-[#eff2ea]", activeText: "text-[#4b5545]", softBg: "bg-[#f7f7f1]", hoverBg: "hover:bg-[#eff2ea]", border: "border-[#d9decf]", ring: "ring-[#ced6c3]" },
  tasks: { icon: "◷", accentText: "text-[#6d6979]", primary: "#6d6979", primaryHover: "#5b5768", primaryShadow: "rgba(109,105,121,0.18)", secondary: "#f0eff4", secondaryHover: "#e3e0ea", secondaryText: "#4e4a5c", activeBg: "bg-[#f0eff4]", activeText: "text-[#4e4a5c]", softBg: "bg-[#f7f6f8]", hoverBg: "hover:bg-[#f0eff4]", border: "border-[#dbd8e1]", ring: "ring-[#cfccd8]" },
  settings: { icon: "⚙", accentText: "text-[#6d716b]", primary: "#6d716b", primaryHover: "#5a5f58", primaryShadow: "rgba(109,113,107,0.18)", secondary: "#f0f1ed", secondaryHover: "#e3e5de", secondaryText: "#4d524b", activeBg: "bg-[#f0f1ed]", activeText: "text-[#4d524b]", softBg: "bg-[#f7f7f4]", hoverBg: "hover:bg-[#f0f1ed]", border: "border-[#dadbd3]", ring: "ring-[#d0d2c9]" }
};

function themeKeyForView(view: ViewKey): ModeThemeKey {
  if (view === "quick") return "quick";
  if (view === "translation") return "translation";
  if (view === "monologue") return "monologue";
  if (view === "podcast") return "podcast";
  if (["weekly", "topic", "practice", "daily", "review"].includes(view)) return "weekly";
  if (view === "corpus") return "corpus";
  if (view === "tasks") return "tasks";
  if (view === "settings") return "settings";
  return "dashboard";
}

function themeKeyForHeader(eyebrow: string, title: string): ModeThemeKey {
  const text = `${eyebrow} ${title}`;
  if (/Quick/.test(text)) return "quick";
  if (/Sentence|句子|听力|翻译/.test(text)) return "translation";
  if (/Monologue|独白/.test(text)) return "monologue";
  if (/Podcast|播客/.test(text)) return "podcast";
  if (/Weekly|主题|复盘/.test(text)) return "weekly";
  if (/Corpus|语料/.test(text)) return "corpus";
  if (/Task|解析/.test(text)) return "tasks";
  if (/Setting|设置/.test(text)) return "settings";
  return "dashboard";
}

function defaultButtonIcon(children: React.ReactNode): string {
  if (typeof children !== "string") return "";
  // If the label already carries a manual icon glyph (e.g. "‹ 上一句", "★ 已收藏",
  // "● 开始实时转写"), don't prepend a second auto-icon — that produced the
  // double-icon buttons seen on both mobile and PC.
  if (/[★☆✓✔●■▶▮‹›→←↻×⚙⌃]/.test(children)) return "";
  if (/导入|上传|添加|新建|创建/.test(children)) return "+";
  if (/开始|播放|进入|继续/.test(children)) return "▶";
  if (/停止|中止/.test(children)) return "■";
  if (/保存|完成|掌握|确认|提交/.test(children)) return "✓";
  if (/刷新|重新|恢复|撤销/.test(children)) return "↻";
  if (/管理|设置|编辑|修改/.test(children)) return "⚙";
  if (/删除|清空|关闭|取消/.test(children)) return "×";
  if (/返回|上一/.test(children)) return "←";
  if (/列表|下一|查看|显示|句子列表/.test(children)) return "→";
  if (/收藏/.test(children)) return "★";
  if (/隐藏|收起/.test(children)) return "⌃";
  return "•";
}

function readListeningAutoPlay(): boolean {
  return localStorage.getItem("pjct_listening_auto_play") === "1";
}

function buttonIcon(icon: string, text: string) {
  return (
    <span className="inline-flex items-center gap-1.5">
      <span aria-hidden="true" className="text-base leading-none">{icon}</span>
      <span>{text}</span>
    </span>
  );
}

// Native HTML5 drag-and-drop does not fire on touch devices, so on mobile the
// "把已揭示的词拖进重点词" interaction was dead. This hook implements a
// touch-based drag: once the finger moves past a small threshold it activates a
// drag, tracks whether the finger is over a [data-word-drop-target] element, and
// drops the word on release. Pair it with <MobileWordDragLayer/>, which renders a
// floating drop target while a drag is in progress.
type TouchWordDrag = {
  dragWord: string;
  pos: { x: number; y: number };
  over: boolean;
  start: (word: string, touch: React.Touch) => void;
  move: (touch: React.Touch) => void;
  end: () => void;
};

function useTouchWordDrag(onDrop: (word: string) => void): TouchWordDrag {
  const [dragWord, setDragWord] = useState("");
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const [over, setOver] = useState(false);
  const state = useRef({ word: "", startX: 0, startY: 0, active: false, over: false });

  function start(word: string, touch: React.Touch) {
    state.current = { word, startX: touch.clientX, startY: touch.clientY, active: false, over: false };
  }
  function move(touch: React.Touch) {
    const s = state.current;
    if (!s.word) return;
    if (!s.active) {
      if (Math.hypot(touch.clientX - s.startX, touch.clientY - s.startY) < 8) return;
      s.active = true;
      setDragWord(s.word);
    }
    setPos({ x: touch.clientX, y: touch.clientY });
    const el = document.elementFromPoint(touch.clientX, touch.clientY);
    const o = !!(el && el.closest("[data-word-drop-target]"));
    s.over = o;
    setOver(o);
  }
  function end() {
    const s = state.current;
    if (s.active && s.over && s.word) onDrop(s.word);
    state.current = { word: "", startX: 0, startY: 0, active: false, over: false };
    setDragWord("");
    setOver(false);
  }
  return { dragWord, pos, over, start, move, end };
}

function MobileWordDragLayer({ drag, label = "重点词" }: { drag: TouchWordDrag; label?: string }) {
  if (!drag.dragWord) return null;
  return (
    <div className="md:hidden">
      <div
        className="pointer-events-none fixed z-[70] -translate-x-1/2 -translate-y-[150%] rounded-lg bg-things-700 px-2.5 py-1 text-sm font-semibold text-white shadow-lg"
        style={{ left: drag.pos.x, top: drag.pos.y }}
      >
        {drag.dragWord}
      </div>
      <div
        data-word-drop-target="true"
        className={`fixed inset-x-4 bottom-6 z-[60] rounded-2xl border-2 border-dashed px-4 py-5 text-center text-sm font-semibold shadow-2xl transition ${
          drag.over ? "scale-[1.03] border-red-500 bg-red-100 text-red-700" : "border-red-300 bg-white/95 text-red-600"
        }`}
      >
        {drag.over ? `松手加入${label}` : `拖到这里加入${label}`}
      </div>
    </div>
  );
}

const emptyState: AppState = {
  topic: null,
  sessions: [],
  corpusItems: [],
  finalReview: ""
};

function id(prefix: string): string {
  return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}

function todayIso(): string {
  return new Date().toISOString();
}

function localDateTimeInputValue(date = new Date()): string {
  const offsetMs = date.getTimezoneOffset() * 60000;
  return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}

async function apiGetState(): Promise<AppState> {
  const response = await fetch("/api/state");
  if (!response.ok) throw new Error("Failed to load state");
  return readApiJson(response);
}

async function readApiJson(response: Response): Promise<any> {
  const text = await response.text();
  if (!text.trim()) {
    throw new Error(`接口返回空内容：HTTP ${response.status}`);
  }
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(text.startsWith("<") ? `接口返回了非 JSON 内容：HTTP ${response.status}` : text);
  }
}

async function apiSaveState(state: AppState): Promise<AppState> {
  const response = await fetch("/api/state", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(state)
  });
  if (!response.ok) throw new Error("Failed to save state");
  return readApiJson(response);
}

async function apiGetPreferences(): Promise<AppPreferences> {
  const response = await fetch("/api/preferences");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load preferences");
  return {
    listeningPlaybackRate: listeningRateOptions.includes(Number(data.listeningPlaybackRate)) ? Number(data.listeningPlaybackRate) : 1,
    listeningScoreSettings: normalizeListeningScoreSettings(data.listeningScoreSettings || {}),
    exists: Boolean(data.exists)
  };
}

async function apiSavePreferences(preferences: AppPreferences): Promise<AppPreferences> {
  const response = await fetch("/api/preferences", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(preferences)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to save preferences");
  return {
    listeningPlaybackRate: listeningRateOptions.includes(Number(data.listeningPlaybackRate)) ? Number(data.listeningPlaybackRate) : 1,
    listeningScoreSettings: normalizeListeningScoreSettings(data.listeningScoreSettings || {}),
    exists: Boolean(data.exists)
  };
}

async function apiResetState(): Promise<AppState> {
  const response = await fetch("/api/reset", { method: "POST" });
  if (!response.ok) throw new Error("Failed to reset state");
  return readApiJson(response);
}

interface AuthUser {
  id: string;
  username: string;
  email: string;
}

async function apiGetMe(): Promise<AuthUser | null> {
  try {
    const response = await fetch("/api/auth/me");
    if (!response.ok) return null;
    const data = await response.json();
    return (data && data.user) || null;
  } catch {
    return null;
  }
}

async function apiLogin(username: string, password: string): Promise<AuthUser> {
  const response = await fetch("/api/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password })
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(data.error || "登录失败");
  return data.user;
}

async function apiRegister(username: string, password: string, email = ""): Promise<AuthUser> {
  const response = await fetch("/api/auth/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password, email })
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(data.error || "注册失败");
  return data.user;
}

async function apiLogout(): Promise<void> {
  try {
    await fetch("/api/auth/logout", { method: "POST" });
  } catch {
    /* ignore */
  }
}

interface ProviderSection {
  provider?: string;
  base_url?: string;
  model?: string;
  api_key_set?: boolean;
  api_key_preview?: string;
  [key: string]: unknown;
}
type ProviderSettings = Record<string, ProviderSection>;
interface ProviderBalance {
  status: "available" | "missing_key" | "unsupported";
  provider?: string;
  source_section?: string;
  requested_section?: string;
  is_available?: boolean;
  message?: string;
  balance_infos?: {
    currency: string;
    total_balance: string;
    granted_balance: string;
    topped_up_balance: string;
  }[];
}
interface DotSettings {
  enabled?: boolean;
  interval_minutes?: number;
  device_id?: string;
  title?: string;
  refresh_now?: boolean;
  api_key_set?: boolean;
  api_key_preview?: string;
  last_attempt_at?: string;
  last_sent_at?: string;
  last_error?: string;
  last_sentence?: string;
}

async function apiGetSettings(): Promise<ProviderSettings> {
  const response = await fetch("/api/settings");
  if (!response.ok) throw new Error("读取配置失败");
  const data = await response.json();
  return (data && data.settings) || {};
}

async function apiSaveSettings(settings: Record<string, Record<string, unknown>>): Promise<ProviderSettings> {
  const response = await fetch("/api/settings", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ settings })
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error((data && data.error) || "保存配置失败");
  return (data && data.settings) || {};
}

async function apiGetProviderBalance(section: string): Promise<ProviderBalance> {
  const response = await fetch(`/api/settings/balance?section=${encodeURIComponent(section)}`);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error((data && data.error) || "查询余额失败");
  return data;
}

async function apiGetDotSettings(): Promise<DotSettings> {
  const response = await fetch("/api/settings");
  if (!response.ok) throw new Error("读取 Dot 配置失败");
  const data = await response.json();
  return (data && data.dot) || {};
}

async function apiSaveDotSettings(dot: Record<string, unknown>): Promise<DotSettings> {
  const response = await fetch("/api/settings", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ dot })
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error((data && data.error) || "保存 Dot 配置失败");
  return (data && data.dot) || {};
}

async function apiPushDotNow(): Promise<{ sentence?: string }> {
  const response = await fetch("/api/dot/push-now", { method: "POST" });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error((data && data.error) || "Dot 推送失败");
  return data || {};
}

async function apiOptimizeJapanese(topic: Topic | null, rawTranscript: string, selfCorrection: string): Promise<AIResponse> {
  const response = await fetch("/api/ai/optimize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ topic, rawTranscript, selfCorrection })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "AI optimize failed");
  return {
    improvedVersion: data.improvedVersion || "",
    explanation: data.explanation || "",
    corpusItems: (data.corpusItems || []).map((item: CorpusItem) => ({
      ...item,
      id: id("corpus"),
      masteryStatus: item.masteryStatus || "未练习"
    }))
  };
}

async function apiAssistThinking(topic: Topic | null, question: string, corpusItems: CorpusItem[]): Promise<AssistResponse> {
  const response = await fetch("/api/ai/assist", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ topic, question, corpusItems })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "AI assist failed");
  return {
    suggestions: (data.suggestions || []).map((item: CorpusItem) => ({
      ...item,
      id: item.id || id("assist"),
      masteryStatus: item.masteryStatus || "未练习"
    })),
    answer: data.answer || ""
  };
}

async function apiGetCorpusItems(includeArchived = false): Promise<CorpusItem[]> {
  const response = await fetch(`/api/corpus/items${includeArchived ? "?include_archived=1" : ""}`);
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load corpus items");
  return data.items || [];
}

async function apiAddCorpusItem(item: Partial<CorpusItem>): Promise<CorpusItem> {
  const response = await fetch("/api/corpus/add", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(item)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to add corpus item");
  return data.item;
}

async function apiUpdateCorpusItem(itemId: string, updates: Partial<Pick<CorpusItem, "masteryStatus" | "tags" | "status">>): Promise<CorpusItem> {
  const response = await fetch("/api/corpus/update", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update corpus item");
  return data.item;
}

async function apiDeleteCorpusItem(itemId: string): Promise<void> {
  const response = await fetch("/api/corpus/delete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete corpus item");
}

async function apiGetTranslationItems(includeArchived = false): Promise<TranslationItem[]> {
  const params = new URLSearchParams();
  if (includeArchived) params.set("include_archived", "1");
  params.set("include_deleted", "1");
  const response = await fetch(`/api/translation/items?${params.toString()}`);
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load translation items");
  return (data.items || []).map(normalizeTranslationItem);
}

function normalizeTranslationItem(item: TranslationItem): TranslationItem {
  return {
    ...item,
    keyExpressions: item.keyExpressions || [],
    keyExpressionReadings: item.keyExpressionReadings || {},
    focusExpressions: item.focusExpressions || [],
    tags: item.tags || [],
    listeningTokens: item.listeningTokens || []
  };
}

async function apiImportTranslationSentences(sentences: string[], tags: string[] = []): Promise<TranslationImportResult> {
  const response = await fetch("/api/translation/import", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sentences, tags })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to import sentences");
  return { task: data.task };
}

async function apiGenerateTranslationInspiration(payload: {
  scene: string;
  domain: string;
  terms: string[];
  goal: string;
  count: number;
  level: string;
  notes: string;
}): Promise<TranslationInspirationResult> {
  const response = await fetch("/api/translation/inspire", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to generate inspiration");
  return {
    title: data.title || payload.scene || "灵感句子",
    sentences: Array.isArray(data.sentences) ? data.sentences.filter(Boolean) : [],
    tags: Array.isArray(data.tags) ? data.tags.filter(Boolean) : []
  };
}

async function apiGetParseTasks(): Promise<ParseTask[]> {
  const response = await fetch("/api/parse-tasks");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load parse tasks");
  return data.tasks || [];
}

async function apiCancelParseTask(taskId: string): Promise<ParseTask> {
  const response = await fetch("/api/parse-tasks/cancel", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to cancel parse task");
  return data.task;
}

async function apiResumeParseTask(taskId: string): Promise<ParseTask> {
  const response = await fetch("/api/parse-tasks/resume", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to resume parse task");
  return data.task;
}

async function apiDeleteParseTask(taskId: string): Promise<{ deleted: boolean; taskId: string; removed?: Record<string, number> }> {
  const response = await fetch("/api/parse-tasks/delete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete parse task");
  return data;
}

async function apiEvaluateTranslation(itemId: string, userAnswer: string, note = ""): Promise<{ evaluation: TranslationEvaluation; attempt: TranslationAttempt }> {
  const response = await fetch("/api/translation/evaluate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, userAnswer, note })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to evaluate translation");
  return { evaluation: data.evaluation, attempt: data.attempt };
}

async function apiScheduleTranslation(itemId: string, intervalMinutes: number): Promise<TranslationItem> {
  const response = await fetch("/api/translation/schedule", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, intervalMinutes })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to schedule translation item");
  return data.item;
}

async function apiGetListeningProgress(): Promise<Record<string, ListeningProgress>> {
  const response = await fetch("/api/listening/progress");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load listening progress");
  return data.progress || {};
}

async function apiScheduleListening(
  itemId: string,
  intervalMinutes: number,
  score: number,
  revealedCount: number,
  tokenCount: number
): Promise<ListeningProgress> {
  const response = await fetch("/api/listening/schedule", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, intervalMinutes, score, revealedCount, tokenCount })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to schedule listening item");
  return data.progress;
}

async function apiSetListeningMastered(
  itemId: string,
  mastered: boolean,
  score: number,
  revealedCount: number,
  tokenCount: number
): Promise<TranslationItem> {
  const response = await fetch("/api/listening/mastered", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, mastered, score, revealedCount, tokenCount })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update listening mastered");
  return data.item;
}

async function apiGetDrillSessions(): Promise<DrillSession[]> {
  const response = await fetch("/api/drill-sessions");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load drill sessions");
  return data.sessions || [];
}

async function apiGetMonologueTasks(): Promise<MonologueTask[]> {
  const response = await fetch("/api/monologue/tasks");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load monologue tasks");
  return data.tasks || [];
}

async function apiCreateMonologueTask(payload: { title: string; taskTime: string; tags: string[]; description: string }): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/create", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to create monologue task");
  return data.task;
}

async function apiAddMonologueSentence(taskId: string, blockType: "sentence" | "note" | "heading" = "sentence", content = ""): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/add-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, blockType, content })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to add monologue sentence");
  return data.task;
}

async function apiUpdateMonologueBlock(taskId: string, blockId: string, updates: { content?: string; targetText?: string; collapsed?: boolean }): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/update-block", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, blockId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update monologue block");
  return data.task;
}

async function apiReorderMonologueBlocks(taskId: string, blockIds: string[]): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/reorder", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, blockIds })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to reorder monologue blocks");
  return data.task;
}

async function apiDeleteMonologueBlock(taskId: string, blockId: string): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/delete-block", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, blockId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete monologue block");
  return data.task;
}

async function apiOptimizeMonologueSentence(taskId: string, sentenceId: string, rawTranscript: string): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/optimize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, sentenceId, rawTranscript })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to optimize monologue sentence");
  return data.task;
}

async function apiCompleteMonologueSentence(taskId: string, sentenceId: string, finalText: string): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/complete-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, sentenceId, finalText })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to complete monologue sentence");
  return data.task;
}

async function apiUndoCompleteMonologueSentence(taskId: string, sentenceId: string): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/undo-complete-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, sentenceId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to undo monologue sentence");
  return data.task;
}

async function apiSaveMonologueRecording(taskId: string, sentenceId: string, audioBlob: Blob): Promise<MonologueTask> {
  const extension = audioBlob.type.includes("mp4") ? "mp4" : audioBlob.type.includes("wav") ? "wav" : "webm";
  const formData = new FormData();
  formData.append("taskId", taskId);
  formData.append("sentenceId", sentenceId);
  formData.append("file", audioBlob, `monologue-${sentenceId}.${extension}`);
  const response = await fetch("/api/monologue/save-recording", {
    method: "POST",
    body: formData
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to save monologue recording");
  return data.task;
}

async function apiFinalizeMonologue(taskId: string, finalTranscript: string, hiddenBlockIds: string[] = []): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/finalize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, finalTranscript, hiddenBlockIds })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to finalize monologue");
  return data.task;
}

async function apiUpdateMonologueTaskStatus(taskId: string, status: MonologueTask["status"]): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/update-task-status", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, status })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update monologue task status");
  return data.task;
}

async function apiUpdateMonologueTask(taskId: string, updates: { title?: string; description?: string }): Promise<MonologueTask> {
  const response = await fetch("/api/monologue/update-task", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update monologue task");
  return data.task;
}

async function apiDeleteMonologueTask(taskId: string): Promise<void> {
  const response = await fetch("/api/monologue/delete-task", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ taskId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete monologue task");
}

async function apiGetPodcastEpisodes(): Promise<PodcastEpisode[]> {
  const response = await fetch("/api/podcast/episodes");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load podcast episodes");
  return data.episodes || [];
}

async function apiGetPodcastEpisode(episodeId: string): Promise<PodcastEpisode> {
  const response = await fetch(`/api/podcast/episode?episodeId=${encodeURIComponent(episodeId)}`);
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load podcast episode");
  return data.episode;
}

async function apiGetPodcastMetadata(url: string): Promise<PodcastMetadata> {
  const response = await fetch(`/api/podcast/metadata?url=${encodeURIComponent(url)}`);
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load podcast metadata");
  return data.metadata;
}

async function apiImportPodcastUrl(payload: { url: string; title: string; language: string; tags: string[] }): Promise<ParseTask> {
  const response = await fetch("/api/podcast/import-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to import podcast URL");
  return data.task;
}

async function apiUploadPodcast(file: File, title: string, language: string, tags: string[]): Promise<ParseTask> {
  const formData = new FormData();
  formData.append("title", title);
  formData.append("language", language);
  formData.append("tags", tags.join(","));
  formData.append("file", file, file.name);
  const response = await fetch("/api/podcast/upload", {
    method: "POST",
    body: formData
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to upload podcast");
  return data.task;
}

async function apiUpdatePodcastSentence(sentenceId: string, updates: { mastered?: boolean; favorite?: boolean }): Promise<PodcastSentence> {
  const response = await fetch("/api/podcast/update-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sentenceId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update podcast sentence");
  return data.sentence;
}

async function apiUpdatePodcastEpisode(episodeId: string, updates: { title: string; tags: string[] }): Promise<PodcastEpisode> {
  const response = await fetch("/api/podcast/update-episode", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ episodeId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update podcast episode");
  return data.episode;
}

async function apiDeletePodcastEpisode(episodeId: string): Promise<void> {
  const response = await fetch("/api/podcast/delete-episode", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ episodeId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete podcast episode");
}

async function apiGetPodcastDifficultWords(): Promise<DifficultWordMap> {
  const response = await fetch("/api/podcast/difficult-words");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load podcast difficult words");
  return data.items || {};
}

async function apiUpdatePodcastDifficultWords(sentenceId: string, words: string[]): Promise<string[]> {
  const response = await fetch("/api/podcast/difficult-words", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sentenceId, words })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update podcast difficult words");
  return data.words || [];
}

async function apiCreateDrillSession(payload: {
  mode: "translation" | "listening";
  title: string;
  itemIds: string[];
  filters?: DrillSession["filters"];
  source?: string;
  plannedDueAt?: string;
}): Promise<DrillSession> {
  const response = await fetch("/api/drill-sessions/create", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to create drill session");
  return data.session;
}

async function apiUpdateDrillSession(sessionId: string, updates: Partial<Pick<DrillSession, "currentIndex" | "status" | "itemIds" | "filters">>): Promise<DrillSession> {
  const response = await fetch("/api/drill-sessions/update", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sessionId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update drill session");
  return data.session;
}

async function apiSkipListening(itemId: string): Promise<void> {
  const response = await fetch("/api/listening/skip", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to skip listening item");
}

async function apiTokenizeJapanese(text: string): Promise<string[]> {
  const response = await fetch("/api/listening/tokenize", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to tokenize Japanese");
  return data.tokens || [];
}

async function apiTokenizeTranslationItem(itemId: string): Promise<TranslationItem> {
  const response = await fetch("/api/translation/tokenize-listening", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to tokenize item");
  return data.item;
}

async function apiGetListeningDifficultWords(): Promise<DifficultWordMap> {
  const response = await fetch("/api/listening/difficult-words");
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load difficult words");
  return data.items || {};
}

async function apiUpdateListeningDifficultWords(itemId: string, words: string[]): Promise<string[]> {
  const response = await fetch("/api/listening/difficult-words", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, words })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update difficult words");
  return data.words || [];
}

async function apiAnalyzeListeningSentence(itemId: string): Promise<ListeningSentenceAnalysis> {
  const response = await fetch("/api/listening/analyze-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to analyze listening sentence");
  return data.analysis;
}

async function apiAnalyzePodcastSentence(sentenceId: string): Promise<ListeningSentenceAnalysis> {
  const response = await fetch("/api/podcast/analyze-sentence", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sentenceId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to analyze podcast sentence");
  return data.analysis;
}

async function apiArchiveTranslation(itemId: string): Promise<TranslationItem> {
  const response = await fetch("/api/translation/archive", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to archive translation item");
  return data.item;
}

async function apiUpdateTranslation(itemId: string, updates: { tags?: string[]; focusExpressions?: string[] }): Promise<TranslationItem> {
  const response = await fetch("/api/translation/update", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId, ...updates })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update translation item");
  return normalizeTranslationItem(data.item);
}

async function apiEnsureKeyExpressionReadings(itemId: string): Promise<TranslationItem> {
  const response = await fetch("/api/translation/key-expression-readings", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load key expression readings");
  return normalizeTranslationItem(data.item);
}

async function apiDeleteTranslation(itemId: string): Promise<void> {
  const response = await fetch("/api/translation/delete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemId })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete translation item");
}

async function apiDeleteTranslations(itemIds: string[]): Promise<void> {
  const response = await fetch("/api/translation/delete-bulk", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemIds })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to delete translation items");
}

async function apiRestoreTranslations(itemIds: string[]): Promise<void> {
  const response = await fetch("/api/translation/restore-bulk", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ itemIds })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to restore translation items");
}

async function apiRestoreTranslationState(item: TranslationItem, progress?: ListeningProgress | null): Promise<TranslationItem> {
  const response = await fetch("/api/translation/restore-state", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ item, progress })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to restore translation item state");
  return data.item;
}

async function apiGetTranslationAttempts(itemId: string): Promise<TranslationAttempt[]> {
  const response = await fetch(`/api/translation/attempts?itemId=${encodeURIComponent(itemId)}`);
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to load translation attempts");
  return data.attempts || [];
}

async function apiUpdateTranslationAttemptNote(attemptId: string, note: string): Promise<TranslationAttempt> {
  const response = await fetch("/api/translation/attempt-note", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ attemptId, note })
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "Failed to update attempt note");
  return data.attempt;
}

async function apiTranscribeAudio(audioBlob: Blob, language = "ja"): Promise<string> {
  const extension = audioBlob.type.includes("mp4") ? "mp4" : audioBlob.type.includes("wav") ? "wav" : "webm";
  const formData = new FormData();
  formData.append("file", audioBlob, `recording.${extension}`);
  formData.append("language", language);
  const response = await fetch("/api/audio/transcribe", {
    method: "POST",
    body: formData
  });
  const data = await readApiJson(response);
  if (!response.ok) throw new Error(data.error || "音频转写失败");
  if (data.warning) throw new Error(data.warning);
  return data.text || "";
}

async function apiSpeakText(text: string): Promise<string> {
  const response = await fetch("/api/tts/speak", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text })
  });
  if (!response.ok) {
    const data = await readApiJson(response);
    throw new Error(data.error || "TTS 生成失败");
  }
  const blob = await response.blob();
  return URL.createObjectURL(blob);
}

function createFinalReview(topic: Topic | null, sessions: PracticeSession[], corpus: CorpusItem[], finalText: string): string {
  const first = sessions.find((session) => session.day === 1) || sessions.find((session) => session.day === 0);
  const mastered = corpus.filter((item) => item.masteryStatus === "已掌握");
  const practicing = corpus.filter((item) => item.masteryStatus !== "已掌握");

  return [
    `テーマ「${topic?.title || "今週のテーマ"}」の最終復習です。`,
    `表現自然度提升：初回の「${(first?.rawTranscript || "初回発話").slice(0, 36)}」に比べ、今回の最終復述では「${finalText.slice(0, 36) || "最終発話"}」のように、説明の順番が整理されています。`,
    "逻辑流畅度提升：背景、具体例、振り返りの順で話す意識が出ており、聞き手が追いやすい構成になっています。",
    `已经固化的表达：${mastered.map((item) => item.chunk).join("、") || "まだ明確な固定表現は少ないため、次週も同じ型を短く反復してください。"}`,
    `仍需继续练习的表达：${practicing.slice(0, 4).map((item) => item.chunk).join("、") || "次のテーマでは新しい表現を追加できます。"}`
  ].join("\n\n");
}

function sessionLabel(session: PracticeSession): string {
  return session.day === 0 ? "Quick Loop" : `Day ${session.day}`;
}

// Pick a recording container the current browser actually supports.
// Chrome/Firefox support WebM/Opus; iOS Safari only supports MP4/AAC, so we
// must not hard-code "audio/webm" (that breaks recording on iPhone/iPad).
function pickRecorderMimeType(): string {
  const candidates = [
    "audio/webm;codecs=opus",
    "audio/webm",
    "audio/mp4",
    "audio/aac",
    "audio/mpeg"
  ];
  if (typeof MediaRecorder !== "undefined" && typeof MediaRecorder.isTypeSupported === "function") {
    for (const candidate of candidates) {
      if (MediaRecorder.isTypeSupported(candidate)) return candidate;
    }
  }
  return ""; // let the browser choose its default
}

function useRecorder() {
  const [isRecording, setIsRecording] = useState(false);
  const [audioUrl, setAudioUrl] = useState("");
  const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
  const [error, setError] = useState("");
  const recorderRef = useRef<MediaRecorder | null>(null);
  const chunksRef = useRef<BlobPart[]>([]);

  async function start() {
    setError("");
    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
      setError("当前浏览器不支持录音，请改用手动输入转写。");
      return;
    }
    if (!window.isSecureContext) {
      setError("录音需要 HTTPS（安全连接）。请通过 https 访问，或先手动粘贴转写文本。");
      return;
    }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mimeType = pickRecorderMimeType();
      const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
      setAudioBlob(null);
      setAudioUrl("");
      chunksRef.current = [];
      recorder.ondataavailable = (event) => {
        if (event.data && event.data.size > 0) chunksRef.current.push(event.data);
      };
      recorder.onstop = () => {
        // Use the recorder's actual mime type so the blob is labeled correctly
        // (iOS produces audio/mp4, Chrome audio/webm); the upload helper derives
        // the file extension from blob.type.
        const type = recorder.mimeType || mimeType || "audio/webm";
        const blob = new Blob(chunksRef.current, { type });
        setAudioBlob(blob);
        setAudioUrl(URL.createObjectURL(blob));
        stream.getTracks().forEach((track) => track.stop());
      };
      recorderRef.current = recorder;
      recorder.start();
      setIsRecording(true);
    } catch {
      setError("无法访问麦克风。请检查浏览器权限，或先手动粘贴转写文本。");
    }
  }

  function stop() {
    recorderRef.current?.stop();
    setIsRecording(false);
  }

  return { isRecording, audioUrl, audioBlob, error, start, stop };
}

function useSpeechTranscriber(onFinalText: (text: string) => void) {
  const [isListening, setIsListening] = useState(false);
  const [interimText, setInterimText] = useState("");
  const [error, setError] = useState("");
  const recognitionRef = useRef<any>(null);

  function getSpeechRecognition() {
    return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
  }

  function start() {
    setError("");
    const SpeechRecognition = getSpeechRecognition();
    if (!SpeechRecognition) {
      setError("当前浏览器不支持自动转写。建议使用 Chrome，或后续接入 Whisper / 云端语音转文字。");
      return;
    }
    const recognition = new SpeechRecognition();
    recognition.lang = "ja-JP";
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.onresult = (event: any) => {
      let finalText = "";
      let interim = "";
      for (let index = event.resultIndex; index < event.results.length; index += 1) {
        const transcript = event.results[index][0]?.transcript || "";
        if (event.results[index].isFinal) {
          finalText += transcript;
        } else {
          interim += transcript;
        }
      }
      if (finalText.trim()) onFinalText(finalText.trim());
      setInterimText(interim.trim());
    };
    recognition.onerror = (event: any) => {
      setError(event.error ? `自动转写失败：${event.error}` : "自动转写失败，请改用手动输入。");
      setIsListening(false);
    };
    recognition.onend = () => {
      setIsListening(false);
      setInterimText("");
    };
    recognitionRef.current = recognition;
    recognition.start();
    setIsListening(true);
  }

  function stop() {
    recognitionRef.current?.stop();
    setIsListening(false);
  }

  useEffect(() => {
    return () => recognitionRef.current?.stop();
  }, []);

  return { isListening, interimText, error, start, stop, isSupported: Boolean(getSpeechRecognition()) };
}

function readListeningPlaybackRate(): number {
  const stored = Number(localStorage.getItem("pjct_listening_playback_rate"));
  return listeningRateOptions.includes(stored) ? stored : 1;
}

function readListeningScoreSettings(): ListeningScoreSettings {
  try {
    const parsed = JSON.parse(localStorage.getItem("pjct_listening_score_settings") || "{}");
    return normalizeListeningScoreSettings(parsed);
  } catch {
    return defaultListeningScoreSettings;
  }
}

function normalizeListeningScoreSettings(value: Partial<ListeningScoreSettings>): ListeningScoreSettings {
  return {
    revealPenaltyMax: clampPenalty(value.revealPenaltyMax, defaultListeningScoreSettings.revealPenaltyMax),
    replayPenaltyPerExtraPlay: clampPenalty(value.replayPenaltyPerExtraPlay, defaultListeningScoreSettings.replayPenaltyPerExtraPlay),
    replayPenaltyMax: clampPenalty(value.replayPenaltyMax, defaultListeningScoreSettings.replayPenaltyMax),
    promptHintPenalty: clampPenalty(value.promptHintPenalty, defaultListeningScoreSettings.promptHintPenalty),
    systemPlayPenalty: clampPenalty(value.systemPlayPenalty, defaultListeningScoreSettings.systemPlayPenalty),
    systemPlayPenaltyMax: clampPenalty(value.systemPlayPenaltyMax, defaultListeningScoreSettings.systemPlayPenaltyMax),
    analysisPenalty: clampPenalty(value.analysisPenalty, defaultListeningScoreSettings.analysisPenalty)
  };
}

function clampPenalty(value: unknown, fallback: number): number {
  const numberValue = Number(value);
  if (!Number.isFinite(numberValue)) return fallback;
  return Math.max(0, Math.min(100, Math.round(numberValue)));
}

function App() {
  const [state, setState] = useState<AppState>(emptyState);
  const [view, setView] = useState<ViewKey>("dashboard");
  const [notice, setNotice] = useState("");
  const [isBooting, setIsBooting] = useState(true);
  const [isSaving, setIsSaving] = useState(false);
  const [parseTasks, setParseTasks] = useState<ParseTask[]>([]);
  const [taskError, setTaskError] = useState("");
  const refreshedTerminalParseTasks = useRef<Set<string>>(new Set());
  const [listeningPlaybackRate, setListeningPlaybackRate] = useState(() => readListeningPlaybackRate());
  const [listeningScoreSettings, setListeningScoreSettings] = useState(() => readListeningScoreSettings());
  const [authUser, setAuthUser] = useState<AuthUser | null | undefined>(undefined);
  const [authChecked, setAuthChecked] = useState(false);
  const [showMobileMenu, setShowMobileMenu] = useState(false);

  useEffect(() => {
    let active = true;
    apiGetMe()
      .then((user) => {
        if (active) setAuthUser(user);
      })
      .finally(() => {
        if (active) setAuthChecked(true);
      });
    return () => {
      active = false;
    };
  }, []);

  async function handleLogout() {
    await apiLogout();
    setAuthUser(null);
    setState(emptyState);
    setView("dashboard");
    setShowMobileMenu(false);
  }

  useEffect(() => {
    if (!authUser) return;
    let active = true;
    setIsBooting(true);
    Promise.all([apiGetState(), apiGetPreferences()])
      .then(([serverState, preferences]) => {
        if (!active) return;
        setState({ ...emptyState, ...serverState });
        if (preferences.exists) {
          setListeningPlaybackRate(preferences.listeningPlaybackRate);
          setListeningScoreSettings(preferences.listeningScoreSettings);
        } else {
          apiSavePreferences({
            listeningPlaybackRate,
            listeningScoreSettings
          }).catch(() => undefined);
        }
      })
      .catch(() => {
        if (active) setNotice("数据库读取失败，请确认 Python 服务正在运行。");
      })
      .finally(() => {
        if (active) setIsBooting(false);
      });
    return () => {
      active = false;
    };
  }, [authUser]);

  useEffect(() => {
    if (!notice) return;
    const timer = window.setTimeout(() => setNotice(""), 2600);
    return () => window.clearTimeout(timer);
  }, [notice]);

  useEffect(() => {
    localStorage.setItem("pjct_listening_playback_rate", String(listeningPlaybackRate));
  }, [listeningPlaybackRate]);

  useEffect(() => {
    localStorage.setItem("pjct_listening_score_settings", JSON.stringify(listeningScoreSettings));
  }, [listeningScoreSettings]);

  async function savePreferences(preferences: AppPreferences) {
    const saved = await apiSavePreferences(preferences);
    setListeningPlaybackRate(saved.listeningPlaybackRate);
    setListeningScoreSettings(saved.listeningScoreSettings);
  }

  function updateListeningPlaybackRate(rate: number) {
    setListeningPlaybackRate(rate);
    apiSavePreferences({
      listeningPlaybackRate: rate,
      listeningScoreSettings
    })
      .then((saved) => {
        setListeningPlaybackRate(saved.listeningPlaybackRate);
        setListeningScoreSettings(saved.listeningScoreSettings);
      })
      .catch(() => undefined);
  }

  const activeParseTaskCount = parseTasks.filter((task) => ["queued", "running", "canceling"].includes(task.status)).length;

  async function refreshParseTasks(options: { refreshTranslationItems?: boolean } = {}) {
    try {
      const tasks = await apiGetParseTasks();
      setParseTasks(tasks);
      setTaskError("");
      if (options.refreshTranslationItems) {
        const terminalImportTasks = tasks.filter((task) => (
          task.type === "translation_import" &&
          ["completed", "failed", "canceled"].includes(task.status) &&
          !refreshedTerminalParseTasks.current.has(task.id)
        ));
        terminalImportTasks.forEach((task) => refreshedTerminalParseTasks.current.add(task.id));
      }
      return tasks;
    } catch (err) {
      setTaskError(err instanceof Error ? err.message : "读取解析任务失败");
      return parseTasks;
    }
  }

  async function cancelParseTask(task: ParseTask) {
    setTaskError("");
    try {
      const updated = await apiCancelParseTask(task.id);
      setParseTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
      setNotice("已请求中止解析任务，当前项目处理完后会停止。");
    } catch (err) {
      setTaskError(err instanceof Error ? err.message : "中止解析任务失败");
    }
  }

  async function resumeParseTask(task: ParseTask) {
    setTaskError("");
    try {
      const updated = await apiResumeParseTask(task.id);
      setParseTasks((current) => [updated, ...current.filter((item) => item.id !== updated.id)]);
      setNotice(task.status === "failed" ? "解析任务已重新开始。" : "解析任务已继续。");
    } catch (err) {
      setTaskError(err instanceof Error ? err.message : "继续解析任务失败");
    }
  }

  async function deleteParseTask(task: ParseTask) {
    if (!window.confirm(`删除解析任务「${task.title}」？失败/中止的播客导入会同时清理已下载的音频和句子。`)) return;
    setTaskError("");
    try {
      await apiDeleteParseTask(task.id);
      setParseTasks((current) => current.filter((item) => item.id !== task.id));
      setNotice("解析任务已删除，关联的临时播客数据已清理。");
    } catch (err) {
      setTaskError(err instanceof Error ? err.message : "删除解析任务失败");
    }
  }

  useEffect(() => {
    refreshParseTasks();
  }, []);

  useEffect(() => {
    if (!activeParseTaskCount) return;
    const timer = window.setInterval(() => {
      refreshParseTasks({ refreshTranslationItems: true });
    }, 1500);
    return () => window.clearInterval(timer);
  }, [activeParseTaskCount]);

  async function commitState(nextState: AppState, successMessage: string, nextView?: ViewKey) {
    setState(nextState);
    setIsSaving(true);
    try {
      const savedState = await apiSaveState(nextState);
      setState({ ...emptyState, ...savedState });
      setNotice(successMessage);
      if (nextView) setView(nextView);
      return savedState;
    } catch {
      setNotice("保存到 SQLite 失败，请检查 Python 服务。");
      throw new Error("保存到 SQLite 失败，请检查 Python 服务。");
    } finally {
      setIsSaving(false);
    }
  }

  const canEnterDaily = Boolean(state.sessions.find((session) => session.day === 1) || state.corpusItems.length > 0);
  const currentDay = state.topic?.currentDay || 1;
  const currentTheme = modeThemes[themeKeyForView(view)];

  function navigate(viewKey: ViewKey) {
    setView(viewKey);
    setShowMobileMenu(false);
    window.scrollTo({ top: 0, behavior: "smooth" });
  }

  async function resetState() {
    setIsSaving(true);
    try {
      const resetState = await apiResetState();
      setState({ ...emptyState, ...resetState });
      setView("dashboard");
      setShowMobileMenu(false);
      setNotice("SQLite 训练数据已重置。");
    } catch {
      setNotice("重置 SQLite 失败，请检查 Python 服务。");
    } finally {
      setIsSaving(false);
    }
  }

  if (!authChecked || authUser === undefined) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-[#f4f4f6] to-[#e9e9ed] text-slate-500">
        <div className="text-sm font-semibold">正在加载…</div>
      </div>
    );
  }

  if (!authUser) {
    return <LoginScreen onSuccess={(user) => setAuthUser(user)} />;
  }

  return (
    <div className="min-h-screen bg-gradient-to-b from-[#f4f4f6] to-[#e9e9ed]">
      <MobileTopBar view={view} activeTaskCount={activeParseTaskCount} onOpenMenu={() => setShowMobileMenu(true)} />
      <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-3 pb-24 pt-3 md:flex-row md:p-6">
        <Sidebar
          view={view}
          setView={navigate}
          authUser={authUser}
          onLogout={handleLogout}
          activeTaskCount={activeParseTaskCount}
          onReset={resetState}
        />
        <main
          className="mode-scope min-h-[calc(100vh-8rem)] flex-1 rounded-[22px] border border-white/80 bg-[#f8f8fa]/90 p-3 shadow-soft backdrop-blur md:min-h-[calc(100vh-2rem)] md:p-6"
          style={{
            "--mode-primary": currentTheme.primary,
            "--mode-primary-hover": currentTheme.primaryHover,
            "--mode-primary-shadow": currentTheme.primaryShadow,
            "--mode-secondary": currentTheme.secondary,
            "--mode-secondary-hover": currentTheme.secondaryHover,
            "--mode-secondary-text": currentTheme.secondaryText
          } as React.CSSProperties}
        >
          <style>{`
            .mode-scope .text-things-600,
            .mode-scope .text-things-700,
            .mode-scope .text-things-800,
            .mode-scope .text-things-900 { color: var(--mode-secondary-text) !important; }
            .mode-scope .bg-things-50,
            .mode-scope .bg-things-100 { background-color: var(--mode-secondary) !important; }
            .mode-scope .bg-things-500,
            .mode-scope .bg-things-600 { background-color: var(--mode-primary) !important; }
            .mode-scope .hover\\:bg-things-50:hover,
            .mode-scope .hover\\:bg-things-100:hover { background-color: var(--mode-secondary-hover) !important; }
            .mode-scope .hover\\:text-things-700:hover,
            .mode-scope .hover\\:text-things-800:hover { color: var(--mode-secondary-text) !important; }
            .mode-scope .border-things-100,
            .mode-scope .border-things-200 { border-color: var(--mode-secondary-hover) !important; }
            .mode-scope .accent-things-600 { accent-color: var(--mode-primary) !important; }
            .mobile-action-grid {
              display: grid;
              grid-template-columns: repeat(2, minmax(0, 1fr));
              gap: 0.5rem;
            }
            .mobile-action-grid > button {
              width: 100%;
              justify-content: center;
            }
            @media (min-width: 768px) {
              .mobile-action-grid {
                display: flex;
                flex-wrap: wrap;
              }
              .mobile-action-grid > button {
                width: auto;
              }
            }
          `}</style>
          {notice && (
            <div className="mb-4 rounded-2xl border border-things-200 bg-things-50 px-4 py-3 text-sm font-semibold text-things-800">
              {notice}
            </div>
          )}
          {isSaving && (
            <div className="mb-4 rounded-2xl border border-line bg-white px-4 py-3 text-sm font-semibold text-slate-600">
              正在写入 SQLite...
            </div>
          )}
          {isBooting && <Locked title="正在读取数据库" text="正在从 SQLite 加载本周训练数据。" />}
          {!isBooting && (
            <>
          {view === "dashboard" && <Dashboard state={state} setView={setView} />}
          {view === "quick" && (
            <QuickLoop
              state={state}
              onSave={(topic, session, corpusItems) => {
                commitState({
                  ...state,
                  topic,
                  sessions: upsertSession(state.sessions, session),
                  corpusItems
                }, "本轮快速练习已保存到 SQLite。");
              }}
            />
          )}
          {view === "translation" && (
            <TranslationDrill
              appState={state}
              onCorpusChange={(corpusItems) => commitState({ ...state, corpusItems }, "已添加corpus。")}
              listeningPlaybackRate={listeningPlaybackRate}
              onListeningPlaybackRateChange={updateListeningPlaybackRate}
              listeningScoreSettings={listeningScoreSettings}
              onParseTaskCreated={(task) => {
                setParseTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
                setView("tasks");
              }}
              onOpenParseTasks={() => setView("tasks")}
            />
          )}
          {view === "monologue" && <MonologuePractice />}
          {view === "podcast" && (
            <PodcastLearning
              playbackRate={listeningPlaybackRate}
              onPlaybackRateChange={updateListeningPlaybackRate}
              onParseTaskCreated={(task) => {
                setParseTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
                setView("tasks");
              }}
            />
          )}
          {view === "tasks" && (
            <ParseTaskCenter
              tasks={parseTasks}
              error={taskError}
              onRefresh={() => refreshParseTasks()}
              onCancel={cancelParseTask}
              onResume={resumeParseTask}
              onDelete={deleteParseTask}
            />
          )}
          {view === "settings" && (
            <SettingsPage
              listeningPlaybackRate={listeningPlaybackRate}
              listeningScoreSettings={listeningScoreSettings}
              onSavePreferences={savePreferences}
            />
          )}
          {view === "weekly" && <WeeklyMode state={state} setView={setView} canEnterDaily={canEnterDaily} />}
          {view === "topic" && (
            <TopicSetup
              topic={state.topic}
              onSave={(topic) => {
                commitState({ ...state, topic }, "主题已保存到 SQLite。", "weekly");
              }}
            />
          )}
          {view === "practice" && (
            <RecordingPractice
              topic={state.topic}
              day={currentDay}
              corpusItems={state.corpusItems}
              onSave={(session, aiItems) => {
                const mergedItems = mergeCorpus(state.corpusItems, withCorpusSource(aiItems, "weekly"));
                commitState({
                  ...state,
                  sessions: upsertSession(state.sessions, session),
                  corpusItems: mergedItems,
                  topic: state.topic ? { ...state.topic, currentDay: Math.max(state.topic.currentDay, 2) } : state.topic
                }, "Day 1 已保存到 SQLite，语料池已生成。", "corpus");
              }}
            />
          )}
          {view === "corpus" && (
            <CorpusPool
              items={state.corpusItems}
              onChange={(items) => commitState({ ...state, corpusItems: items }, "语料池熟练度已保存到 SQLite。")}
            />
          )}
          {view === "daily" && (
            <DailyTraining
              topic={state.topic}
              corpusItems={state.corpusItems}
              sessions={state.sessions}
              onSave={(session, updatedCorpus, nextDay) => {
                commitState({
                  ...state,
                  sessions: upsertSession(state.sessions, session),
                  corpusItems: updatedCorpus,
                  topic: state.topic ? { ...state.topic, currentDay: nextDay } : state.topic
                }, `Day ${session.day} 已保存到 SQLite。`, "dashboard");
              }}
            />
          )}
            </>
          )}
        </main>
      </div>
      <MobileBottomNav view={view} onNavigate={navigate} onOpenMenu={() => setShowMobileMenu(true)} />
      {showMobileMenu && (
        <MobileMoreMenu
          view={view}
          activeTaskCount={activeParseTaskCount}
          authUser={authUser}
          onNavigate={navigate}
          onClose={() => setShowMobileMenu(false)}
          onLogout={handleLogout}
          onReset={resetState}
        />
      )}
    </div>
  );
}

function mobileViewTitle(view: ViewKey): string {
  if (view === "dashboard") return "首页";
  if (view === "translation") return "句子训练";
  if (view === "podcast") return "播客学习";
  if (view === "quick") return "Quick Loop";
  if (view === "monologue") return "独白打磨";
  if (["weekly", "topic", "practice", "daily", "review"].includes(view)) return "Weekly Mode";
  if (view === "corpus") return "Corpus Pool";
  if (view === "tasks") return "解析任务";
  return "设置";
}

function mobileViewIcon(view: ViewKey): string {
  return modeThemes[themeKeyForView(view)].icon;
}

function MobileTopBar({ view, activeTaskCount, onOpenMenu }: { view: ViewKey; activeTaskCount: number; onOpenMenu: () => void }) {
  return (
    <header className="sticky top-0 z-40 flex items-center justify-between border-b border-white/80 bg-[#f8f8fa]/90 px-4 py-3 shadow-hairline backdrop-blur md:hidden">
      <div className="flex min-w-0 items-center gap-3">
        <span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-white text-sm font-semibold text-slate-600 shadow-hairline">{mobileViewIcon(view)}</span>
        <div className="min-w-0">
          <div className="truncate text-base font-semibold text-ink">{mobileViewTitle(view)}</div>
          <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">LinkNihon</div>
        </div>
      </div>
      <button type="button" onClick={onOpenMenu} className="relative grid h-10 w-10 place-items-center rounded-xl bg-white text-lg font-semibold text-slate-600 shadow-hairline" aria-label="打开更多功能">
        ⋯
        {activeTaskCount > 0 && <span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500" />}
      </button>
    </header>
  );
}

function MobileBottomNav({ view, onNavigate, onOpenMenu }: { view: ViewKey; onNavigate: (view: ViewKey) => void; onOpenMenu: () => void }) {
  const items: { key: ViewKey | "more"; label: string; icon: string; active: boolean }[] = [
    { key: "dashboard", label: "首页", icon: "⌂", active: view === "dashboard" },
    { key: "translation", label: "句子", icon: "文", active: view === "translation" },
    { key: "podcast", label: "播客", icon: "♫", active: view === "podcast" },
    { key: "more", label: "更多", icon: "•••", active: !["dashboard", "translation", "podcast"].includes(view) }
  ];
  return (
    <nav className="fixed inset-x-0 bottom-0 z-40 border-t border-white/80 bg-[#f8f8fa]/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-10px_30px_rgba(52,59,93,0.08)] backdrop-blur md:hidden" aria-label="移动端主导航">
      <div className="mx-auto grid max-w-md grid-cols-4 gap-1">
        {items.map((item) => (
          <button
            key={item.key}
            type="button"
            onClick={() => item.key === "more" ? onOpenMenu() : onNavigate(item.key)}
            className={`grid min-h-14 place-items-center rounded-xl px-1 py-1 text-xs font-semibold transition ${item.active ? "bg-slate-100 text-ink" : "text-slate-400"}`}
          >
            <span className="block text-base leading-5">{item.icon}</span>
            <span>{item.label}</span>
          </button>
        ))}
      </div>
    </nav>
  );
}

function MobileMoreMenu({
  view,
  activeTaskCount,
  authUser,
  onNavigate,
  onClose,
  onLogout,
  onReset
}: {
  view: ViewKey;
  activeTaskCount: number;
  authUser: AuthUser;
  onNavigate: (view: ViewKey) => void;
  onClose: () => void;
  onLogout: () => void;
  onReset: () => void;
}) {
  const items: { key: ViewKey; label: string; detail: string }[] = [
    { key: "quick", label: "Quick Loop", detail: "快速输出与修正" },
    { key: "monologue", label: "独白打磨", detail: "逐句打磨完整表达" },
    { key: "weekly", label: "Weekly Mode", detail: "按周主题持续练习" },
    { key: "corpus", label: "Corpus Pool", detail: "回顾已标记表达" },
    { key: "tasks", label: `解析任务${activeTaskCount ? ` · ${activeTaskCount}` : ""}`, detail: "查看导入与后台处理" },
    { key: "settings", label: "设置", detail: "账户配置与练习偏好" }
  ];
  return (
    <div className="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true" aria-label="更多功能">
      <button type="button" onClick={onClose} className="absolute inset-0 bg-ink/25 backdrop-blur-[2px]" aria-label="关闭更多功能" />
      <section className="absolute inset-x-0 bottom-0 max-h-[82vh] overflow-auto rounded-t-[24px] border-t border-white/80 bg-[#f8f8fa] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3 shadow-soft">
        <div className="mx-auto mb-3 h-1.5 w-12 rounded-full bg-slate-300" />
        <div className="mb-3 flex items-center justify-between">
          <div>
            <div className="text-lg font-semibold text-ink">更多功能</div>
            <div className="mt-0.5 text-xs text-slate-400">{authUser.username} · 已登录</div>
          </div>
          <button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center rounded-full bg-white text-lg text-slate-500 shadow-hairline" aria-label="关闭">×</button>
        </div>
        <div className="grid gap-2">
          {items.map((item) => (
            <button key={item.key} type="button" onClick={() => onNavigate(item.key)} className={`flex items-center gap-3 rounded-2xl px-3 py-3 text-left shadow-hairline ${view === item.key ? "bg-things-50" : "bg-white"}`}>
              <span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-slate-50 text-sm font-semibold text-slate-600">{mobileViewIcon(item.key)}</span>
              <span className="min-w-0">
                <span className="block truncate text-sm font-semibold text-ink">{item.label}</span>
                <span className="mt-0.5 block truncate text-xs text-slate-400">{item.detail}</span>
              </span>
              <span className="ml-auto text-slate-300">›</span>
            </button>
          ))}
        </div>
        <div className="mt-4 grid grid-cols-2 gap-2 border-t border-line pt-4">
          <button type="button" onClick={onLogout} className="rounded-xl bg-white px-3 py-3 text-sm font-semibold text-slate-600 shadow-hairline">退出登录</button>
          <button type="button" onClick={onReset} className="rounded-xl bg-red-50 px-3 py-3 text-sm font-semibold text-red-600">重置数据</button>
        </div>
      </section>
    </div>
  );
}

function Sidebar({
  view,
  setView,
  activeTaskCount,
  onReset,
  authUser,
  onLogout
}: {
  view: ViewKey;
  setView: (view: ViewKey) => void;
  activeTaskCount: number;
  onReset: () => void;
  authUser?: AuthUser | null;
  onLogout?: () => void;
}) {
  const isWeeklyView = ["weekly", "topic", "practice", "daily"].includes(view);

  return (
    <aside className="hidden rounded-[22px] border border-white/80 bg-[#f8f8fa]/95 p-4 shadow-hairline md:sticky md:top-6 md:block md:max-h-[calc(100vh-3rem)] md:w-72 md:overflow-auto">
      <div className="mb-5">
        <div className="text-xs font-semibold uppercase tracking-[0.16em] text-things-600">PJCT</div>
        <h1 className="mt-2 text-2xl font-semibold leading-tight text-ink">Personal Japanese Corpus Trainer</h1>
      </div>
      <nav className="grid gap-4">
        <div>
          <div className="mb-2 px-1 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">Mode</div>
          <div className="grid grid-cols-2 gap-2 md:grid-cols-1">
            <NavButton active={view === "quick"} themeKey="quick" onClick={() => setView("quick")}>Quick Loop</NavButton>
            <NavButton active={view === "translation"} themeKey="translation" onClick={() => setView("translation")}>句子训练</NavButton>
            <NavButton active={view === "monologue"} themeKey="monologue" onClick={() => setView("monologue")}>独白打磨</NavButton>
            <NavButton active={view === "podcast"} themeKey="podcast" onClick={() => setView("podcast")}>播客学习</NavButton>
            <NavButton active={isWeeklyView} themeKey="weekly" onClick={() => setView("weekly")}>Weekly Mode</NavButton>
          </div>
        </div>
        <div>
          <div className="mb-2 px-1 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">Library</div>
          <div className="grid grid-cols-2 gap-2 md:grid-cols-1">
            <NavButton active={view === "dashboard"} themeKey="dashboard" onClick={() => setView("dashboard")}>Overview</NavButton>
            <NavButton active={view === "corpus"} themeKey="corpus" onClick={() => setView("corpus")}>Corpus Pool</NavButton>
            <NavButton active={view === "tasks"} themeKey="tasks" onClick={() => setView("tasks")}>
              解析任务{activeTaskCount ? ` · ${activeTaskCount}` : ""}
            </NavButton>
          </div>
        </div>
        <div>
          <div className="mb-2 px-1 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">System</div>
          <div className="grid grid-cols-2 gap-2 md:grid-cols-1">
            <NavButton active={view === "settings"} themeKey="settings" onClick={() => setView("settings")}>Settings</NavButton>
          </div>
          <button
            onClick={onReset}
            className="mt-2 w-full rounded-xl bg-white px-3 py-2.5 text-left text-sm font-semibold text-slate-500 shadow-hairline transition hover:bg-red-50 hover:text-red-600"
          >
            重置本地数据
          </button>
        </div>
        {authUser && (
          <div className="mt-1 border-t border-line pt-3">
            <div className="px-1 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">账户</div>
            <div className="mt-2 flex items-center justify-between gap-2 rounded-xl bg-white px-3 py-2.5 shadow-hairline">
              <div className="min-w-0">
                <div className="truncate text-sm font-semibold text-ink">{authUser.username}</div>
                <div className="text-xs text-slate-400">已登录</div>
              </div>
              <button
                onClick={onLogout}
                className="shrink-0 rounded-lg bg-slate-50 px-2.5 py-1.5 text-xs font-semibold text-slate-500 transition hover:bg-red-50 hover:text-red-600"
              >
                退出
              </button>
            </div>
          </div>
        )}
      </nav>
    </aside>
  );
}

function NavButton({ children, active, disabled, themeKey, onClick }: { children: React.ReactNode; active: boolean; disabled?: boolean; themeKey: ModeThemeKey; onClick: () => void }) {
  const theme = modeThemes[themeKey];
  return (
    <button
      disabled={disabled}
      onClick={onClick}
      className={`flex items-center gap-2 rounded-xl border px-3 py-3 text-left text-sm font-medium transition ${
        active
          ? `${theme.activeBg} ${theme.activeText} ${theme.border} shadow-hairline ring-1 ${theme.ring}`
          : `${theme.softBg} ${theme.accentText} border-transparent ${theme.hoverBg}`
      } disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400`}
    >
      <span className={`grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-white/70 text-sm font-semibold ${theme.accentText}`}>{theme.icon}</span>
      <span>{children}</span>
    </button>
  );
}

function Dashboard({ state, setView }: { state: AppState; setView: (view: ViewKey) => void }) {
  const [translationItems, setTranslationItems] = useState<TranslationItem[]>([]);
  const [corpusItems, setCorpusItems] = useState<CorpusItem[]>(state.corpusItems.map(normalizeCorpusItem));
  const [drillSessions, setDrillSessions] = useState<DrillSession[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    let active = true;
    setLoading(true);
    setError("");
    Promise.all([
      apiGetTranslationItems(true),
      apiGetCorpusItems(true),
      apiGetDrillSessions()
    ])
      .then(([nextTranslations, nextCorpus, nextSessions]) => {
        if (!active) return;
        setTranslationItems(nextTranslations);
        setCorpusItems(nextCorpus.map(normalizeCorpusItem));
        setDrillSessions(nextSessions);
      })
      .catch((err) => {
        if (active) setError(err instanceof Error ? err.message : "Overview 数据读取失败");
      })
      .finally(() => {
        if (active) setLoading(false);
      });
    return () => {
      active = false;
    };
  }, [state.sessions.length, state.corpusItems.length]);

  const quickCount = state.sessions.filter((session) => session.day === 0).length;
  const weeklyCount = state.sessions.filter((session) => session.day > 0).length;
  const activeTranslations = translationItems.filter((item) => item.status !== "archived");
  const archivedTranslations = translationItems.filter((item) => item.status === "archived");
  const listeningMastered = translationItems.filter((item) => item.listeningMastered).length;
  const activeCorpus = corpusItems.filter((item) => item.status !== "archived");
  const archivedCorpus = corpusItems.filter((item) => item.status === "archived");
  const readyCorpus = corpusItems.filter((item) => item.detailStatus === "ready").length;
  const activeDrills = drillSessions.filter((session) => session.status === "active");
  const completedDrills = drillSessions.filter((session) => session.status === "completed");
  const recentPractice = state.sessions.slice(0, 4);
  const recentDrills = drillSessions.slice(0, 5);
  const recentCorpus = [...corpusItems]
    .sort((a, b) => new Date(b.updatedAt || b.createdAt || "").getTime() - new Date(a.updatedAt || a.createdAt || "").getTime())
    .slice(0, 6);

  return (
    <section>
      <Header eyebrow="Overview" title="学习仪表盘" description="查看各模块使用情况、最近活动、句库和 corpus 的总体状态。" />
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      {loading && <p className="mb-4 rounded-2xl bg-slate-50 px-4 py-3 text-sm font-semibold text-slate-500">正在刷新 Overview...</p>}

      <div className="grid gap-3 md:grid-cols-4">
        <Metric label="句库总数" value={`${translationItems.length}`} />
        <Metric label="活跃句子" value={`${activeTranslations.length}`} />
        <Metric label="Corpus" value={`${activeCorpus.length}`} />
        <Metric label="进行中练习" value={`${activeDrills.length}`} />
      </div>

      <div className="mt-4 grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
        <div className="rounded-2xl border border-line bg-white p-5">
          <div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
            <div>
              <div className="text-sm font-semibold text-things-700">模块使用情况</div>
              <p className="mt-1 text-sm text-slate-500">按最近保存和练习履历汇总。</p>
            </div>
            <div className="mobile-action-grid">
              <SecondaryButton onClick={() => setView("quick")}>Quick Loop</SecondaryButton>
              <SecondaryButton onClick={() => setView("translation")}>句子训练</SecondaryButton>
              <SecondaryButton onClick={() => setView("monologue")}>独白打磨</SecondaryButton>
              <SecondaryButton onClick={() => setView("podcast")}>播客学习</SecondaryButton>
            </div>
          </div>
          <div className="grid gap-3 md:grid-cols-2">
            <DashboardModuleCard themeKey="quick" title="Quick Loop" value={`${quickCount} 轮`} detail={recentPractice[0] ? formatDateTime(recentPractice[0].createdAt) : "暂无记录"} onClick={() => setView("quick")} />
            <DashboardModuleCard themeKey="translation" title="句子训练" value={`${activeTranslations.length} 活跃`} detail={`${archivedTranslations.length} 已归档 · ${listeningMastered} 听力掌握`} onClick={() => setView("translation")} />
            <DashboardModuleCard themeKey="monologue" title="独白打磨" value="任务工作台" detail="查看逐句打磨和完整独白任务" onClick={() => setView("monologue")} />
            <DashboardModuleCard themeKey="podcast" title="播客学习" value="音频精听" detail="URL / 上传音频后逐句训练" onClick={() => setView("podcast")} />
            <DashboardModuleCard themeKey="weekly" title="Weekly Mode" value={`${weeklyCount} 条记录`} detail={state.topic?.title || "未设置周主题"} onClick={() => setView("weekly")} />
          </div>
        </div>

        <div className="rounded-2xl border border-line bg-white p-5">
          <div className="text-sm font-semibold text-things-700">统计</div>
          <div className="mt-4 grid gap-3">
            <DashboardStatLine label="Corpus 解释已生成" value={`${readyCorpus}/${corpusItems.length}`} />
            <DashboardStatLine label="Corpus 已归档" value={`${archivedCorpus.length}`} />
            <DashboardStatLine label="练习履历" value={`${drillSessions.length}`} />
            <DashboardStatLine label="已完成练习" value={`${completedDrills.length}`} />
          </div>
        </div>
      </div>

      <div className="mt-4 grid gap-4 xl:grid-cols-2">
        <div className="rounded-2xl border border-line bg-white p-5">
          <div className="mb-3 text-sm font-semibold text-things-700">最近使用</div>
          <div className="grid gap-2">
            {recentDrills.map((session) => (
              <button key={session.id} onClick={() => setView("translation")} className="rounded-xl bg-slate-50 px-4 py-3 text-left hover:bg-things-50">
                <div className="text-sm font-semibold text-ink">{session.title}</div>
                <div className="mt-1 text-xs text-slate-500">{session.status === "active" ? "进行中" : session.status === "completed" ? "已完成" : "已中止"} · {formatDateTime(session.updatedAt)}</div>
              </button>
            ))}
            {recentPractice.map((session) => (
              <button key={session.id} onClick={() => setView(session.day === 0 ? "quick" : "weekly")} className="rounded-xl bg-slate-50 px-4 py-3 text-left hover:bg-things-50">
                <div className="text-sm font-semibold text-ink">{sessionLabel(session)}</div>
                <div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{session.aiCorrection || session.selfCorrection || session.rawTranscript}</div>
              </button>
            ))}
            {!recentDrills.length && !recentPractice.length && <p className="text-sm text-slate-500">还没有练习记录。</p>}
          </div>
        </div>

        <div className="rounded-2xl border border-line bg-white p-5">
          <div className="mb-3 flex items-center justify-between gap-3">
            <div className="text-sm font-semibold text-things-700">最近 Corpus</div>
            <SecondaryButton onClick={() => setView("corpus")}>查看全部</SecondaryButton>
          </div>
          <div className="grid gap-2">
            {recentCorpus.map((item) => (
              <button key={item.id} onClick={() => setView("corpus")} className="rounded-xl bg-slate-50 px-4 py-3 text-left hover:bg-things-50">
                <div className="text-sm font-semibold text-ink">{item.chunk}</div>
                <div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{item.meaningZh || item.explanation || detailStatusLabel(item.detailStatus)}</div>
              </button>
            ))}
            {!recentCorpus.length && <p className="text-sm text-slate-500">还没有 corpus。</p>}
          </div>
        </div>
      </div>
    </section>
  );
}

function DashboardModuleCard({ themeKey, title, value, detail, onClick }: { themeKey: ModeThemeKey; title: string; value: string; detail: string; onClick: () => void }) {
  const theme = modeThemes[themeKey];
  return (
    <button onClick={onClick} className={`rounded-xl border ${theme.border} ${theme.softBg} p-4 text-left transition ${theme.hoverBg}`}>
      <div className="flex items-center gap-2">
        <span className={`grid h-8 w-8 place-items-center rounded-lg bg-white/80 text-sm font-semibold ${theme.accentText}`}>{theme.icon}</span>
        <span className={`text-sm font-semibold ${theme.accentText}`}>{title}</span>
      </div>
      <div className="mt-2 text-2xl font-semibold text-ink">{value}</div>
      <div className="mt-1 text-xs leading-5 text-slate-500">{detail}</div>
    </button>
  );
}

function DashboardStatLine({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-xl bg-slate-50 px-4 py-3">
      <span className="text-sm font-semibold text-slate-600">{label}</span>
      <span className="text-sm font-semibold text-ink">{value}</span>
    </div>
  );
}

function ParseTaskCenter({
  tasks,
  error,
  onRefresh,
  onCancel,
  onResume,
  onDelete
}: {
  tasks: ParseTask[];
  error: string;
  onRefresh: () => void;
  onCancel: (task: ParseTask) => void;
  onResume: (task: ParseTask) => void;
  onDelete: (task: ParseTask) => void;
}) {
  const activeTasks = tasks.filter((task) => ["queued", "running", "canceling"].includes(task.status));
  const failedOrCanceledTasks = tasks.filter((task) => ["failed", "canceled"].includes(task.status));
  return (
    <section>
      <Header eyebrow="Task Center" title="解析任务" description="统一查看后台解析、导入和预处理任务；后续听力预分词等任务也会进入这里。" />
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      <div className="mb-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-center">
        <div className="grid gap-3 sm:grid-cols-3">
          <Metric label="进行中" value={`${activeTasks.length}`} />
          <Metric label="已完成" value={`${tasks.filter((task) => task.status === "completed").length}`} />
          <Metric label="异常/中止" value={`${failedOrCanceledTasks.length}`} />
        </div>
        <SecondaryButton onClick={onRefresh}>刷新任务</SecondaryButton>
      </div>
      <div className="grid min-w-0 gap-3">
        {tasks.length ? tasks.map((task) => <ParseTaskCard key={task.id} task={task} onCancel={onCancel} onResume={onResume} onDelete={onDelete} />) : (
          <Locked title="暂无解析任务" text="从句子导入或其他模块创建后台任务后，会在这里显示进度、耗时和中止入口。" />
        )}
      </div>
    </section>
  );
}

function ParseTaskCard({ task, onCancel, onResume, onDelete }: { task: ParseTask; onCancel: (task: ParseTask) => void; onResume: (task: ParseTask) => void; onDelete: (task: ParseTask) => void }) {
  const percent = task.totalCount ? Math.round((task.processedCount / task.totalCount) * 100) : 0;
  const isActive = ["queued", "running", "canceling"].includes(task.status);
  const canResume = ["failed", "canceled"].includes(task.status) && ["translation_import", "podcast_import"].includes(task.type);
  const latestError = task.errors[task.errors.length - 1];
  return (
    <article className="min-w-0 max-w-full overflow-hidden rounded-2xl border border-line bg-white p-5">
      <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
        <div className="min-w-0">
          <div className="flex flex-wrap items-center gap-2">
            <span className="rounded-full bg-things-50 px-3 py-1 text-xs font-semibold text-things-700">{parseTaskTypeLabel(task.type)}</span>
            <span className="rounded-full bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{parseTaskStatusLabel(task.status)}</span>
          </div>
          <h2 className="mt-2 break-words text-lg font-semibold text-ink">{task.title}</h2>
          <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500">
            <span>{task.processedCount}/{task.totalCount}</span>
            <span>成功 {task.succeededCount}</span>
            <span>跳过 {task.skippedCount}</span>
            <span>失败 {task.failedCount}</span>
            <span>耗时 {formatDuration(task.elapsedSeconds)}</span>
          </div>
        </div>
        <div className="mobile-action-grid">
          {isActive && (
            <SecondaryButton onClick={() => onCancel(task)} disabled={task.status === "canceling"}>
              {task.status === "canceling" ? "中止中..." : "中止任务"}
            </SecondaryButton>
          )}
          {canResume && (
            <PrimaryButton onClick={() => onResume(task)}>
              {task.status === "failed" ? "重试任务" : "继续任务"}
            </PrimaryButton>
          )}
        </div>
      </div>
      <div className="mt-4 h-2 overflow-hidden rounded-full bg-slate-100">
        <div className="h-full rounded-full bg-things-500 transition-all" style={{ width: `${percent}%` }} />
      </div>
      {task.currentLabel && <div className="mt-2 min-w-0 truncate text-xs text-slate-500">当前：{task.currentLabel}</div>}
      {latestError && (
        <div className="mt-3 break-words [overflow-wrap:anywhere] rounded-xl bg-red-50 px-3 py-2 text-xs leading-5 text-red-600">
          最近错误：{latestError.sourceSentence ? `${latestError.sourceSentence} · ` : ""}{latestError.error}
        </div>
      )}
      <div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
        <span>创建：{formatDateTime(task.createdAt)}</span>
        <span>更新：{formatDateTime(task.updatedAt)}</span>
        {task.finishedAt && <span>结束：{formatDateTime(task.finishedAt)}</span>}
        {!isActive && (
          <button
            type="button"
            onClick={() => onDelete(task)}
            className="text-slate-300 underline-offset-2 transition hover:text-red-500 hover:underline"
          >
            删除
          </button>
        )}
      </div>
    </article>
  );
}

function parseTaskTypeLabel(type: string): string {
  if (type === "translation_import") return "句子导入";
  if (type === "listening_tokenize") return "听力分词";
  if (type === "podcast_import") return "播客导入";
  return type || "解析任务";
}

function parseTaskStatusLabel(status: ParseTaskStatus): string {
  if (status === "queued") return "排队中";
  if (status === "running") return "解析中";
  if (status === "canceling") return "中止中";
  if (status === "completed") return "已完成";
  if (status === "canceled") return "已中止";
  return "失败";
}

function LoginScreen({ onSuccess }: { onSuccess: (user: AuthUser) => void }) {
  const [mode, setMode] = useState<"login" | "register">("login");
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  async function submit(event: React.FormEvent) {
    event.preventDefault();
    setBusy(true);
    setError("");
    try {
      const user = mode === "login"
        ? await apiLogin(username.trim(), password)
        : await apiRegister(username.trim(), password);
      onSuccess(user);
    } catch (err) {
      setError(err instanceof Error ? err.message : "操作失败");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-[#f4f4f6] to-[#e9e9ed] px-4">
      <div className="w-full max-w-sm rounded-[22px] border border-white/80 bg-[#f8f8fa]/95 p-6 shadow-soft">
        <div className="text-xs font-semibold uppercase tracking-[0.16em] text-things-600">PJCT</div>
        <h1 className="mt-2 text-2xl font-semibold leading-tight text-ink">
          {mode === "login" ? "登录" : "注册新账户"}
        </h1>
        <p className="mt-1 text-sm leading-6 text-slate-500">
          {mode === "login" ? "登录后进入你自己的学习数据。" : "创建一个独立账户，拥有独立的数据与 API 配置。"}
        </p>
        <form onSubmit={submit} className="mt-5 grid gap-3">
          <label className="grid gap-1.5">
            <span className="text-sm font-semibold text-slate-700">用户名</span>
            <input
              className="input"
              autoComplete="username"
              value={username}
              onChange={(event) => setUsername(event.target.value)}
              required
            />
          </label>
          <label className="grid gap-1.5">
            <span className="text-sm font-semibold text-slate-700">密码</span>
            <input
              className="input"
              type="password"
              autoComplete={mode === "login" ? "current-password" : "new-password"}
              value={password}
              onChange={(event) => setPassword(event.target.value)}
              required
            />
          </label>
          {error && <div className="rounded-xl bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">{error}</div>}
          <button
            type="submit"
            disabled={busy || !username.trim() || !password}
            className="mt-1 w-full rounded-xl bg-things-600 px-4 py-2.5 text-sm font-semibold text-white shadow-soft transition hover:bg-things-700 disabled:opacity-50"
          >
            {busy ? "请稍候…" : mode === "login" ? "登录" : "注册并登录"}
          </button>
        </form>
        <button
          onClick={() => {
            setMode(mode === "login" ? "register" : "login");
            setError("");
          }}
          className="mt-4 w-full text-center text-sm font-semibold text-things-600 hover:text-things-800"
        >
          {mode === "login" ? "没有账户？去注册" : "已有账户？去登录"}
        </button>
      </div>
    </div>
  );
}

const PROVIDER_OPTIONS: Record<string, string[]> = {
  ai: ["mock", "deepseek", "openai_compatible"],
  stt: ["mock", "openai", "openai_compatible", "whisper"],
  tts: ["mock", "openai", "openai_compatible"],
  tokenizer: ["ai", "fallback"]
};

const PROVIDER_SECTION_LABELS: Record<string, string> = {
  ai: "AI 对话 / 优化",
  stt: "语音转写 (STT)",
  tts: "语音合成 (TTS)",
  tokenizer: "分词器"
};

function ProviderSettings({ activeSection }: { activeSection: keyof typeof PROVIDER_SECTION_LABELS }) {
  const [settings, setSettings] = useState<ProviderSettings | null>(null);
  const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({});
  const [loadError, setLoadError] = useState("");
  const [saving, setSaving] = useState(false);
  const [saved, setSaved] = useState(false);
  const [error, setError] = useState("");
  const [balance, setBalance] = useState<ProviderBalance | null>(null);
  const [balanceLoading, setBalanceLoading] = useState(false);
  const [balanceError, setBalanceError] = useState("");
  const balanceRequestRef = useRef(0);

  useEffect(() => {
    let active = true;
    apiGetSettings()
      .then((data) => {
        if (active) setSettings(data);
      })
      .catch((err) => {
        if (active) setLoadError(err instanceof Error ? err.message : "读取配置失败");
      });
    return () => {
      active = false;
    };
  }, []);

  async function loadBalance(section = activeSection) {
    const requestId = balanceRequestRef.current + 1;
    balanceRequestRef.current = requestId;
    setBalanceLoading(true);
    setBalanceError("");
    try {
      const nextBalance = await apiGetProviderBalance(section);
      if (balanceRequestRef.current === requestId) setBalance(nextBalance);
    } catch (err) {
      if (balanceRequestRef.current === requestId) {
        setBalance(null);
        setBalanceError(err instanceof Error ? err.message : "查询余额失败");
      }
    } finally {
      if (balanceRequestRef.current === requestId) setBalanceLoading(false);
    }
  }

  useEffect(() => {
    if (!settings) return;
    setBalance(null);
    void loadBalance(activeSection);
  }, [activeSection, settings]);

  function setField(section: string, field: string, value: string) {
    setDrafts((current) => ({
      ...current,
      [section]: { ...(current[section] || {}), [field]: value }
    }));
  }

  function fieldValue(section: string, field: string): string {
    const draft = drafts[section]?.[field];
    if (draft !== undefined) return draft;
    const raw = settings?.[section]?.[field];
    return raw === undefined || raw === null ? "" : String(raw);
  }

  async function save() {
    setSaving(true);
    setError("");
    try {
      const payload: Record<string, Record<string, unknown>> = {};
      for (const section of Object.keys(drafts)) {
        payload[section] = {};
        for (const [field, value] of Object.entries(drafts[section])) {
          if (field === "api_key" && !value) continue; // blank = keep existing
          payload[section][field] = value;
        }
      }
      const updated = await apiSaveSettings(payload);
      setSettings(updated);
      setDrafts({});
      await loadBalance(activeSection);
      setSaved(true);
      window.setTimeout(() => setSaved(false), 1800);
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存配置失败");
    } finally {
      setSaving(false);
    }
  }

  const hasChanges = Object.values(drafts).some((section) => Object.keys(section).length > 0);

  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="text-lg font-semibold text-ink">API 提供方配置</div>
      <p className="mt-1 text-sm leading-6 text-slate-500">
        每个账户拥有独立的密钥与模型配置。配置保存在你自己的数据库里，不与其他账户共享。
        API Key 留空表示保持原值不变；填写新值才会覆盖。Provider 选 <code className="rounded bg-slate-100 px-1">mock</code> 时无需密钥。
      </p>
      {loadError && <div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">{loadError}</div>}
      {!settings && !loadError && <div className="mt-4 text-sm text-slate-400">正在读取配置…</div>}
      {settings && (
        <div className="mt-4 grid gap-4">
          <div className="rounded-xl border border-line bg-slate-50 p-4">
            <div className="text-sm font-semibold text-ink">{PROVIDER_SECTION_LABELS[activeSection]}</div>
            <div className="mt-3 grid gap-3 md:grid-cols-2">
              <label className="grid gap-1.5">
                <span className="text-xs font-semibold text-slate-600">Provider</span>
                <select className="input" value={fieldValue(activeSection, "provider")} onChange={(event) => setField(activeSection, "provider", event.target.value)}>
                  {(PROVIDER_OPTIONS[activeSection] || []).map((option) => (
                    <option key={option} value={option}>{option}</option>
                  ))}
                </select>
              </label>
              {activeSection !== "tokenizer" && (
                <label className="grid gap-1.5">
                  <span className="text-xs font-semibold text-slate-600">Base URL</span>
                  <input className="input" value={fieldValue(activeSection, "base_url")} onChange={(event) => setField(activeSection, "base_url", event.target.value)} placeholder="https://api.example.com" />
                </label>
              )}
              {activeSection !== "tokenizer" && (
                <label className="grid gap-1.5">
                  <span className="text-xs font-semibold text-slate-600">Model</span>
                  <input className="input" value={fieldValue(activeSection, "model")} onChange={(event) => setField(activeSection, "model", event.target.value)} />
                </label>
              )}
              {activeSection !== "tokenizer" && (
                <label className="grid gap-1.5">
                  <span className="text-xs font-semibold text-slate-600">
                    API Key {settings[activeSection]?.api_key_set ? `（已设置 ${settings[activeSection]?.api_key_preview}）` : "（未设置）"}
                  </span>
                  <input
                    className="input"
                    type="password"
                    value={drafts[activeSection]?.api_key ?? ""}
                    onChange={(event) => setField(activeSection, "api_key", event.target.value)}
                    placeholder={settings[activeSection]?.api_key_set ? "留空则不修改" : "输入 API Key"}
                  />
                </label>
              )}
            </div>
          </div>
          <div className="rounded-xl border border-line bg-slate-50 p-4">
            <div className="flex flex-wrap items-center justify-between gap-2">
              <div>
                <div className="text-sm font-semibold text-ink">API 账户余额</div>
                <div className="mt-1 text-xs leading-5 text-slate-500">展示服务商返回的计费余额，不是固定 token 包。实际可用 token 数会随模型与调用方式变化。</div>
              </div>
              <button className="rounded-lg border border-line bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-50" onClick={() => void loadBalance()} disabled={balanceLoading}>
                {balanceLoading ? "查询中..." : "刷新余额"}
              </button>
            </div>
            {balanceError && <div className="mt-3 text-sm font-semibold text-red-600">{balanceError}</div>}
            {!balanceError && balanceLoading && !balance && <div className="mt-3 text-sm text-slate-400">正在查询余额...</div>}
            {!balanceError && balance && balance.status !== "available" && <div className="mt-3 text-sm text-slate-500">{balance.message}</div>}
            {!balanceError && balance?.status === "available" && (
              <div className="mt-3 grid gap-2">
                <div className={`text-sm font-semibold ${balance.is_available ? "text-things-700" : "text-red-600"}`}>
                  {balance.is_available ? "当前余额可用于 API 调用" : "当前余额不足，无法调用 API"}
                  {balance.requested_section === "tokenizer" && balance.source_section === "ai" ? "（分词器复用 AI 配置）" : ""}
                </div>
                {(balance.balance_infos || []).map((item) => (
                  <div key={item.currency} className="grid gap-1 rounded-lg border border-line bg-white px-3 py-2 text-xs text-slate-600 sm:grid-cols-3">
                    <span className="font-semibold text-ink">可用余额：{item.total_balance} {item.currency}</span>
                    <span>赠送余额：{item.granted_balance} {item.currency}</span>
                    <span>充值余额：{item.topped_up_balance} {item.currency}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
          <div className="flex flex-wrap items-center gap-3">
            <PrimaryButton onClick={save} disabled={!hasChanges || saving}>{saving ? "保存中..." : "保存配置"}</PrimaryButton>
            {saved && <span className="text-sm font-semibold text-things-700">已保存。</span>}
            {error && <span className="text-sm font-semibold text-red-600">{error}</span>}
          </div>
        </div>
      )}
    </div>
  );
}

function DotPushSettings() {
  const [settings, setSettings] = useState<DotSettings | null>(null);
  const [apiKey, setApiKey] = useState("");
  const [saving, setSaving] = useState(false);
  const [pushing, setPushing] = useState(false);
  const [message, setMessage] = useState("");
  const [error, setError] = useState("");

  useEffect(() => {
    apiGetDotSettings()
      .then(setSettings)
      .catch((err) => setError(err instanceof Error ? err.message : "读取 Dot 配置失败"));
  }, []);

  function setField(field: keyof DotSettings, value: string | number | boolean) {
    setSettings((current) => ({ ...(current || {}), [field]: value }));
  }

  async function save() {
    if (!settings) return;
    setSaving(true);
    setError("");
    try {
      const updated = await apiSaveDotSettings({
        enabled: Boolean(settings.enabled),
        interval_minutes: Number(settings.interval_minutes || 60),
        device_id: settings.device_id || "",
        title: settings.title || "",
        refresh_now: settings.refresh_now !== false,
        ...(apiKey ? { api_key: apiKey } : {})
      });
      setSettings(updated);
      setApiKey("");
      setMessage("Dot 推送设置已保存。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存 Dot 配置失败");
    } finally {
      setSaving(false);
    }
  }

  async function pushNow() {
    setPushing(true);
    setError("");
    try {
      const result = await apiPushDotNow();
      setMessage(`已推送：${result.sentence || "随机句子"}`);
      setSettings(await apiGetDotSettings());
    } catch (err) {
      setError(err instanceof Error ? err.message : "Dot 推送失败");
    } finally {
      setPushing(false);
    }
  }

  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="text-lg font-semibold text-ink">Dot 定时推送</div>
      <p className="mt-1 text-sm leading-6 text-slate-500">
        定时从中译日句库随机选择一条未删除、未归档句子，发送到 Dot Text API。API Key 保存在当前账户的私有数据库中，页面只显示掩码。
      </p>
      {!settings && !error && <div className="mt-4 text-sm text-slate-400">正在读取配置…</div>}
      {settings && (
        <div className="mt-4 grid gap-3 md:grid-cols-2">
          <label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
            <input type="checkbox" checked={Boolean(settings.enabled)} onChange={(event) => setField("enabled", event.target.checked)} />
            开启定时推送
          </label>
          <label className="flex items-center gap-2 text-sm font-semibold text-slate-700">
            <input type="checkbox" checked={settings.refresh_now !== false} onChange={(event) => setField("refresh_now", event.target.checked)} />
            推送后立即切换显示
          </label>
          <label className="grid gap-1.5">
            <span className="text-xs font-semibold text-slate-600">推送间隔（分钟）</span>
            <input className="input" type="number" min="1" max="10080" value={settings.interval_minutes || 60} onChange={(event) => setField("interval_minutes", Number(event.target.value))} />
          </label>
          <label className="grid gap-1.5">
            <span className="text-xs font-semibold text-slate-600">设备序列号（可留空自动选择第一个设备）</span>
            <input className="input" value={settings.device_id || ""} onChange={(event) => setField("device_id", event.target.value)} placeholder="例如 ABCD1234ABCD" />
          </label>
          <label className="grid gap-1.5">
            <span className="text-xs font-semibold text-slate-600">显示标题</span>
            <input className="input" value={settings.title || ""} onChange={(event) => setField("title", event.target.value)} />
          </label>
          <label className="grid gap-1.5">
            <span className="text-xs font-semibold text-slate-600">Dot API Key {settings.api_key_set ? `（已设置 ${settings.api_key_preview}）` : "（未设置）"}</span>
            <input className="input" type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder={settings.api_key_set ? "留空则不修改" : "输入 Dot API Key"} />
          </label>
          <div className="md:col-span-2 flex flex-wrap items-center gap-3">
            <PrimaryButton onClick={save} disabled={saving}>{saving ? "保存中..." : "保存 Dot 设置"}</PrimaryButton>
            <button className="rounded-xl border border-line bg-white px-4 py-2 text-sm font-semibold text-ink hover:bg-slate-50 disabled:opacity-50" onClick={pushNow} disabled={pushing || !settings.api_key_set}>
              {pushing ? "推送中..." : "立即推送一次"}
            </button>
          </div>
          {(settings.last_sent_at || settings.last_error) && (
            <div className="md:col-span-2 rounded-xl bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
              {settings.last_sent_at && <div>最近成功：{settings.last_sent_at}</div>}
              {settings.last_sentence && <div>最近句子：{settings.last_sentence}</div>}
              {settings.last_error && <div className="text-red-600">最近错误：{settings.last_error}</div>}
            </div>
          )}
        </div>
      )}
      {message && <div className="mt-3 text-sm font-semibold text-things-700">{message}</div>}
      {error && <div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">{error}</div>}
    </div>
  );
}

function SettingsPage({
  listeningPlaybackRate,
  listeningScoreSettings,
  onSavePreferences
}: {
  listeningPlaybackRate: number;
  listeningScoreSettings: ListeningScoreSettings;
  onSavePreferences: (preferences: AppPreferences) => Promise<void>;
}) {
  const [activeTab, setActiveTab] = useState<"practice" | "ai" | "stt" | "tts" | "tokenizer" | "dot">("practice");
  const [draftRate, setDraftRate] = useState(listeningPlaybackRate);
  const [draftScoreSettings, setDraftScoreSettings] = useState(listeningScoreSettings);
  const [saved, setSaved] = useState(false);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    setDraftRate(listeningPlaybackRate);
  }, [listeningPlaybackRate]);

  useEffect(() => {
    setDraftScoreSettings(listeningScoreSettings);
  }, [listeningScoreSettings]);

  async function save() {
    setSaving(true);
    setError("");
    try {
      await onSavePreferences({
        listeningPlaybackRate: draftRate,
        listeningScoreSettings: normalizeListeningScoreSettings(draftScoreSettings)
      });
      setSaved(true);
      window.setTimeout(() => setSaved(false), 1800);
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存设置失败");
    } finally {
      setSaving(false);
    }
  }

  function updateScoreSetting(key: keyof ListeningScoreSettings, value: string) {
    setDraftScoreSettings((current) => ({ ...current, [key]: Number(value) }));
  }

  function resetScoreSettings() {
    setDraftScoreSettings(defaultListeningScoreSettings);
  }

  const settingsChanged = draftRate !== listeningPlaybackRate || JSON.stringify(normalizeListeningScoreSettings(draftScoreSettings)) !== JSON.stringify(listeningScoreSettings);
  const tabs = [
    { key: "practice", label: "练习偏好" },
    { key: "ai", label: "AI" },
    { key: "stt", label: "STT" },
    { key: "tts", label: "TTS" },
    { key: "tokenizer", label: "分词器" },
    { key: "dot", label: "Dot 推送" }
  ] as const;

  return (
    <section>
      <Header eyebrow="Settings" title="设置" description="管理账户配置与练习偏好。设置会保存到你自己的 SQLite 数据库，重启服务后仍会保留。" />
      <div className="mb-4 flex flex-wrap gap-2 rounded-2xl border border-line bg-white p-2">
        {tabs.map((tab) => (
          <button
            key={tab.key}
            type="button"
            onClick={() => setActiveTab(tab.key)}
            className={`rounded-xl px-4 py-2.5 text-sm font-semibold transition ${
              activeTab === tab.key ? "bg-things-600 text-white shadow-soft" : "text-slate-500 hover:bg-things-50 hover:text-things-800"
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {activeTab !== "practice" && activeTab !== "dot" && <ProviderSettings activeSection={activeTab} />}
      {activeTab === "dot" && <DotPushSettings />}
      {activeTab === "practice" && <div className="grid gap-4">
      <div className="rounded-2xl border border-line bg-white p-5">
        <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
          <div>
            <div className="text-lg font-semibold text-ink">听力全局倍速</div>
            <p className="mt-1 text-sm leading-6 text-slate-500">
              影响“句子训练 / 听力练习”里的 AI 音频播放和系统播放。你也可以在听力练习页临时调整，它会同步更新这里。
            </p>
          </div>
          <div className="rounded-full bg-things-50 px-4 py-2 text-sm font-semibold text-things-800">
            当前 {listeningPlaybackRate}x
          </div>
        </div>
        <div className="mt-5 flex flex-wrap gap-2">
          {listeningRateOptions.map((rate) => (
            <button
              key={rate}
              onClick={() => setDraftRate(rate)}
              className={`rounded-xl px-4 py-2.5 text-sm font-semibold transition ${
                draftRate === rate ? "bg-things-600 text-white shadow-soft" : "bg-slate-100 text-slate-600 hover:bg-things-50 hover:text-things-800"
              }`}
            >
              {rate}x
            </button>
          ))}
        </div>
      </div>
      <div className="rounded-2xl border border-line bg-white p-5">
        <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
          <div>
            <div className="text-lg font-semibold text-ink">听力评分扣分</div>
            <p className="mt-1 text-sm leading-6 text-slate-500">
              调整听力练习里的辅助行为扣分。设置只影响之后的本轮实时评分，不会重算历史记录。
            </p>
          </div>
          <button
            type="button"
            onClick={resetScoreSettings}
            className="w-fit rounded-full bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-400 hover:bg-slate-100 hover:text-slate-600"
          >
            恢复默认
          </button>
        </div>
        <div className="mt-4 grid gap-3 md:grid-cols-2">
          <ScoreSettingInput label="揭示词块最多扣分" value={draftScoreSettings.revealPenaltyMax} onChange={(value) => updateScoreSetting("revealPenaltyMax", value)} />
          <ScoreSettingInput label="重听每次扣分" value={draftScoreSettings.replayPenaltyPerExtraPlay} onChange={(value) => updateScoreSetting("replayPenaltyPerExtraPlay", value)} />
          <ScoreSettingInput label="重听最多扣分" value={draftScoreSettings.replayPenaltyMax} onChange={(value) => updateScoreSetting("replayPenaltyMax", value)} />
          <ScoreSettingInput label="显示提示扣分" value={draftScoreSettings.promptHintPenalty} onChange={(value) => updateScoreSetting("promptHintPenalty", value)} />
          <ScoreSettingInput label="系统播放每次扣分" value={draftScoreSettings.systemPlayPenalty} onChange={(value) => updateScoreSetting("systemPlayPenalty", value)} />
          <ScoreSettingInput label="系统播放最多扣分" value={draftScoreSettings.systemPlayPenaltyMax} onChange={(value) => updateScoreSetting("systemPlayPenaltyMax", value)} />
          <ScoreSettingInput label="显示解析扣分" value={draftScoreSettings.analysisPenalty} onChange={(value) => updateScoreSetting("analysisPenalty", value)} />
        </div>
      </div>
      <div className="flex flex-wrap items-center gap-3">
        <PrimaryButton onClick={save} disabled={!settingsChanged || saving}>{saving ? "保存中..." : "保存设置"}</PrimaryButton>
        {saved && <span className="text-sm font-semibold text-things-700">已保存。</span>}
        {error && <span className="text-sm font-semibold text-red-600">{error}</span>}
      </div>
      </div>}
    </section>
  );
}

function ScoreSettingInput({ label, value, onChange }: { label: string; value: number; onChange: (value: string) => void }) {
  return (
    <label className="grid gap-1.5 rounded-xl bg-slate-50 p-3">
      <span className="text-sm font-semibold text-slate-700">{label}</span>
      <input
        type="number"
        min="0"
        max="100"
        step="1"
        className="input"
        value={Number.isFinite(value) ? value : 0}
        onChange={(event) => onChange(event.target.value)}
      />
    </label>
  );
}

function WeeklyMode({ state, setView, canEnterDaily }: { state: AppState; setView: (view: ViewKey) => void; canEnterDaily: boolean }) {
  return (
    <section>
      <Header eyebrow="Weekly Mode" title="周训练模式" description="适合想围绕一个主题做更完整训练时使用。快速循环不受这个流程限制。" />
      <div className="grid gap-3 md:grid-cols-2">
        <WorkflowCard
          title="1. 设置本周主题"
          text={state.topic ? `当前主题：${state.topic.title}` : "创建一个用于周训练的主题。"}
          action="设置主题"
          onClick={() => setView("topic")}
        />
        <WorkflowCard
          title="2. Day 1 准备"
          text="完成初次输出、自主修正和 AI 优化，生成本周语料池。"
          action="进入 Day 1"
          disabled={!state.topic}
          onClick={() => setView("practice")}
        />
        <WorkflowCard
          title="3. 结构化训练"
          text="从语料池抽取 2 个表达，围绕同一主题复述。"
          action="开始训练"
          disabled={!canEnterDaily}
          onClick={() => setView("daily")}
        />
        <WorkflowCard
          title="4. 复盘"
          text="对比初次输出和最终复述，确认已经固化的表达。"
          action="进入复盘"
          disabled={!state.sessions.length}
          onClick={() => setView("review")}
        />
      </div>
    </section>
  );
}

function WorkflowCard({ title, text, action, disabled, onClick }: { title: string; text: string; action: string; disabled?: boolean; onClick: () => void }) {
  return (
    <article className="rounded-2xl border border-line bg-white p-5">
      <h3 className="text-lg font-semibold text-ink">{title}</h3>
      <p className="mt-2 min-h-12 text-sm leading-6 text-slate-600">{text}</p>
      <div className="mt-4">
        <SecondaryButton onClick={onClick} disabled={disabled}>{action}</SecondaryButton>
      </div>
    </article>
  );
}

function TopicSetup({ topic, onSave }: { topic: Topic | null; onSave: (topic: Topic) => void }) {
  const [title, setTitle] = useState(topic?.title || "");
  const [type, setType] = useState<TopicType>(topic?.type || "经历");

  function save() {
    if (!title.trim()) return;
    onSave({
      id: topic?.id || id("topic"),
      title: title.trim(),
      type,
      createdAt: topic?.createdAt || todayIso(),
      currentDay: topic?.currentDay || 1
    });
  }

  return (
    <section>
      <Header eyebrow="Topic Setup" title="创建本周主题" description="一周只训练一个主题，降低认知负担，让表达真正固化。" />
      {topic && (
        <div className="mb-4 rounded-2xl bg-things-50 p-4 text-sm text-things-900">
          当前主题：<span className="font-semibold">{topic.title}</span> · Day {topic.currentDay}
        </div>
      )}
      <div className="grid gap-4 rounded-2xl border border-line bg-white p-5">
        <label className="grid gap-2">
          <span className="text-sm font-semibold text-slate-700">主题</span>
          <input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如：入职前的不安" className="input" />
        </label>
        <label className="grid gap-2">
          <span className="text-sm font-semibold text-slate-700">主题类型</span>
          <select value={type} onChange={(event) => setType(event.target.value as TopicType)} className="input">
            {["经历", "观点", "日常", "作品感想", "工作表达"].map((item) => (
              <option key={item}>{item}</option>
            ))}
          </select>
        </label>
        <PrimaryButton onClick={save} disabled={!title.trim()}>保存主题</PrimaryButton>
      </div>
    </section>
  );
}

function ThinkingPanel({ topic, corpusItems }: { topic: Topic | null; corpusItems: CorpusItem[] }) {
  const [suggestions, setSuggestions] = useState<CorpusItem[]>([]);
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const topicKey = topic?.title || "";
  const cacheKey = `assist_${topicKey}_${corpusItems.slice(0, 8).map((item) => item.chunk).join("|")}`;

  async function ask(nextQuestion = question) {
    setError("");
    setLoading(true);
    try {
      const response = await apiAssistThinking(topic, nextQuestion, corpusItems);
      setSuggestions(response.suggestions);
      setAnswer(response.answer);
    } catch (err) {
      setError(err instanceof Error ? err.message : "AI 辅助失败");
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    setSuggestions([]);
    setAnswer("");
    setQuestion("");
    setError("");
    if (!topicKey.trim()) return;
    const cached = localStorage.getItem(cacheKey);
    if (!cached) return;
    try {
      const parsed = JSON.parse(cached);
      setSuggestions(parsed.suggestions || []);
      setAnswer(parsed.answer || "");
    } catch {
      localStorage.removeItem(cacheKey);
    }
  }, [cacheKey]);

  async function generateSuggestions() {
    setError("");
    setLoading(true);
    try {
      const response = await apiAssistThinking(topic, "", corpusItems);
      setSuggestions(response.suggestions);
      setAnswer(response.answer);
      localStorage.setItem(cacheKey, JSON.stringify(response));
    } catch (err) {
      setError(err instanceof Error ? err.message : "AI 辅助失败");
    } finally {
      setLoading(false);
    }
  }

  return (
    <section className="mb-4 rounded-2xl border border-line bg-white p-5">
      <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
        <div>
          <div className="text-sm font-semibold text-things-700">AI 辅助思考</div>
          <p className="mt-1 text-sm text-slate-600">先看可用表达和组织方向，再开始输出。</p>
        </div>
        <SecondaryButton onClick={generateSuggestions} disabled={loading || !topicKey.trim()}>{loading ? "生成中..." : suggestions.length || answer ? "重新生成建议" : "生成建议"}</SecondaryButton>
      </div>
      {!suggestions.length && !answer && !loading && (
        <p className="mt-4 rounded-xl bg-slate-50 p-3 text-sm text-slate-500">AI 建议不会自动生成，点击后才会调用 API。</p>
      )}
      {suggestions.length > 0 && (
        <div className="mt-4 grid gap-3 md:grid-cols-3">
          {suggestions.map((item) => (
            <div key={item.id} className="rounded-xl bg-mist p-4">
              <div className="font-semibold text-ink">{item.chunk}</div>
              <div className="mt-1 text-sm text-slate-600">{item.meaningZh}</div>
              <div className="mt-2 text-sm leading-6 text-slate-700">{item.exampleJa}</div>
            </div>
          ))}
        </div>
      )}
      <div className="mt-4 grid gap-2 md:grid-cols-[1fr_auto]">
        <input className="input" value={question} onChange={(event) => setQuestion(event.target.value)} placeholder="输入问题，例如：自我介绍开头怎么说更自然？" />
        <PrimaryButton onClick={() => ask()} disabled={loading || !question.trim()}>提问</PrimaryButton>
      </div>
      {answer && <p className="mt-4 whitespace-pre-wrap rounded-xl bg-things-50 p-4 text-sm leading-7 text-slate-700">{answer}</p>}
      {error && <p className="mt-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
    </section>
  );
}

function QuickLoop({
  state,
  onSave
}: {
  state: AppState;
  onSave: (topic: Topic, session: PracticeSession, corpusItems: CorpusItem[]) => void;
}) {
  const recorder = useRecorder();
  const [topicTitle, setTopicTitle] = useState(state.topic?.title || "自我介绍");
  const [topicType, setTopicType] = useState<TopicType>(state.topic?.type || "日常");
  const [rawTranscript, setRawTranscript] = useState("");
  const [selfCorrection, setSelfCorrection] = useState("");
  const [ai, setAi] = useState<AIResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [aiError, setAiError] = useState("");
  const [transcribing, setTranscribing] = useState(false);
  const [transcribeError, setTranscribeError] = useState("");
  const [activeTab, setActiveTab] = useState<"setup" | "output" | "correct" | "result">("setup");
  const topicIdRef = useRef(state.topic?.id || id("topic"));
  const createdAtRef = useRef(state.topic?.createdAt || todayIso());
  const focusItems = useMemo(() => chooseDailyItems(state.corpusItems, 0), [state.corpusItems]);
  const transcriber = useSpeechTranscriber((text) => {
    setRawTranscript((current) => [current.trim(), text].filter(Boolean).join("\n"));
  });

  const topic: Topic = {
    id: topicIdRef.current,
    title: topicTitle.trim() || "自我介绍",
    type: topicType,
    createdAt: createdAtRef.current,
    currentDay: state.topic?.currentDay || 1
  };

  async function optimize() {
    if (!selfCorrection.trim()) return;
    setAiError("");
    setLoading(true);
    try {
      const response = await apiOptimizeJapanese(topic, rawTranscript, selfCorrection);
      setAi(response);
      setActiveTab("result");
    } catch (error) {
      setAiError(error instanceof Error ? error.message : "AI 优化失败");
    } finally {
      setLoading(false);
    }
  }

  async function transcribeRecording() {
    if (!recorder.audioBlob) return;
    setTranscribeError("");
    setTranscribing(true);
    try {
      const text = await apiTranscribeAudio(recorder.audioBlob, "ja");
      setRawTranscript((current) => [current.trim(), text].filter(Boolean).join("\n"));
    } catch (error) {
      setTranscribeError(error instanceof Error ? error.message : "Whisper 转写失败");
    } finally {
      setTranscribing(false);
    }
  }

  function saveAndNext() {
    if (!ai) return;
    const mergedItems = mergeCorpus(state.corpusItems, withCorpusSource(ai.corpusItems, "quick"));
    const session: PracticeSession = {
      id: id("session"),
      day: 0,
      rawTranscript,
      selfCorrection,
      aiCorrection: ai.improvedVersion,
      notes: `Quick Loop · ${topic.title} · 聚焦表达：${focusItems.map((item) => item.chunk).join(" / ") || "本轮生成新语料"}`,
      createdAt: todayIso()
    };
    onSave(topic, session, mergedItems);
    setRawTranscript("");
    setSelfCorrection("");
    setAi(null);
    setActiveTab("output");
  }

  return (
    <section>
      <Header eyebrow="Quick Loop" title="快速循环：输出、修正、沉淀" description="适合自我介绍、面试回答、常用场景表达。每轮只处理一个短主题，不需要按天推进。" />
      <ModuleTabs
        themeKey="quick"
        className="mb-0"
        active={activeTab}
        onChange={setActiveTab}
        tabs={[
          { key: "setup", label: "主题思考", icon: "想" },
          { key: "output", label: "输出转写", icon: "●" },
          { key: "correct", label: "自主修正", icon: "修" },
          { key: "result", label: "结果沉淀", icon: "✓" }
        ]}
      />

      {activeTab === "setup" && (
        <div className="grid gap-4 rounded-b-2xl border border-line bg-white p-4">
          <div className="grid gap-3 rounded-2xl border border-line bg-white p-5 md:grid-cols-[1fr_180px]">
            <label className="grid gap-2">
              <span className="text-sm font-semibold text-slate-700">本轮主题</span>
              <input className="input" value={topicTitle} onChange={(event) => setTopicTitle(event.target.value)} placeholder="例如：自我介绍 / 面试中的强项 / 为什么想来日本工作" />
            </label>
            <label className="grid gap-2">
              <span className="text-sm font-semibold text-slate-700">类型</span>
              <select value={topicType} onChange={(event) => setTopicType(event.target.value as TopicType)} className="input">
                {["经历", "观点", "日常", "作品感想", "工作表达"].map((item) => (
                  <option key={item}>{item}</option>
                ))}
              </select>
            </label>
          </div>
          <ThinkingPanel topic={topic} corpusItems={state.corpusItems} />
          {focusItems.length > 0 && (
            <>
              <div className="text-sm font-semibold text-things-700">本轮优先复用这 2 个表达</div>
              <DailyChunks items={focusItems} />
            </>
          )}
          <div className="mobile-action-grid">
            <PrimaryButton onClick={() => setActiveTab("output")}>开始输出</PrimaryButton>
          </div>
        </div>
      )}

      {activeTab === "output" && (
        <div className="grid gap-4">
          <AudioCapturePanel
            recorder={recorder}
            transcriber={transcriber}
            loading={transcribing}
            whisperError={transcribeError}
            onTranscribe={transcribeRecording}
          />
          <TextPanel
            label="原始转写"
            value={rawTranscript}
            onChange={setRawTranscript}
            placeholder="可以点击自动转写，也可以手动粘贴录音转写文本"
          />
          <div className="mobile-action-grid">
            <SecondaryButton onClick={() => setRawTranscript("")} disabled={!rawTranscript.trim()}>清空转写</SecondaryButton>
            <PrimaryButton onClick={() => setActiveTab("correct")} disabled={!rawTranscript.trim()}>进入自主修正</PrimaryButton>
          </div>
        </div>
      )}

      {activeTab === "correct" && (
        <div>
          <CorrectionGrid rawTranscript={rawTranscript} selfCorrection={selfCorrection} aiCorrection={ai?.improvedVersion || ""} onRaw={setRawTranscript} onSelf={setSelfCorrection} />
          <div className="mobile-action-grid mt-4">
            <PrimaryButton onClick={optimize} disabled={!selfCorrection.trim() || loading}>{loading ? "AI 优化中..." : "AI 优化"}</PrimaryButton>
            <SecondaryButton onClick={() => setActiveTab("output")}>返回转写</SecondaryButton>
          </div>
          {!ai && (
            <p className="mt-3 text-sm text-slate-500">
              先写一版自己的修正，再让 AI 优化。这样能保留你的表达判断，不会变成直接抄答案。
            </p>
          )}
        </div>
      )}

      {aiError && <p className="mt-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{aiError}</p>}

      {activeTab === "result" && (
        <div>
          {!ai ? (
            <Locked title="还没有 AI 优化结果" text="请先在“自主修正”里填写你的修正版，并点击 AI 优化。" />
          ) : (
            <>
              <AIResult ai={ai} existingCount={state.corpusItems.length} />
              <div className="mobile-action-grid mt-4">
                <PrimaryButton onClick={saveAndNext}>保存本轮并继续</PrimaryButton>
                <SecondaryButton onClick={() => setActiveTab("correct")}>返回修改</SecondaryButton>
              </div>
            </>
          )}
        </div>
      )}
    </section>
  );
}

function MonologuePractice() {
  const [tasks, setTasks] = useState<MonologueTask[]>([]);
  const [selectedTaskId, setSelectedTaskId] = useState("");
  const [selectedSentenceId, setSelectedSentenceId] = useState("");
  const [title, setTitle] = useState("");
  const [taskTime, setTaskTime] = useState(() => localDateTimeInputValue());
  const [tagText, setTagText] = useState("");
  const [description, setDescription] = useState("");
  const [rawTranscript, setRawTranscript] = useState("");
  const [finalTranscript, setFinalTranscript] = useState("");
  const [hiddenSummaryBlockIds, setHiddenSummaryBlockIds] = useState<string[]>([]);
  const [showCreateTask, setShowCreateTask] = useState(false);
  const [showTaskManager, setShowTaskManager] = useState(false);
  const [draggingBlockId, setDraggingBlockId] = useState("");
  const [loading, setLoading] = useState(false);
  const [completingSentenceId, setCompletingSentenceId] = useState("");
  const [undoingSentenceId, setUndoingSentenceId] = useState("");
  const [finalizingTask, setFinalizingTask] = useState(false);
  const [recordingSentenceId, setRecordingSentenceId] = useState("");
  const [notice, setNotice] = useState("");
  const [error, setError] = useState("");
  const [sentenceAudioUrls, setSentenceAudioUrls] = useState<Record<string, string>>({});
  const [sentenceAudioLoadingKey, setSentenceAudioLoadingKey] = useState("");
  const [sentenceAudioError, setSentenceAudioError] = useState("");
  const [addingCorpusKey, setAddingCorpusKey] = useState("");
  const [addedCorpusKeys, setAddedCorpusKeys] = useState<string[]>([]);
  const recorder = useRecorder();
  const finalRecorder = useRecorder();
  const transcriber = useSpeechTranscriber((text) => setRawTranscript((current) => [current, text].filter(Boolean).join("\n")));

  const tabTasks = tasks.filter((task) => task.status !== "closed");
  const selectedTask = tasks.find((task) => task.id === selectedTaskId) || tabTasks[0] || tasks[0];
  const currentSentence = selectedTask?.sentences.find((sentence) => sentence.id === selectedSentenceId) || selectedTask?.sentences.find((sentence) => sentence.blockType === "sentence");
  const latestAttempt = currentSentence?.attempts[currentSentence.attempts.length - 1];

  useEffect(() => {
    refreshTasks();
  }, []);

  useEffect(() => {
    if (selectedTask && !selectedTask.sentences.some((sentence) => sentence.id === selectedSentenceId)) {
      setSelectedSentenceId(selectedTask.sentences[0]?.id || "");
    }
  }, [selectedTask?.id, selectedTask?.sentences.length]);

  useEffect(() => {
    setHiddenSummaryBlockIds([]);
  }, [selectedTaskId]);

  async function refreshTasks() {
    try {
      const nextTasks = await apiGetMonologueTasks();
      setTasks(nextTasks);
      if (!selectedTaskId) {
        const nextVisibleTask = nextTasks.find((task) => task.status !== "closed") || nextTasks[0];
        if (nextVisibleTask) setSelectedTaskId(nextVisibleTask.id);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取独白任务失败");
    }
  }

  function upsertTask(task: MonologueTask) {
    setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
    setSelectedTaskId(task.id);
    if (task.sentences[0] && !selectedSentenceId) setSelectedSentenceId(task.sentences[0].id);
  }

  function patchLocalSentence(taskId: string, sentenceId: string, updates: Partial<MonologueSentence>) {
    setTasks((current) => current.map((task) => task.id === taskId ? {
      ...task,
      sentences: task.sentences.map((sentence) => sentence.id === sentenceId ? { ...sentence, ...updates } : sentence)
    } : task));
  }

  function patchLocalTask(taskId: string, updates: Partial<MonologueTask>) {
    setTasks((current) => current.map((task) => task.id === taskId ? { ...task, ...updates } : task));
  }

  function selectTask(task: MonologueTask) {
    setSelectedTaskId(task.id);
    setSelectedSentenceId(task.sentences[0]?.id || "");
    setRawTranscript("");
    setFinalTranscript(task.finalTranscript || "");
    setHiddenSummaryBlockIds([]);
    setShowTaskManager(false);
  }

  async function updateTaskStatus(task: MonologueTask, status: MonologueTask["status"]) {
    const previousTask = task;
    patchLocalTask(task.id, { status, updatedAt: todayIso() });
    if (status === "closed" && selectedTaskId === task.id) {
      const nextTask = tasks.find((item) => item.id !== task.id && item.status !== "closed");
      if (nextTask) selectTask(nextTask);
    }
    try {
      upsertTask(await apiUpdateMonologueTaskStatus(task.id, status));
      setNotice(status === "closed" ? "任务已从标签栏关闭。" : status === "completed" ? "任务已标记完成。" : "任务已恢复为进行中。");
    } catch (err) {
      patchLocalTask(previousTask.id, { status: previousTask.status, updatedAt: previousTask.updatedAt });
      setError(err instanceof Error ? `任务状态同步失败：${err.message}` : "任务状态同步失败");
    }
  }

  async function deleteTask(task: MonologueTask) {
    const ok = window.confirm(`删除任务「${task.title}」？这会删除句子、优化记录和录音，不能撤销。`);
    if (!ok) return;
    const previousTasks = tasks;
    setTasks((current) => current.filter((item) => item.id !== task.id));
    if (selectedTaskId === task.id) {
      const nextTask = previousTasks.find((item) => item.id !== task.id && item.status !== "closed") || previousTasks.find((item) => item.id !== task.id);
      setSelectedTaskId(nextTask?.id || "");
      setSelectedSentenceId(nextTask?.sentences[0]?.id || "");
      setRawTranscript("");
      setFinalTranscript(nextTask?.finalTranscript || "");
    }
    try {
      await apiDeleteMonologueTask(task.id);
      setNotice("任务已删除。");
    } catch (err) {
      setTasks(previousTasks);
      setError(err instanceof Error ? `删除任务失败：${err.message}` : "删除任务失败");
    }
  }

  async function updateTaskDetails(task: MonologueTask, updates: { title?: string; description?: string }) {
    const nextTitle = updates.title?.trim();
    if ("title" in updates && !nextTitle) {
      setError("任务标题不能为空。");
      return;
    }
    const localUpdates: Partial<MonologueTask> = {
      updatedAt: todayIso(),
      ...(updates.title !== undefined ? { title: nextTitle || task.title } : {}),
      ...(updates.description !== undefined ? { description: updates.description.trim() } : {})
    };
    patchLocalTask(task.id, localUpdates);
    try {
      upsertTask(await apiUpdateMonologueTask(task.id, updates));
      setNotice("任务信息已更新。");
    } catch (err) {
      patchLocalTask(task.id, { title: task.title, description: task.description, updatedAt: task.updatedAt });
      setError(err instanceof Error ? `任务信息同步失败：${err.message}` : "任务信息同步失败");
    }
  }

  async function createTask() {
    if (!title.trim()) return;
    setLoading(true);
    setError("");
    try {
      let task = await apiCreateMonologueTask({
        title: title.trim(),
        taskTime: new Date(taskTime).toISOString(),
        tags: tagText.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean),
        description
      });
      task = await apiAddMonologueSentence(task.id);
      upsertTask(task);
      setTitle("");
      setTagText("");
      setDescription("");
      setTaskTime(localDateTimeInputValue());
      setShowCreateTask(false);
      setNotice("独白任务已创建。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "创建任务失败");
    } finally {
      setLoading(false);
    }
  }

  async function addBlock(blockType: "sentence" | "note" | "heading") {
    if (!selectedTask) return;
    setLoading(true);
    try {
      const task = await apiAddMonologueSentence(
        selectedTask.id,
        blockType,
        blockType === "heading" ? "小标题" : blockType === "note" ? "备注" : ""
      );
      upsertTask(task);
      const block = task.sentences[task.sentences.length - 1];
      setSelectedSentenceId(block?.blockType === "sentence" ? block.id : "");
      setRawTranscript("");
      setNotice(blockType === "sentence" ? "已添加句子块。" : blockType === "heading" ? "已添加小标题块。" : "已添加备注块。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "添加块失败");
    } finally {
      setLoading(false);
    }
  }

  async function updateBlock(block: MonologueSentence, updates: { content?: string; targetText?: string; collapsed?: boolean }) {
    if (!selectedTask) return;
    try {
      upsertTask(await apiUpdateMonologueBlock(selectedTask.id, block.id, updates));
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新块失败");
    }
  }

  async function moveBlock(targetBlockId: string) {
    if (!selectedTask || !draggingBlockId) {
      setDraggingBlockId("");
      return;
    }
    if (draggingBlockId === targetBlockId) {
      setDraggingBlockId("");
      return;
    }
    const ids = selectedTask.sentences.map((block) => block.id);
    const fromIndex = ids.indexOf(draggingBlockId);
    const toIndex = ids.indexOf(targetBlockId);
    if (fromIndex < 0 || toIndex < 0) {
      setDraggingBlockId("");
      return;
    }
    ids.splice(toIndex, 0, ids.splice(fromIndex, 1)[0]);
    setDraggingBlockId("");
    try {
      upsertTask(await apiReorderMonologueBlocks(selectedTask.id, ids));
    } catch (err) {
      setError(err instanceof Error ? err.message : "调整顺序失败");
    }
  }

  async function deleteBlock(block: MonologueSentence) {
    if (!selectedTask) return;
    const ok = window.confirm("删除这个块？相关优化履历也会删除。");
    if (!ok) return;
    try {
      const task = await apiDeleteMonologueBlock(selectedTask.id, block.id);
      upsertTask(task);
      if (selectedSentenceId === block.id) {
        setSelectedSentenceId(task.sentences.find((item) => item.blockType === "sentence")?.id || "");
        setRawTranscript("");
      }
      setHiddenSummaryBlockIds((current) => current.filter((id) => id !== block.id));
      setNotice("块已删除。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "删除块失败");
    }
  }

  async function transcribeSentenceRecording() {
    if (!recorder.audioBlob) return;
    setLoading(true);
    setError("");
    try {
      setRawTranscript(await apiTranscribeAudio(recorder.audioBlob, "ja"));
    } catch (err) {
      setError(err instanceof Error ? err.message : "转写失败");
    } finally {
      setLoading(false);
    }
  }

  async function optimizeSentence(block = currentSentence) {
    if (!selectedTask || !block || !rawTranscript.trim()) return;
    setLoading(true);
    setError("");
    try {
      const task = await apiOptimizeMonologueSentence(selectedTask.id, block.id, rawTranscript);
      upsertTask(task);
      setSelectedSentenceId(block.id);
      setNotice("AI 已优化这一句。可以填入文本框后再完成。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "AI 优化失败");
    } finally {
      setLoading(false);
    }
  }

  function fillTranscriptFromAttempt(block: MonologueSentence, attempt: MonologueAttempt) {
    setSelectedSentenceId(block.id);
    setRawTranscript(attempt.improvedText);
    setNotice("已把 AI 优化版本填入转写文本框。");
  }

  async function completeSentenceWithText(block: MonologueSentence, finalText: string) {
    if (!selectedTask || !block || !finalText.trim()) return;
    setLoading(true);
    setCompletingSentenceId(block.id);
    setError("");
    setSentenceAudioError("");
    try {
      const task = await apiCompleteMonologueSentence(selectedTask.id, block.id, finalText.trim());
      upsertTask(task);
      setSelectedSentenceId(block.id);
      setNotice("这一句已完成。可以继续下一句。");
      const recordingBelongsToBlock = !recordingSentenceId || recordingSentenceId === block.id || selectedSentenceId === block.id;
      if (recorder.audioBlob && recordingBelongsToBlock) {
        try {
          upsertTask(await apiSaveMonologueRecording(selectedTask.id, block.id, recorder.audioBlob));
          setNotice("这一句已完成，录音也已保存。");
        } catch (recordingError) {
          setSentenceAudioError(recordingError instanceof Error ? `句子已完成，但录音保存失败：${recordingError.message}` : "句子已完成，但录音保存失败");
        }
      } else if (recorder.audioBlob && recordingSentenceId && recordingSentenceId !== block.id) {
        setSentenceAudioError("句子已完成，但当前录音属于其他句子，所以没有保存到这一句。");
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "完成句子失败");
    } finally {
      setCompletingSentenceId("");
      setLoading(false);
    }
  }

  async function addMonologueExpressionToCorpus(block: MonologueSentence, text: string) {
    const expression = text.trim();
    if (!selectedTask || !expression) return;
    const corpusKey = `${block.id}_${expression}`;
    setAddingCorpusKey(corpusKey);
    setError("");
    try {
      await apiAddCorpusItem({
        chunk: expression,
        meaningZh: selectedTask.title,
        usageScene: "独白打磨中不确定、需要回顾的表达",
        exampleJa: expression,
        masteryStatus: "未练习",
        tags: selectedTask.tags || [],
        status: "active",
        sourceType: "monologue",
        sourceLabel: sourceLabel("monologue"),
        sourceRef: block.id,
        detailStatus: "pending"
      });
      setAddedCorpusKeys((current) => Array.from(new Set([...current, corpusKey])));
      setNotice("已添加corpus，后台会补全解释和例句。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "添加corpus失败");
    } finally {
      setAddingCorpusKey("");
    }
  }

  async function undoCompleteSentence(block: MonologueSentence) {
    if (!selectedTask || !block) return;
    const taskId = selectedTask.id;
    const nextStatus = block.attempts.length ? "optimized" : "draft";
    setUndoingSentenceId(block.id);
    setError("");
    setSentenceAudioError("");
    patchLocalSentence(taskId, block.id, { status: nextStatus });
    setSelectedSentenceId(block.id);
    setRawTranscript(block.finalText || block.attempts[block.attempts.length - 1]?.rawTranscript || "");
    setNotice("已撤销完成状态，可以继续编辑这一句。");
    try {
      const task = await apiUndoCompleteMonologueSentence(taskId, block.id);
      upsertTask(task);
    } catch (err) {
      setError(err instanceof Error ? `已在当前页面撤销，但后端同步失败：${err.message}` : "已在当前页面撤销，但后端同步失败");
    } finally {
      setUndoingSentenceId("");
    }
  }

  async function playSentenceAudio(text: string) {
    const trimmed = text.trim();
    if (!trimmed) return;
    setSentenceAudioError("");
    setSentenceAudioLoadingKey(trimmed);
    try {
      let url = sentenceAudioUrls[trimmed];
      if (!url) {
        url = await apiSpeakText(trimmed);
        setSentenceAudioUrls((current) => ({ ...current, [trimmed]: url }));
      }
      const audio = new Audio(url);
      audio.play();
    } catch (err) {
      setSentenceAudioError(err instanceof Error ? err.message : "句子语音播放失败");
    } finally {
      setSentenceAudioLoadingKey("");
    }
  }

  async function transcribeFinalRecording() {
    if (!finalRecorder.audioBlob) return;
    setLoading(true);
    try {
      setFinalTranscript(await apiTranscribeAudio(finalRecorder.audioBlob, "ja"));
    } catch (err) {
      setError(err instanceof Error ? err.message : "最终录音转写失败");
    } finally {
      setLoading(false);
    }
  }

  async function finalizeTask() {
    if (!selectedTask) return;
    const taskId = selectedTask.id;
    const nextStatus: MonologueTask["status"] = selectedTask.status === "completed" ? "active" : "completed";
    setFinalizingTask(true);
    setError("");
    patchLocalTask(taskId, {
      status: nextStatus,
      finalTranscript,
      updatedAt: todayIso()
    });
    setNotice(nextStatus === "completed" ? "独白任务已完成。" : "已撤销任务完成状态。");
    try {
      if (nextStatus === "completed") {
        upsertTask(await apiFinalizeMonologue(taskId, finalTranscript, hiddenSummaryBlockIds));
      } else {
        upsertTask(await apiUpdateMonologueTaskStatus(taskId, "active"));
      }
    } catch (err) {
      setError(err instanceof Error ? `已在当前页面更新状态，但后端同步失败：${err.message}` : "已在当前页面更新状态，但后端同步失败");
    } finally {
      setFinalizingTask(false);
    }
  }

  function transcriptPreviewBlocks(task: MonologueTask, hiddenBlockIds: string[] = []) {
    const hiddenIds = new Set(hiddenBlockIds);
    return task.sentences.map((block) => {
      if (block.blockType === "heading") {
        return {
          id: block.id,
          type: "heading",
          text: block.content.trim() || "未命名小标题",
          hidden: hiddenIds.has(block.id)
        };
      }
      if (block.blockType === "note") {
        return {
          id: block.id,
          type: "note",
          text: block.content.trim() || "空备注",
          hidden: hiddenIds.has(block.id)
        };
      }
      return {
        id: block.id,
        type: "sentence",
        text: (block.finalText || block.attempts[block.attempts.length - 1]?.rawTranscript || block.content || "").trim(),
        status: block.status,
        hidden: hiddenIds.has(block.id)
      };
    }).filter((item) => item.type !== "sentence" || item.text);
  }

  function toggleSummaryBlock(blockId: string) {
    setHiddenSummaryBlockIds((current) => (
      current.includes(blockId) ? current.filter((id) => id !== blockId) : [...current, blockId]
    ));
  }

  function blockIcon(blockType: MonologueSentence["blockType"]) {
    if (blockType === "heading") return "T";
    if (blockType === "note") return "i";
    return "♪";
  }

  function blockLabel(block: MonologueSentence) {
    if (block.blockType === "heading") return "小标题";
    if (block.blockType === "note") return "备注";
    const latestAttempt = block.attempts[block.attempts.length - 1];
    const titleText = block.finalText || latestAttempt?.rawTranscript || latestAttempt?.improvedText || block.content;
    return titleText ? titleText : `自定义句子 ${block.position}`;
  }

  function iconLabel(icon: string, text: string) {
    return (
      <span className="inline-flex items-center gap-1.5">
        <span aria-hidden="true">{icon}</span>
        <span>{text}</span>
      </span>
    );
  }

  return (
    <section>
      <div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
        <div>
          <div className="mb-3 text-sm font-bold uppercase tracking-[0.35em] text-things-600">Monologue Studio</div>
          <h2 className="text-4xl font-bold tracking-tight text-ink md:text-5xl">独白打磨</h2>
          <p className="mt-4 max-w-3xl text-lg leading-8 text-slate-600">适合自我介绍、面试回答、发表稿等连续口语内容。按句打磨，最后合成完整文本。</p>
        </div>
        <div className="flex shrink-0 flex-wrap gap-2">
          <SecondaryButton onClick={() => setShowTaskManager(!showTaskManager)}>{iconLabel("☰", showTaskManager ? "返回练习" : "任务管理")}</SecondaryButton>
          <SecondaryButton onClick={() => setShowCreateTask(!showCreateTask)}>{iconLabel(showCreateTask ? "⌃" : "+", showCreateTask ? "收起新建" : "新建任务")}</SecondaryButton>
        </div>
      </div>
      {notice && <p className="mb-4 rounded-2xl bg-things-50 px-4 py-3 text-sm font-semibold text-things-800">{notice}</p>}
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      {sentenceAudioError && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{sentenceAudioError}</p>}

      <div className="grid gap-4">
        <div className="rounded-2xl border border-line bg-white p-3">
          <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
            <div className="flex gap-2 overflow-x-auto pb-1">
              {tabTasks.map((task) => (
                <div
                  key={task.id}
                  className={`shrink-0 rounded-xl px-4 py-2 text-left text-sm font-semibold transition ${
                    selectedTask?.id === task.id
                      ? task.status === "completed" ? "bg-emerald-600 text-white shadow-soft" : "bg-things-600 text-white shadow-soft"
                      : task.status === "completed" ? "bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "bg-slate-50 text-slate-600 hover:bg-things-50 hover:text-things-800"
                  }`}
                >
                  <div className="flex items-start gap-2">
                    <button onClick={() => selectTask(task)} className="min-w-0 text-left">
                      <span className="block max-w-40 truncate">{task.status === "completed" ? "✓ " : ""}{task.title}</span>
                      <span className={`mt-0.5 block text-xs ${selectedTask?.id === task.id ? "text-white/80" : task.status === "completed" ? "text-emerald-500" : "text-slate-400"}`}>{task.status === "completed" ? "已完成" : "进行中"} · {task.sentences.length} 块</span>
                    </button>
                    <button
                      onClick={() => updateTaskStatus(task, "closed")}
                      className={`rounded-full px-1.5 text-xs ${selectedTask?.id === task.id ? "text-white/80 hover:bg-white/15" : "text-slate-400 hover:bg-white hover:text-slate-600"}`}
                      title="从标签栏关闭"
                    >
                      ×
                    </button>
                  </div>
                </div>
              ))}
              {!tabTasks.length && <div className="rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-500">标签栏没有打开的任务</div>}
            </div>
          </div>
          {showCreateTask && (
            <div className="mt-4 grid gap-3 border-t border-line pt-4 md:grid-cols-2">
              <input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如：自我介绍" />
              <input className="input" type="datetime-local" value={taskTime} onChange={(event) => setTaskTime(event.target.value)} />
              <input className="input" value={tagText} onChange={(event) => setTagText(event.target.value)} placeholder="标签：面试, 自我介绍" />
              <textarea className="textarea min-h-24 md:col-span-2" value={description} onChange={(event) => setDescription(event.target.value)} placeholder="详细描述：对象、场景、时长、语气要求..." />
              <div className="md:col-span-2">
                <PrimaryButton onClick={createTask} disabled={loading || !title.trim()}>{iconLabel("+", "创建任务")}</PrimaryButton>
              </div>
            </div>
          )}
        </div>

        {showTaskManager ? (
          <div className="rounded-2xl border border-line bg-white p-5">
            <div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
              <div>
                <div className="text-sm font-semibold text-things-700">任务管理</div>
                <h3 className="mt-1 text-2xl font-semibold text-ink">独白任务列表</h3>
                <p className="mt-1 text-sm text-slate-500">管理任务状态、标签栏开关和删除。点击任务标题进入练习页。</p>
              </div>
              <div className="text-xs font-semibold text-slate-400">
                {tasks.filter((task) => task.status === "active").length} 进行中 · {tasks.filter((task) => task.status === "completed").length} 已完成 · {tasks.filter((task) => task.status === "closed").length} 已关闭
              </div>
            </div>
            {tasks.length ? (
              <div className="grid gap-3 md:grid-cols-2">
                {tasks.map((task) => {
                  const sentenceCount = task.sentences.filter((block) => block.blockType === "sentence").length;
                  const doneCount = task.sentences.filter((block) => block.blockType === "sentence" && block.status === "done").length;
                  const isCurrent = selectedTask?.id === task.id;
                  return (
                    <div key={`manager_${task.id}`} className={`rounded-xl border px-4 py-3 ${isCurrent ? "border-things-400 bg-things-50" : "border-line bg-slate-50"}`}>
                      <button onClick={() => selectTask(task)} className="flex w-full items-start justify-between gap-3 text-left">
                        <div className="min-w-0">
                          <div className="truncate text-sm font-semibold text-ink">{task.title}</div>
                          <div className="mt-1 text-xs font-semibold text-slate-400">{doneCount}/{sentenceCount || 0} 句完成 · {formatDateTime(task.updatedAt)}</div>
                        </div>
                        <span className={`shrink-0 rounded-full px-2 py-1 text-xs font-semibold ${
                          task.status === "completed" ? "bg-emerald-100 text-emerald-700" : task.status === "closed" ? "bg-slate-200 text-slate-600" : "bg-things-100 text-things-700"
                        }`}>
                          {task.status === "completed" ? "已完成" : task.status === "closed" ? "已关闭" : "进行中"}
                        </span>
                      </button>
                      <div className="mobile-action-grid mt-3">
                        {task.status === "closed" ? (
                          <SecondaryButton onClick={() => updateTaskStatus(task, "active")}>{iconLabel("↗", "打开到标签栏")}</SecondaryButton>
                        ) : (
                          <SecondaryButton onClick={() => updateTaskStatus(task, "closed")}>{iconLabel("×", "关闭标签")}</SecondaryButton>
                        )}
                        <SecondaryButton onClick={() => updateTaskStatus(task, task.status === "completed" ? "active" : "completed")}>
                          {iconLabel("✓", task.status === "completed" ? "撤销完成" : "标记完成")}
                        </SecondaryButton>
                        <button onClick={() => deleteTask(task)} className="rounded-xl bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-100">
                          {iconLabel("×", "删除")}
                        </button>
                      </div>
                    </div>
                  );
                })}
              </div>
            ) : (
              <Locked title="还没有任务" text="先新建一个独白任务，再回到这里管理状态。" />
            )}
          </div>
        ) : !selectedTask ? (
          <Locked title="先创建一个独白任务" text="例如自我介绍、项目介绍、面试回答。创建后就可以逐句录音和优化。" />
        ) : (
          <main className="grid gap-4">
            <div className="sticky top-3 z-20 rounded-2xl border border-line bg-white/95 p-3 shadow-hairline backdrop-blur">
              <div className="flex flex-wrap items-center gap-2">
                <PrimaryButton onClick={() => addBlock("sentence")} disabled={loading}>{iconLabel("♪", "句子块")}</PrimaryButton>
                <SecondaryButton onClick={() => addBlock("heading")} disabled={loading}>{iconLabel("T", "小标题块")}</SecondaryButton>
                <SecondaryButton onClick={() => addBlock("note")} disabled={loading}>{iconLabel("i", "备注块")}</SecondaryButton>
                <SecondaryButton onClick={finalizeTask} disabled={finalizingTask}>{iconLabel("✓", finalizingTask ? "处理中..." : selectedTask.status === "completed" ? "已完成 · 点击撤销" : "完成并生成全文")}</SecondaryButton>
              </div>
            </div>

            <div className="rounded-2xl border border-line bg-white p-5">
              <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
                <div className="min-w-0 flex-1">
                  <input
                    className="block w-full rounded-lg border border-transparent bg-transparent px-0 py-1 text-2xl font-semibold text-ink outline-none transition hover:border-line hover:bg-slate-50 hover:px-2 focus:border-things-300 focus:bg-white focus:px-2 md:text-3xl"
                    value={selectedTask.title}
                    onChange={(event) => patchLocalTask(selectedTask.id, { title: event.target.value })}
                    onBlur={(event) => updateTaskDetails(selectedTask, { title: event.target.value })}
                    aria-label="任务标题"
                  />
                  <input
                    className="mt-1 block w-full rounded-lg border border-transparent bg-transparent px-0 py-1 text-sm text-slate-500 outline-none transition hover:border-line hover:bg-slate-50 hover:px-2 focus:border-things-300 focus:bg-white focus:px-2"
                    value={selectedTask.description}
                    onChange={(event) => patchLocalTask(selectedTask.id, { description: event.target.value })}
                    onBlur={(event) => updateTaskDetails(selectedTask, { description: event.target.value })}
                    placeholder="暂无描述"
                    aria-label="任务描述"
                  />
                  <div className="mt-2 flex flex-wrap gap-2 text-xs font-semibold text-slate-400">
                    <span>{formatDateTime(selectedTask.taskTime)}</span>
                    <span>{selectedTask.sentences.length} 句</span>
                    {selectedTask.tags.map((tag) => <span key={tag} className="rounded-full bg-things-50 px-2 py-0.5 text-things-700"># {tag}</span>)}
                  </div>
                </div>
              </div>
            </div>

            <div className="grid gap-3">
              {selectedTask.sentences.map((block) => {
                const isSelectedSentence = block.id === selectedSentenceId;
                const attempt = block.attempts[block.attempts.length - 1];
                return (
                  <section
                    key={block.id}
                    draggable={block.collapsed}
                    onDragStart={(event) => {
                      if (!block.collapsed) {
                        event.preventDefault();
                        return;
                      }
                      setDraggingBlockId(block.id);
                    }}
                    onDragEnd={() => setDraggingBlockId("")}
                    onDragOver={(event) => event.preventDefault()}
                    onDrop={() => moveBlock(block.id)}
                    className={`rounded-2xl border bg-white p-4 shadow-hairline ${block.collapsed ? "cursor-grab" : ""} ${draggingBlockId === block.id ? "border-things-400 opacity-60" : "border-line"}`}
                  >
                    <div className="sticky top-20 z-10 flex items-center justify-between gap-3 rounded-xl bg-white/95 py-1 backdrop-blur">
                      <button
                        onClick={() => updateBlock(block, { collapsed: !block.collapsed })}
                        className="grid min-w-0 flex-1 grid-cols-[28px_minmax(0,1fr)] items-center gap-2 text-left text-sm font-semibold text-things-700"
                      >
                        <span className="grid h-7 w-7 place-items-center rounded-lg bg-things-50 text-things-700">{blockIcon(block.blockType)}</span>
                        <span className="min-w-0">
                          <span className="block truncate">{blockLabel(block)}</span>
                          <span className="mt-1 flex flex-wrap gap-1">
                            <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{block.collapsed ? "可拖动" : "编辑中"}</span>
                            {block.blockType === "sentence" && <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">{block.status === "done" ? "已确认" : block.status === "optimized" ? "已优化" : "草稿"}</span>}
                          </span>
                        </span>
                      </button>
                      <div className="flex shrink-0 items-center gap-2">
                        <SecondaryButton onClick={() => updateBlock(block, { collapsed: !block.collapsed })}>{iconLabel(block.collapsed ? "▾" : "▴", block.collapsed ? "展开" : "折叠")}</SecondaryButton>
                        <button
                          onClick={() => deleteBlock(block)}
                          className="h-10 rounded-xl bg-red-50 px-3 text-xs font-semibold text-red-600 hover:bg-red-100"
                        >
                          {iconLabel("×", "删除")}
                        </button>
                      </div>
                    </div>

                    {!block.collapsed && block.blockType === "heading" && (
                      <input
                        className="input mt-3 text-lg font-semibold"
                        defaultValue={block.content}
                        onBlur={(event) => updateBlock(block, { content: event.target.value })}
                        placeholder="输入小标题"
                      />
                    )}

                    {!block.collapsed && block.blockType === "note" && (
                      <textarea
                        className="textarea mt-3 min-h-24"
                        defaultValue={block.content}
                        onBlur={(event) => updateBlock(block, { content: event.target.value })}
                        placeholder="输入备注、转场提示或想法"
                      />
                    )}

                    {!block.collapsed && block.blockType === "sentence" && (
                      <div className="mt-3 grid gap-3">
                        <label className="grid gap-2 rounded-xl bg-slate-50 p-3">
                          <span className="flex flex-col gap-1 text-sm font-semibold text-slate-700 sm:flex-row sm:items-center sm:justify-between">
                            <span>目标文本</span>
                            <span className="text-xs font-medium text-slate-400">可选，有目标时 AI 会尽量靠近它</span>
                          </span>
                          <textarea
                            className="textarea min-h-20 bg-white"
                            defaultValue={block.targetText || ""}
                            onFocus={() => setSelectedSentenceId(block.id)}
                            onBlur={(event) => updateBlock(block, { targetText: event.target.value })}
                            placeholder="例如：面试稿里的标准答案、想模仿的日语原句。没有目标时留空。"
                          />
                        </label>
                        <AudioCapturePanel
                          recorder={recorder}
                          transcriber={transcriber}
                          loading={loading}
                          whisperError=""
                          onStartRecording={() => {
                            setSelectedSentenceId(block.id);
                            setRecordingSentenceId(block.id);
                          }}
                          onTranscribe={() => {
                            setSelectedSentenceId(block.id);
                            setRecordingSentenceId(block.id);
                            transcribeSentenceRecording();
                          }}
                        />
                        <label className="grid gap-2">
                          <span className="text-sm font-semibold text-slate-700">转写文本</span>
                          <textarea
                            className="textarea min-h-28"
                            value={isSelectedSentence ? rawTranscript : ""}
                            onFocus={() => setSelectedSentenceId(block.id)}
                            onChange={(event) => {
                              setSelectedSentenceId(block.id);
                              setRawTranscript(event.target.value);
                            }}
                            placeholder={block.finalText || "录音转写会填到这里，也可以手动输入。"}
                          />
                        </label>
                        <div className="mobile-action-grid">
                          <PrimaryButton onClick={() => optimizeSentence(block)} disabled={loading || !isSelectedSentence || !rawTranscript.trim()}>{iconLabel("✦", loading ? "处理中..." : "AI 优化")}</PrimaryButton>
                          <PrimaryButton
                            onClick={() => completeSentenceWithText(block, rawTranscript)}
                            disabled={loading || !isSelectedSentence || !rawTranscript.trim() || block.status === "done"}
                          >
                            {iconLabel("✓", completingSentenceId === block.id ? "完成中..." : block.status === "done" ? "已完成" : "完成")}
                          </PrimaryButton>
                          <SecondaryButton
                            onClick={() => playSentenceAudio(rawTranscript)}
                            disabled={!isSelectedSentence || !rawTranscript.trim() || sentenceAudioLoadingKey === rawTranscript.trim()}
                          >
                            {iconLabel("▶", sentenceAudioLoadingKey === rawTranscript.trim() ? "生成中..." : "获取范例发音")}
                          </SecondaryButton>
                        </div>
                        {attempt && (
                          <div className="rounded-2xl bg-slate-50 p-4">
                            <Info label="AI 优化版本" value={attempt.improvedText} />
                            <Info label="修改说明" value={attempt.explanation} />
                            {(attempt.pronunciationTips || []).length > 0 && (
                              <div className="mt-3 rounded-xl bg-white p-3 shadow-hairline">
                                <div className="text-sm font-semibold text-things-700">发音技巧</div>
                                <div className="mt-2 grid gap-2">
                                  {(attempt.pronunciationTips || []).map((tip) => (
                                    <p key={tip} className="rounded-lg bg-things-50 px-3 py-2 text-sm leading-7 text-slate-700">{tip}</p>
                                  ))}
                                </div>
                              </div>
                            )}
                            <div className="mobile-action-grid mt-3">
                              <SecondaryButton onClick={() => fillTranscriptFromAttempt(block, attempt)}>{iconLabel("↙", "填入文本框")}</SecondaryButton>
                              <SecondaryButton
                                onClick={() => addMonologueExpressionToCorpus(block, attempt.improvedText)}
                                disabled={addingCorpusKey === `${block.id}_${attempt.improvedText.trim()}` || addedCorpusKeys.includes(`${block.id}_${attempt.improvedText.trim()}`)}
                              >
                                {iconLabel("+", addedCorpusKeys.includes(`${block.id}_${attempt.improvedText.trim()}`) ? "已添加corpus" : addingCorpusKey === `${block.id}_${attempt.improvedText.trim()}` ? "添加中..." : "添加corpus")}
                              </SecondaryButton>
                            </div>
                          </div>
                        )}
                        {block.status === "done" && block.finalText && (
                          <div className="rounded-xl bg-things-50 p-3 text-sm leading-7 text-things-900">
                            <div>{block.finalText}</div>
                            <div className="mt-3 grid gap-2">
                              {block.recordingUrl ? (
                                <audio controls src={block.recordingUrl} className="w-full" />
                              ) : (
                                <div className="rounded-lg bg-white/70 px-3 py-2 text-xs font-semibold text-things-700">没有保存到这一句的录音。</div>
                              )}
                              {block.status === "done" && (
                                <div className="mobile-action-grid">
                                  <SecondaryButton
                                    onClick={() => addMonologueExpressionToCorpus(block, block.finalText)}
                                    disabled={addingCorpusKey === `${block.id}_${block.finalText.trim()}` || addedCorpusKeys.includes(`${block.id}_${block.finalText.trim()}`)}
                                  >
                                    {iconLabel("+", addedCorpusKeys.includes(`${block.id}_${block.finalText.trim()}`) ? "已添加corpus" : addingCorpusKey === `${block.id}_${block.finalText.trim()}` ? "添加中..." : "添加corpus")}
                                  </SecondaryButton>
                                  <SecondaryButton onClick={() => undoCompleteSentence(block)} disabled={undoingSentenceId === block.id}>
                                    {iconLabel("↶", undoingSentenceId === block.id ? "撤销中..." : "撤销完成")}
                                  </SecondaryButton>
                                </div>
                              )}
                            </div>
                          </div>
                        )}
                      </div>
                    )}
                  </section>
                );
              })}
            </div>

            <div className="rounded-2xl border border-line bg-white p-5">
              <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
                  <div>
                    <div className="text-sm font-semibold text-things-700">文稿预览</div>
                  <p className="mt-1 text-sm text-slate-500">按上方块顺序整理标题、备注和每个句子的转写版本；隐藏的块不会写入最终总结。</p>
                  </div>
                {selectedTask.status === "completed" && <span className="w-fit rounded-full bg-things-50 px-3 py-1.5 text-xs font-semibold text-things-700">已完成</span>}
              </div>
              <div className="mt-4 grid gap-3">
                {(() => {
                  const previewBlocks = transcriptPreviewBlocks(selectedTask, hiddenSummaryBlockIds);
                  let sentenceIndex = 0;
                  return previewBlocks.length ? previewBlocks.map((item) => {
                    if (item.type === "heading") {
                      return (
                        <div key={item.id} className={`border-l-4 px-4 py-3 ${item.hidden ? "border-slate-300 bg-slate-50 opacity-60" : "border-things-500 bg-things-50"}`}>
                          <div className="flex items-center justify-between gap-3">
                            <div className="text-xs font-semibold uppercase text-things-600">Heading</div>
                            <button type="button" onClick={() => toggleSummaryBlock(item.id)} className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 shadow-hairline hover:bg-slate-100">
                              {item.hidden ? "显示" : "隐藏"}
                            </button>
                          </div>
                          {item.hidden ? (
                            <div className="mt-1 rounded-lg bg-white/70 px-3 py-2 text-sm font-semibold text-slate-400">已隐藏</div>
                          ) : (
                            <div className="mt-1 text-lg font-semibold text-ink">{item.text}</div>
                          )}
                        </div>
                      );
                    }
                    if (item.type === "note") {
                      return (
                        <div key={item.id} className={`rounded-xl border border-dashed border-line bg-slate-50 px-4 py-3 ${item.hidden ? "opacity-60" : ""}`}>
                          <div className="flex items-center justify-between gap-3">
                            <div className="text-xs font-semibold uppercase text-slate-400">Note</div>
                            <button type="button" onClick={() => toggleSummaryBlock(item.id)} className="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 shadow-hairline hover:bg-slate-100">
                              {item.hidden ? "显示" : "隐藏"}
                            </button>
                          </div>
                          {item.hidden ? (
                            <div className="mt-1 rounded-lg bg-white px-3 py-2 text-sm font-semibold text-slate-400 shadow-hairline">已隐藏</div>
                          ) : (
                            <div className="mt-1 whitespace-pre-wrap text-sm leading-6 text-slate-600">{item.text}</div>
                          )}
                        </div>
                      );
                    }
                    sentenceIndex += 1;
                    return (
                      <div key={item.id} className={`rounded-xl bg-white px-4 py-3 shadow-hairline ${item.hidden ? "opacity-60" : ""}`}>
                        <div className="mb-1 flex items-center justify-between gap-3">
                          <div className="text-xs font-semibold text-slate-400">句子 {sentenceIndex}{item.status !== "done" ? " · 未完成" : ""}</div>
                          <button type="button" onClick={() => toggleSummaryBlock(item.id)} className="rounded-full bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100">
                            {item.hidden ? "显示" : "隐藏"}
                          </button>
                        </div>
                        {item.hidden ? (
                          <div className="rounded-lg bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-400">已隐藏</div>
                        ) : (
                          <div className="whitespace-pre-wrap text-sm leading-7 text-ink">{item.text}</div>
                        )}
                      </div>
                    );
                  }) : (
                    <div className="rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-500">还没有可整理的文稿。请先完成至少一个句子，或添加标题/备注块。</div>
                  );
                })()}
              </div>

              <div className="mt-5 rounded-2xl border border-line bg-slate-50 p-4">
                <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
                  <div>
                    <div className="text-sm font-semibold text-things-700">最终录音</div>
                    <p className="mt-1 text-sm leading-6 text-slate-600">录完整独白后转写到下方，用来和整理好的文稿对照。</p>
                  </div>
                  <div className="mobile-action-grid">
                    <PrimaryButton
                      onClick={() => finalRecorder.start()}
                      disabled={finalRecorder.isRecording || finalizingTask}
                    >
                      {iconLabel("●", "开始录音")}
                    </PrimaryButton>
                    <SecondaryButton onClick={finalRecorder.stop} disabled={!finalRecorder.isRecording}>{iconLabel("■", "停止录音")}</SecondaryButton>
                    <PrimaryButton onClick={transcribeFinalRecording} disabled={loading || !finalRecorder.audioBlob}>{iconLabel("⌁", loading ? "转写中..." : "转写录音")}</PrimaryButton>
                  </div>
                </div>
                <div className="mt-4 grid gap-3">
                  {finalRecorder.isRecording && <span className="w-fit rounded-full bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">录音中</span>}
                  {finalRecorder.audioUrl ? <audio controls src={finalRecorder.audioUrl} className="w-full" /> : <div className="rounded-xl bg-white px-4 py-3 text-sm text-slate-500">还没有最终录音。</div>}
                  {finalRecorder.error && <p className="text-sm text-red-600">{finalRecorder.error}</p>}
                  <label className="grid gap-2">
                    <span className="text-sm font-semibold text-slate-700">最终录音转写</span>
                    <textarea className="textarea min-h-28" value={finalTranscript} onChange={(event) => setFinalTranscript(event.target.value)} placeholder="最终录音转写会填到这里，也可以手动输入。" />
                  </label>
                </div>
              </div>

              <div className="mobile-action-grid mt-4">
                <button
                  onClick={finalizeTask}
                  disabled={finalizingTask}
                  className={`rounded-xl px-4 py-2.5 text-sm font-semibold shadow-lg transition disabled:cursor-not-allowed disabled:shadow-none ${
                    selectedTask.status === "completed"
                      ? "bg-emerald-600 text-white shadow-emerald-500/20"
                      : "bg-things-500 text-white shadow-things-500/20 hover:bg-things-600 disabled:bg-slate-300"
                  }`}
                >
                  {iconLabel("✓", finalizingTask ? "处理中..." : selectedTask.status === "completed" ? "已完成 · 点击撤销" : "完成独白任务")}
                </button>
              </div>
            </div>
          </main>
        )}
      </div>
    </section>
  );
}

function PodcastLearning({
  playbackRate,
  onPlaybackRateChange,
  onParseTaskCreated
}: {
  playbackRate: number;
  onPlaybackRateChange: (rate: number) => void;
  onParseTaskCreated: (task: ParseTask) => void;
}) {
  const [episodes, setEpisodes] = useState<PodcastEpisode[]>([]);
  const [selectedEpisode, setSelectedEpisode] = useState<PodcastEpisode | null>(null);
  const [selectedSentenceId, setSelectedSentenceId] = useState("");
  const [difficultWords, setDifficultWords] = useState<DifficultWordMap>({});
  const [url, setUrl] = useState("");
  const [title, setTitle] = useState("");
  const [tagText, setTagText] = useState("");
  const [metadataPreview, setMetadataPreview] = useState<PodcastMetadata | null>(null);
  const [metadataLoading, setMetadataLoading] = useState(false);
  const [language, setLanguage] = useState("ja");
  const [file, setFile] = useState<File | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  const [showImportModal, setShowImportModal] = useState(false);
  const [showManageModal, setShowManageModal] = useState(false);
  const [episodeTitleDraft, setEpisodeTitleDraft] = useState("");
  const [episodeTagDraft, setEpisodeTagDraft] = useState("");
  const [selectedPodcastTags, setSelectedPodcastTags] = useState<string[]>([]);
  const [sentenceFilter, setSentenceFilter] = useState<"all" | "seen" | "learning" | "mastered" | "favorite">("all");
  const [seenSentenceIds, setSeenSentenceIds] = useState<Record<string, string[]>>({});
  const [podcastAutoPlay, setPodcastAutoPlay] = useState(() => localStorage.getItem("pjct_podcast_auto_play") === "1");
  const [podcastAutoNext, setPodcastAutoNext] = useState(() => localStorage.getItem("pjct_podcast_auto_next") === "1");
  const [podcastTopTab, setPodcastTopTab] = useState<"library" | "episode">("library");
  const [podcastPanel, setPodcastPanel] = useState<"sentences" | "practice">("practice");

  async function refreshEpisodes() {
    setError("");
    try {
      const next = await apiGetPodcastEpisodes();
      setEpisodes(next);
      if (!selectedEpisode && next[0]) {
        openEpisode(next[0].id);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取播客列表失败");
    }
  }

  async function refreshDifficultWords() {
    try {
      setDifficultWords(await apiGetPodcastDifficultWords());
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取播客难词失败");
    }
  }

  async function openEpisode(episodeId: string) {
    setError("");
    try {
      const episode = await apiGetPodcastEpisode(episodeId);
      setSelectedEpisode(episode);
      const nextId = episode.sentences.some((sentence) => sentence.id === selectedSentenceId) ? selectedSentenceId : episode.sentences[0]?.id || "";
      chooseSentence(episode, nextId);
      setPodcastTopTab("episode");
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取播客失败");
    }
  }

  useEffect(() => {
    refreshEpisodes();
    refreshDifficultWords();
  }, []);

  useEffect(() => {
    localStorage.setItem("pjct_podcast_auto_play", podcastAutoPlay ? "1" : "0");
  }, [podcastAutoPlay]);

  useEffect(() => {
    localStorage.setItem("pjct_podcast_auto_next", podcastAutoNext ? "1" : "0");
  }, [podcastAutoNext]);

  function parsePodcastTags(text: string): string[] {
    return Array.from(new Set(text.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean)));
  }

  async function loadPodcastMetadata(nextUrl = url) {
    if (!nextUrl.trim()) return;
    setMetadataLoading(true);
    setError("");
    try {
      const metadata = await apiGetPodcastMetadata(nextUrl.trim());
      setMetadataPreview(metadata);
      if (!title.trim() && metadata.title) setTitle(metadata.title);
      const metadataTags = metadata.tags || [];
      const existingTags = parsePodcastTags(tagText);
      if (metadataTags.length) setTagText(Array.from(new Set([...existingTags, ...metadataTags])).join(", "));
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取播客信息失败");
    } finally {
      setMetadataLoading(false);
    }
  }

  useEffect(() => {
    if (!url.includes("podcasts.apple.com")) return;
    const timeoutId = window.setTimeout(() => {
      loadPodcastMetadata(url);
    }, 650);
    return () => window.clearTimeout(timeoutId);
  }, [url]);

  async function importUrl() {
    if (!url.trim()) return;
    setLoading(true);
    setError("");
    setNotice("");
    try {
      const task = await apiImportPodcastUrl({ url: url.trim(), title: title.trim(), language, tags: parsePodcastTags(tagText) });
      setShowImportModal(false);
      onParseTaskCreated(task);
      setUrl("");
      setTitle("");
      setTagText("");
      setMetadataPreview(null);
      setNotice("播客导入任务已创建，可在解析任务里查看进度。");
      refreshEpisodes();
    } catch (err) {
      setError(err instanceof Error ? err.message : "导入播客 URL 失败");
    } finally {
      setLoading(false);
    }
  }

  async function uploadFile() {
    if (!file) return;
    setLoading(true);
    setError("");
    setNotice("");
    try {
      const task = await apiUploadPodcast(file, title.trim() || file.name, language, parsePodcastTags(tagText));
      setShowImportModal(false);
      onParseTaskCreated(task);
      setFile(null);
      setTitle("");
      setTagText("");
      setMetadataPreview(null);
      setNotice("播客上传任务已创建，可在解析任务里查看进度。");
      refreshEpisodes();
    } catch (err) {
      setError(err instanceof Error ? err.message : "上传播客失败");
    } finally {
      setLoading(false);
    }
  }

  function updateSentence(nextSentence: PodcastSentence) {
    setSelectedEpisode((current) => current ? {
      ...current,
      sentences: current.sentences.map((sentence) => sentence.id === nextSentence.id ? nextSentence : sentence)
    } : current);
  }

  function chooseSentence(episode: PodcastEpisode | null, sentenceId: string) {
    if (!episode || !sentenceId) return;
    setSelectedSentenceId(sentenceId);
    setSeenSentenceIds((current) => {
      const currentIds = current[episode.id] || [];
      return currentIds.includes(sentenceId) ? current : { ...current, [episode.id]: [...currentIds, sentenceId] };
    });
  }

  function openSentencePractice(episode: PodcastEpisode, sentenceId: string) {
    chooseSentence(episode, sentenceId);
    setPodcastPanel("practice");
  }

  async function saveEpisodeTitle() {
    if (!selectedEpisode || !episodeTitleDraft.trim()) return;
    setLoading(true);
    setError("");
    try {
      const updated = await apiUpdatePodcastEpisode(selectedEpisode.id, { title: episodeTitleDraft.trim(), tags: parsePodcastTags(episodeTagDraft) });
      setSelectedEpisode(updated);
      setEpisodes((current) => current.map((episode) => episode.id === updated.id ? { ...episode, ...updated, sentenceCount: updated.sentences.length } : episode));
      setShowManageModal(false);
      setNotice("播客信息已更新。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新播客失败");
    } finally {
      setLoading(false);
    }
  }

  async function deletePodcastEpisodeFromList(episode: PodcastEpisode) {
    if (!window.confirm(`删除播客「${episode.title}」？已解析的句子和音频会从列表中移除。`)) return;
    setLoading(true);
    setError("");
    try {
      await apiDeletePodcastEpisode(episode.id);
      setEpisodes((current) => current.filter((item) => item.id !== episode.id));
      if (selectedEpisode?.id === episode.id) {
        setSelectedEpisode(null);
        setSelectedSentenceId("");
        setPodcastTopTab("library");
      }
      setShowManageModal(false);
      setNotice("播客已删除。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "删除播客失败");
    } finally {
      setLoading(false);
    }
  }

  async function deleteEpisode() {
    if (!selectedEpisode) return;
    await deletePodcastEpisodeFromList(selectedEpisode);
  }

  const selectedSentence = selectedEpisode?.sentences.find((sentence) => sentence.id === selectedSentenceId) || selectedEpisode?.sentences[0] || null;
  const masteredCount = selectedEpisode?.sentences.filter((sentence) => sentence.mastered).length || 0;
  const favoriteCount = selectedEpisode?.sentences.filter((sentence) => sentence.favorite).length || 0;
  const allPodcastTags = Array.from(new Set(episodes.flatMap((episode) => episode.tags || []))).sort((a, b) => a.localeCompare(b));
  const filteredEpisodes = selectedPodcastTags.length === 0
    ? episodes
    : episodes.filter((episode) => selectedPodcastTags.every((tag) => (episode.tags || []).includes(tag)));
  const seenIds = selectedEpisode ? (seenSentenceIds[selectedEpisode.id] || []) : [];
  const filteredSentences = selectedEpisode ? selectedEpisode.sentences.filter((sentence) => {
    if (sentenceFilter === "seen") return seenIds.includes(sentence.id) || sentence.position < (selectedSentence?.position || 1);
    if (sentenceFilter === "learning") return !sentence.mastered;
    if (sentenceFilter === "mastered") return sentence.mastered;
    if (sentenceFilter === "favorite") return sentence.favorite;
    return true;
  }) : [];

  return (
    <section>
      <div className="mb-0 flex flex-col gap-3 md:mb-4 md:flex-row md:items-start md:justify-between">
        <div className="hidden md:block">
          <Header eyebrow="Podcast Learning" title="播客学习" description="选择已解析播客后按句精听；新播客可从右侧导入入口创建后台任务。" />
        </div>
        <div className="hidden md:block">
          <PrimaryButton onClick={() => setShowImportModal(true)}>导入播客</PrimaryButton>
        </div>
      </div>
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      {notice && <p className="mb-4 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{notice}</p>}

      <div className="rounded-2xl border border-line bg-white p-3 md:p-4">
        <div className="flex flex-col gap-3">
          <div className="hidden flex-col gap-3 md:flex md:flex-row md:items-end md:justify-between">
            <ModuleTabs
              themeKey="podcast"
              active={podcastTopTab}
              onChange={setPodcastTopTab}
              tabs={[
                { key: "library", label: "播客列表", icon: "列" },
                { key: "episode", label: "当前播客", icon: "♫", disabled: !selectedEpisode }
              ]}
            />
          <div className="mobile-action-grid">
            <SecondaryButton onClick={refreshEpisodes}>刷新</SecondaryButton>
            <SecondaryButton onClick={() => setShowImportModal(true)}>新导入</SecondaryButton>
          </div>
          </div>
        </div>

        {podcastTopTab === "library" && (
          <div className="mt-0 md:mt-4">
            <div className="mb-3 flex items-center justify-between gap-3 md:hidden">
              <div>
                <div className="text-base font-semibold text-ink">播客列表</div>
                <div className="mt-0.5 text-xs font-semibold text-slate-400">{filteredEpisodes.length} 个播客</div>
              </div>
              <PrimaryButton size="sm" onClick={() => setShowImportModal(true)}>导入</PrimaryButton>
            </div>
            {allPodcastTags.length > 0 && (
              <div className="mb-3 flex flex-wrap gap-2 rounded-xl bg-slate-50 p-2">
                <FilterPill active={selectedPodcastTags.length === 0} onClick={() => setSelectedPodcastTags([])}>全部标签</FilterPill>
                {allPodcastTags.map((tag) => (
                  <FilterPill
                    key={tag}
                    active={selectedPodcastTags.includes(tag)}
                    onClick={() => setSelectedPodcastTags((current) => current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag])}
                  >
                    {tag}
                  </FilterPill>
                ))}
              </div>
            )}
            <div className="grid max-h-[calc(100vh-17rem)] gap-2 overflow-auto rounded-xl bg-slate-50 p-2">
            {filteredEpisodes.map((episode) => (
              <button
                key={episode.id}
                onClick={() => openEpisode(episode.id)}
                className={`grid grid-cols-[3.5rem_minmax(0,1fr)] gap-3 rounded-xl px-3 py-2 text-left transition ${
                  selectedEpisode?.id === episode.id ? "bg-things-50 text-things-900" : "bg-slate-50 text-slate-600 hover:bg-things-50"
                }`}
              >
                {episode.coverUrl ? (
                  <img src={episode.coverUrl} alt="" className="h-14 w-14 rounded-lg object-cover shadow-hairline" />
                ) : (
                  <div className="grid h-14 w-14 place-items-center rounded-lg bg-things-100 text-lg font-semibold text-things-700">♫</div>
                )}
                <div className="min-w-0">
                  <div className="flex items-start justify-between gap-2">
                    <div className="min-w-0">
                      <div className="truncate text-sm font-semibold">{episode.title}</div>
                      {episode.podcastTitle && <div className="mt-0.5 truncate text-xs text-slate-500">{episode.podcastTitle}</div>}
                    </div>
                    <div className="flex shrink-0 items-center gap-2">
                      <span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${
                        episode.status === "ready" ? "bg-emerald-50 text-emerald-700" : episode.status === "error" ? "bg-red-50 text-red-600" : "bg-warm-50 text-warm-700"
                      }`}>
                        {podcastStatusLabel(episode.status)}
                      </span>
                      <span
                        role="button"
                        tabIndex={0}
                        aria-label={`删除播客 ${episode.title}`}
                        onClick={(event) => {
                          event.preventDefault();
                          event.stopPropagation();
                          deletePodcastEpisodeFromList(episode);
                        }}
                        onKeyDown={(event) => {
                          if (event.key !== "Enter" && event.key !== " ") return;
                          event.preventDefault();
                          event.stopPropagation();
                          deletePodcastEpisodeFromList(episode);
                        }}
                        className="rounded-full bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-600 shadow-hairline transition hover:bg-red-100"
                      >
                        删除
                      </span>
                    </div>
                  </div>
                  <div className="mt-1 text-xs">{episode.sentenceCount || episode.sentences?.length || 0} 句 · {formatDateTime(episode.updatedAt)}</div>
                  {(episode.tags || []).length > 0 && (
                    <div className="mt-1 flex flex-wrap gap-1">
                      {episode.tags.slice(0, 4).map((tag) => <span key={tag} className="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-things-700 shadow-hairline"># {tag}</span>)}
                    </div>
                  )}
                </div>
              </button>
            ))}
            {!filteredEpisodes.length && (
              <div className="rounded-xl bg-slate-50 px-4 py-3">
                <p className="text-sm text-slate-500">{episodes.length ? "当前标签筛选下没有播客。" : "还没有已入库播客。"}</p>
                <div className="mt-3">
                  <PrimaryButton onClick={() => setShowImportModal(true)}>导入第一个播客</PrimaryButton>
                </div>
              </div>
            )}
            </div>
          </div>
        )}

        {podcastTopTab === "episode" && selectedEpisode && (
          <div className="mt-0 min-w-0 md:mt-4">
            <div className="mb-3 flex items-center gap-3 border-b border-line pb-3 md:hidden">
              <button type="button" onClick={() => setPodcastTopTab("library")} className="shrink-0 text-sm font-semibold text-things-700">‹ 列表</button>
              <div className="min-w-0">
                <div className="truncate text-sm font-semibold text-ink">{selectedEpisode.title}</div>
                <div className="mt-0.5 text-xs font-semibold text-slate-400">{masteredCount}/{selectedEpisode.sentences.length} 句已掌握</div>
              </div>
            </div>
            <div className="mb-4 hidden rounded-2xl border border-line bg-white p-4 md:block">
              <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,22rem)] lg:items-start">
                <div className="min-w-0">
                  <div className="text-sm font-semibold text-things-700">当前播客</div>
                  <h3 className="mt-1 truncate text-2xl font-semibold text-ink">{selectedEpisode.title}</h3>
                  <p className="mt-1 text-sm text-slate-500">
                    {masteredCount}/{selectedEpisode.sentences.length} 句已掌握 · 收藏 {favoriteCount} · 已过 {seenIds.length} · 语言 {selectedEpisode.language || "auto"}
                  </p>
                  {(selectedEpisode.tags || []).length > 0 && (
                    <div className="mt-2 flex flex-wrap gap-1.5">
                      {selectedEpisode.tags.map((tag) => <span key={tag} className="rounded-full bg-things-50 px-2 py-0.5 text-xs font-semibold text-things-700"># {tag}</span>)}
                    </div>
                  )}
                  <div className="mt-3">
                    <SecondaryButton onClick={() => {
                      setEpisodeTitleDraft(selectedEpisode.title);
                      setEpisodeTagDraft((selectedEpisode.tags || []).join(", "));
                      setShowManageModal(true);
                    }}>管理</SecondaryButton>
                  </div>
                </div>
                <div className="grid gap-3">
                  {selectedEpisode.coverUrl && <img src={selectedEpisode.coverUrl} alt="" className="h-36 w-36 rounded-xl object-cover shadow-hairline lg:justify-self-end" />}
                  <audio controls src={selectedEpisode.audioUrl} className="w-full" />
                </div>
              </div>
            </div>
            <div className="overflow-hidden rounded-2xl border border-line bg-white">
              <div className="flex flex-col gap-2 border-b border-line bg-slate-50/80 p-2 md:flex-row md:items-center md:justify-between">
                <ModuleTabs
                  themeKey="podcast"
                  active={podcastPanel}
                  onChange={setPodcastPanel}
                  tabs={[
                    { key: "practice", label: "练习当前句", icon: "▶" },
                    { key: "sentences", label: "句子列表", icon: "列" }
                  ]}
                />
                <div className="px-2 text-xs font-semibold text-slate-400">
                  {podcastPanel === "practice" ? `当前第 ${selectedSentence?.position || "-"} 句` : `${filteredSentences.length} / ${selectedEpisode.sentences.length} 句`}
                </div>
              </div>
              <div className="p-4">
                {podcastPanel === "sentences" && (
                  <div>
                <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
                  <div>
                    <div className="text-sm font-semibold text-things-700">句子列表</div>
                    <p className="mt-1 text-xs text-slate-500">点击任意句子进入精听；列表区域独立滚动。</p>
                  </div>
                  <ModuleTabs
                    themeKey="podcast"
                    active={sentenceFilter}
                    onChange={setSentenceFilter}
                    tabs={[
                      { key: "all", label: `全部 ${selectedEpisode.sentences.length}`, icon: "全" },
                      { key: "seen", label: `已过 ${seenIds.length}`, icon: "見" },
                      { key: "learning", label: `未掌握 ${selectedEpisode.sentences.length - masteredCount}`, icon: "…" },
                      { key: "mastered", label: `掌握 ${masteredCount}`, icon: "✓" },
                      { key: "favorite", label: `收藏 ${favoriteCount}`, icon: "★" }
                    ]}
                  />
                </div>
                <div className="mt-4 grid max-h-[calc(100vh-18rem)] gap-2 overflow-auto rounded-xl bg-slate-50 p-2">
                  {filteredSentences.map((sentence) => (
                    <button
                      key={sentence.id}
                      onClick={() => openSentencePractice(selectedEpisode, sentence.id)}
                      className={`grid gap-1 rounded-xl px-3 py-2 text-left text-xs font-semibold transition ${
                        selectedSentence?.id === sentence.id
                          ? "bg-things-600 text-white"
                          : sentence.mastered
                            ? "bg-emerald-50 text-emerald-700"
                            : "bg-white text-slate-500 shadow-hairline hover:bg-things-50 hover:text-things-800"
                      }`}
                    >
                      <span className="flex items-center justify-between gap-2">
                        <span>#{sentence.position} · {formatSeconds(sentence.startSeconds)}-{formatSeconds(sentence.endSeconds)}</span>
                        <span>{sentence.favorite ? "★" : ""}{sentence.mastered ? " 已掌握" : " 未掌握"}</span>
                      </span>
                      <span className="line-clamp-2 font-medium leading-5 opacity-80">{sentence.text}</span>
                    </button>
                  ))}
                  {!filteredSentences.length && <p className="px-2 py-3 text-sm text-slate-500">当前筛选下没有句子。</p>}
                </div>
              </div>
            )}
                {podcastPanel === "practice" && (
                  selectedSentence ? (
                    <PodcastSentencePractice
                      episode={selectedEpisode}
                      sentence={selectedSentence}
                      difficultWords={difficultWords}
                      onDifficultWordsChange={setDifficultWords}
                      playbackRate={playbackRate}
                      onPlaybackRateChange={onPlaybackRateChange}
                      onSentenceUpdate={updateSentence}
                      autoPlay={podcastAutoPlay}
                      onAutoPlayChange={setPodcastAutoPlay}
                      autoNext={podcastAutoNext}
                      onAutoNextChange={setPodcastAutoNext}
                      onPrevious={() => {
                        const index = selectedEpisode.sentences.findIndex((sentence) => sentence.id === selectedSentence.id);
                        openSentencePractice(selectedEpisode, selectedEpisode.sentences[Math.max(0, index - 1)]?.id || selectedSentence.id);
                      }}
                      onNext={() => {
                        const index = selectedEpisode.sentences.findIndex((sentence) => sentence.id === selectedSentence.id);
                        openSentencePractice(selectedEpisode, selectedEpisode.sentences[Math.min(selectedEpisode.sentences.length - 1, index + 1)]?.id || selectedSentence.id);
                      }}
                      onBrowseSentences={() => setPodcastPanel("sentences")}
                    />
                  ) : (
                    <Locked title="还没有选择句子" text="从中间的句子队列选择一句开始精听。" />
                  )
                )}
              </div>
            </div>
          </div>
        )}

        {podcastTopTab === "episode" && !selectedEpisode && (
          <div className="mt-4">
            <Locked title="还没有选择播客" text="从左侧选择一个已解析播客开始学习；没有内容时先导入播客。" />
          </div>
        )}
      </div>
      {showImportModal && (
        <div
          className="fixed inset-0 z-50 flex items-center justify-center bg-ink/35 p-4 backdrop-blur-sm"
          role="dialog"
          aria-modal="true"
          aria-labelledby="podcast-import-title"
          onClick={() => !loading && setShowImportModal(false)}
        >
          <div
            className="max-h-[88vh] w-full max-w-2xl overflow-auto rounded-2xl border border-line bg-white p-5 shadow-soft"
            onClick={(event) => event.stopPropagation()}
          >
            <div className="flex items-start justify-between gap-4">
              <div>
                <div id="podcast-import-title" className="text-xl font-semibold text-ink">导入播客</div>
                <p className="mt-1 text-sm leading-6 text-slate-500">导入会创建后台解析任务，进度在“解析任务”里查看。解析完成后会自动入库，下次可直接学习。</p>
              </div>
              <button
                type="button"
                disabled={loading}
                className="rounded-full bg-mist px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-things-50 hover:text-things-800 disabled:cursor-not-allowed disabled:opacity-50"
                onClick={() => setShowImportModal(false)}
              >
                关闭
              </button>
            </div>
            <div className="mt-5 grid gap-3">
              <label className="grid gap-1.5">
                <span className="text-sm font-semibold text-slate-700">转录语言</span>
                <select className="input" value={language} onChange={(event) => setLanguage(event.target.value)}>
                  <option value="ja">日语 ja</option>
                  <option value="en">英语 en</option>
                  <option value="zh">中文 zh</option>
                </select>
              </label>
              <label className="grid gap-1.5">
                <span className="text-sm font-semibold text-slate-700">标题</span>
                <input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="可选，Apple 链接会自动读取单集标题" />
              </label>
              <label className="grid gap-1.5">
                <span className="text-sm font-semibold text-slate-700">标签</span>
                <input className="input" value={tagText} onChange={(event) => setTagText(event.target.value)} placeholder="用逗号分隔，例如：聴解, 新闻, YUYU" />
              </label>
              <div className="rounded-2xl bg-slate-50 p-3">
                <label className="grid gap-1.5">
                  <span className="text-sm font-semibold text-slate-700">播客 URL</span>
                  <input className="input" value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://..." />
                </label>
                {metadataPreview && (
                  <div className="mt-3 grid grid-cols-[4rem_minmax(0,1fr)] gap-3 rounded-xl bg-white p-2 shadow-hairline">
                    {metadataPreview.coverUrl ? <img src={metadataPreview.coverUrl} alt="" className="h-16 w-16 rounded-lg object-cover" /> : <div className="grid h-16 w-16 place-items-center rounded-lg bg-things-50 text-things-700">♫</div>}
                    <div className="min-w-0">
                      <div className="truncate text-sm font-semibold text-ink">{metadataPreview.title || "未读取到标题"}</div>
                      {metadataPreview.podcastTitle && <div className="mt-0.5 truncate text-xs text-slate-500">{metadataPreview.podcastTitle}</div>}
                      {metadataPreview.tags.length > 0 && (
                        <div className="mt-1 flex flex-wrap gap-1">
                          {metadataPreview.tags.map((tag) => <span key={tag} className="rounded-full bg-things-50 px-2 py-0.5 text-[11px] font-semibold text-things-700"># {tag}</span>)}
                        </div>
                      )}
                    </div>
                  </div>
                )}
                <div className="mt-3">
                  <SecondaryButton onClick={() => loadPodcastMetadata()} disabled={loading || metadataLoading || !url.trim()}>
                    {metadataLoading ? "读取中..." : "读取封面/标题"}
                  </SecondaryButton>
                  <span className="mx-1" />
                  <PrimaryButton onClick={importUrl} disabled={loading || !url.trim()}>{loading ? "创建中..." : "导入 URL"}</PrimaryButton>
                </div>
              </div>
              <div className="rounded-2xl bg-slate-50 p-3">
                <label className="grid gap-1.5">
                  <span className="text-sm font-semibold text-slate-700">上传音频文件</span>
                  <input
                    type="file"
                    accept="audio/*,video/mp4"
                    onChange={(event) => setFile(event.target.files?.[0] || null)}
                    className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-xl file:border-0 file:bg-things-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-things-800"
                  />
                </label>
                <div className="mt-3">
                  <SecondaryButton onClick={uploadFile} disabled={loading || !file}>{loading ? "创建中..." : "上传音频"}</SecondaryButton>
                </div>
              </div>
            </div>
          </div>
        </div>
      )}
      {showManageModal && selectedEpisode && (
        <div
          className="fixed inset-0 z-50 flex items-center justify-center bg-ink/35 p-4 backdrop-blur-sm"
          role="dialog"
          aria-modal="true"
          aria-labelledby="podcast-manage-title"
          onClick={() => !loading && setShowManageModal(false)}
        >
          <div
            className="w-full max-w-xl rounded-2xl border border-line bg-white p-5 shadow-soft"
            onClick={(event) => event.stopPropagation()}
          >
            <div className="flex items-start justify-between gap-4">
              <div>
                <div id="podcast-manage-title" className="text-xl font-semibold text-ink">管理播客</div>
                <p className="mt-1 text-sm text-slate-500">修改标题或从播客列表中删除这条已入库内容。</p>
              </div>
              <button
                type="button"
                disabled={loading}
                className="rounded-full bg-mist px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-things-50 hover:text-things-800 disabled:cursor-not-allowed disabled:opacity-50"
                onClick={() => setShowManageModal(false)}
              >
                关闭
              </button>
            </div>
            <div className="mt-5 grid gap-3">
              <label className="grid gap-1.5">
                <span className="text-sm font-semibold text-slate-700">播客标题</span>
                <input className="input" value={episodeTitleDraft} onChange={(event) => setEpisodeTitleDraft(event.target.value)} />
              </label>
              <label className="grid gap-1.5">
                <span className="text-sm font-semibold text-slate-700">标签</span>
                <input className="input" value={episodeTagDraft} onChange={(event) => setEpisodeTagDraft(event.target.value)} placeholder="用逗号分隔标签" />
              </label>
              <div className="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-500">
                {selectedEpisode.sentences.length} 句 · {masteredCount} 句已掌握 · {favoriteCount} 句收藏
              </div>
              <div className="flex flex-wrap justify-between gap-2 border-t border-line pt-3">
                <button
                  type="button"
                  onClick={deleteEpisode}
                  disabled={loading}
                  className="rounded-xl bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
                >
                  删除播客
                </button>
                <div className="mobile-action-grid">
                  <SecondaryButton onClick={() => setShowManageModal(false)} disabled={loading}>取消</SecondaryButton>
                  <PrimaryButton onClick={saveEpisodeTitle} disabled={loading || !episodeTitleDraft.trim()}>
                    {loading ? "保存中..." : "保存"}
                  </PrimaryButton>
                </div>
              </div>
            </div>
          </div>
        </div>
      )}
    </section>
  );
}

function podcastStatusLabel(status: string): string {
  if (status === "ready") return "已解析";
  if (status === "processing") return "处理中";
  if (status === "error") return "失败";
  return status || "未知";
}

function PodcastSentencePractice({
  episode,
  sentence,
  difficultWords,
  onDifficultWordsChange,
  playbackRate,
  onPlaybackRateChange,
  onSentenceUpdate,
  autoPlay,
  onAutoPlayChange,
  autoNext,
  onAutoNextChange,
  onBrowseSentences,
  onPrevious,
  onNext
}: {
  episode: PodcastEpisode;
  sentence: PodcastSentence;
  difficultWords: DifficultWordMap;
  onDifficultWordsChange: (words: DifficultWordMap) => void;
  playbackRate: number;
  onPlaybackRateChange: (rate: number) => void;
  onSentenceUpdate: (sentence: PodcastSentence) => void;
  autoPlay: boolean;
  onAutoPlayChange: (value: boolean) => void;
  autoNext: boolean;
  onAutoNextChange: (value: boolean) => void;
  onBrowseSentences: () => void;
  onPrevious: () => void;
  onNext: () => void;
}) {
  const [revealed, setRevealed] = useState<number[]>([]);
  const [revealedEver, setRevealedEver] = useState<number[]>([]);
  const [playCount, setPlayCount] = useState(0);
  const [loop, setLoop] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [showShadowingModal, setShowShadowingModal] = useState(false);
  const [practicePlaying, setPracticePlaying] = useState(false);
  const [practiceError, setPracticeError] = useState("");
  const [practiceAnalysis, setPracticeAnalysis] = useState<ListeningSentenceAnalysis | null>(null);
  const [practiceAnalysisLoading, setPracticeAnalysisLoading] = useState(false);
  const [showPracticeAnalysis, setShowPracticeAnalysis] = useState(false);
  const [error, setError] = useState("");
  const [showDifficultWords, setShowDifficultWords] = useState(false);
  const [showSentenceText, setShowSentenceText] = useState(false);
  const [mobilePodcastPage, setMobilePodcastPage] = useState<"practice" | "tools">("practice");
  const [wordSaving, setWordSaving] = useState(false);
  const [addingCorpusWord, setAddingCorpusWord] = useState("");
  const [addedCorpusWords, setAddedCorpusWords] = useState<string[]>([]);
  const wordDrag = useTouchWordDrag((word) => addDifficultWord(word));
  const shadowingRecorder = useRecorder();
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const timerRef = useRef<number | null>(null);
  const playSerialRef = useRef(0);
  const practiceAudioRef = useRef<HTMLAudioElement | null>(null);
  const practiceTimerRef = useRef<number | null>(null);
  const practicePlaySerialRef = useRef(0);
  const currentDifficultWords = difficultWords[sentence.id] || [];
  const tokens = sentence.tokens || tokenizeJapanese(sentence.text);

  useEffect(() => {
    setRevealed([]);
    setRevealedEver([]);
    setPlayCount(0);
    setPlaying(false);
    setPracticePlaying(false);
    setError("");
    setPracticeError("");
    setPracticeAnalysis(null);
    setPracticeAnalysisLoading(false);
    setShowPracticeAnalysis(false);
    setShowSentenceText(false);
    setMobilePodcastPage("practice");
    setShowShadowingModal(false);
    stopAudio();
    stopPracticeAudio();
    if (autoPlay) {
      const timer = window.setTimeout(() => {
        playSentence();
      }, 250);
      return () => {
        window.clearTimeout(timer);
        stopAudio();
        stopPracticeAudio();
      };
    }
    return () => {
      stopAudio();
      stopPracticeAudio();
    };
  }, [sentence.id, autoPlay]);

  function clearAudio() {
    if (timerRef.current) {
      window.clearInterval(timerRef.current);
      timerRef.current = null;
    }
    audioRef.current?.pause();
    audioRef.current = null;
    setPlaying(false);
  }

  function stopAudio() {
    playSerialRef.current += 1;
    clearAudio();
  }

  function clearPracticeAudio() {
    if (practiceTimerRef.current) {
      window.clearInterval(practiceTimerRef.current);
      practiceTimerRef.current = null;
    }
    practiceAudioRef.current?.pause();
    practiceAudioRef.current = null;
    setPracticePlaying(false);
  }

  function stopPracticeAudio() {
    practicePlaySerialRef.current += 1;
    clearPracticeAudio();
  }

  function waitForAudioMetadata(audio: HTMLAudioElement): Promise<void> {
    if (audio.readyState >= 1) return Promise.resolve();
    return new Promise((resolve, reject) => {
      const cleanup = () => {
        audio.removeEventListener("loadedmetadata", handleLoaded);
        audio.removeEventListener("error", handleError);
      };
      const handleLoaded = () => {
        cleanup();
        resolve();
      };
      const handleError = () => {
        cleanup();
        reject(new Error("音频加载失败"));
      };
      audio.addEventListener("loadedmetadata", handleLoaded, { once: true });
      audio.addEventListener("error", handleError, { once: true });
      audio.load();
    });
  }

  function seekAudio(audio: HTMLAudioElement, start: number): Promise<void> {
    return new Promise((resolve, reject) => {
      const timeout = window.setTimeout(() => {
        cleanup();
        reject(new Error(`音频没有定位到 ${formatSeconds(start)}，请稍后重试或重新导入。`));
      }, 3000);
      const cleanup = () => {
        window.clearTimeout(timeout);
        audio.removeEventListener("seeked", handleSeeked);
        audio.removeEventListener("error", handleError);
      };
      const handleSeeked = () => {
        cleanup();
        resolve();
      };
      const handleError = () => {
        cleanup();
        reject(new Error("音频定位失败"));
      };
      audio.addEventListener("seeked", handleSeeked, { once: true });
      audio.addEventListener("error", handleError, { once: true });
      audio.currentTime = start;
      if (start <= 0 || Math.abs(audio.currentTime - start) < 0.05) {
        cleanup();
        resolve();
      }
    });
  }

  async function playSentence() {
    const start = Math.max(0, sentence.startSeconds || 0);
    const end = Math.max(start, sentence.endSeconds || 0);
    if (end <= start) {
      setError("这句没有可用的播客时间戳，无法准确播放原音频片段。请重新导入生成时间戳。");
      return;
    }
    const serial = playSerialRef.current + 1;
    playSerialRef.current = serial;
    clearAudio();
    setError("");
    const audio = new Audio(episode.audioUrl);
    audioRef.current = audio;
    audio.playbackRate = playbackRate;
    audio.preload = "auto";
    audio.onpause = () => {
      if (playSerialRef.current === serial) setPlaying(false);
    };
    audio.onerror = () => {
      if (playSerialRef.current === serial) setPlaying(false);
    };
    try {
      setPlaying(true);
      await waitForAudioMetadata(audio);
      if (playSerialRef.current !== serial || audioRef.current !== audio) return;
      await seekAudio(audio, start);
      if (playSerialRef.current !== serial || audioRef.current !== audio) return;
      timerRef.current = window.setInterval(() => {
        if (playSerialRef.current !== serial || audioRef.current !== audio) return;
        if (audio.currentTime >= end - 0.03) {
          if (loop) {
            audio.currentTime = start;
            audio.play();
          } else {
            stopAudio();
            if (autoNext) {
              window.setTimeout(onNext, 180);
            }
          }
        }
      }, 60);
      await audio.play();
      if (playSerialRef.current !== serial) return;
      setPlayCount((current) => current + 1);
    } catch (err) {
      if (playSerialRef.current === serial) {
        setError(err instanceof Error ? err.message : "播放播客音频失败");
        setPlaying(false);
      }
    }
  }

  async function playPracticeSentence() {
    const start = Math.max(0, sentence.startSeconds || 0);
    const end = Math.max(start, sentence.endSeconds || 0);
    if (end <= start) {
      setPracticeError("这句没有可用的播客时间戳，无法准确播放原音频片段。请重新导入生成时间戳。");
      return;
    }
    const serial = practicePlaySerialRef.current + 1;
    practicePlaySerialRef.current = serial;
    clearPracticeAudio();
    setPracticeError("");
    const audio = new Audio(episode.audioUrl);
    practiceAudioRef.current = audio;
    audio.playbackRate = playbackRate;
    audio.preload = "auto";
    audio.onpause = () => {
      if (practicePlaySerialRef.current === serial) setPracticePlaying(false);
    };
    audio.onerror = () => {
      if (practicePlaySerialRef.current === serial) setPracticePlaying(false);
    };
    try {
      setPracticePlaying(true);
      await waitForAudioMetadata(audio);
      if (practicePlaySerialRef.current !== serial || practiceAudioRef.current !== audio) return;
      await seekAudio(audio, start);
      if (practicePlaySerialRef.current !== serial || practiceAudioRef.current !== audio) return;
      practiceTimerRef.current = window.setInterval(() => {
        if (practicePlaySerialRef.current !== serial || practiceAudioRef.current !== audio) return;
        if (audio.currentTime >= end - 0.03) {
          stopPracticeAudio();
        }
      }, 60);
      await audio.play();
    } catch (err) {
      if (practicePlaySerialRef.current === serial) {
        setPracticeError(err instanceof Error ? err.message : "播放播客音频失败");
        setPracticePlaying(false);
      }
    }
  }

  function openShadowingModal() {
    stopAudio();
    setPracticeError("");
    setShowShadowingModal(true);
  }

  function closeShadowingModal() {
    stopPracticeAudio();
    if (shadowingRecorder.isRecording) shadowingRecorder.stop();
    setShowShadowingModal(false);
  }

  async function analyzePracticeSentence() {
    setPracticeAnalysisLoading(true);
    setPracticeError("");
    setShowPracticeAnalysis(true);
    try {
      setPracticeAnalysis(await apiAnalyzePodcastSentence(sentence.id));
    } catch (err) {
      setPracticeError(err instanceof Error ? err.message : "句子解析失败");
    } finally {
      setPracticeAnalysisLoading(false);
    }
  }

  function reveal(index: number) {
    setRevealed((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index]);
    setRevealedEver((current) => current.includes(index) ? current : [...current, index]);
  }

  async function saveDifficultWords(words: string[]) {
    const uniqueWords = Array.from(new Set(words.map((word) => word.trim()).filter(Boolean)));
    onDifficultWordsChange({ ...difficultWords, [sentence.id]: uniqueWords });
    setWordSaving(true);
    try {
      const savedWords = await apiUpdatePodcastDifficultWords(sentence.id, uniqueWords);
      onDifficultWordsChange({ ...difficultWords, [sentence.id]: savedWords });
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存难词失败");
    } finally {
      setWordSaving(false);
    }
  }

  function addDifficultWord(word: string) {
    if (!word || currentDifficultWords.includes(word)) return;
    saveDifficultWords([...currentDifficultWords, word]);
  }

  function handleDropDifficultWord(event: React.DragEvent<HTMLDivElement>) {
    event.preventDefault();
    addDifficultWord(event.dataTransfer.getData("text/plain"));
  }

  async function addPodcastWordToCorpus(word: string) {
    if (!word.trim()) return;
    setAddingCorpusWord(word.trim());
    try {
      await apiAddCorpusItem({
        chunk: word.trim(),
        meaningZh: "播客精听中标记的难词",
        usageScene: "播客学习",
        exampleJa: sentence.text,
        masteryStatus: "未练习",
        tags: ["播客"],
        status: "active",
        sourceType: "listening",
        sourceLabel: "播客学习",
        sourceRef: sentence.id,
        detailStatus: "pending"
      });
      setAddedCorpusWords((current) => Array.from(new Set([...current, word.trim()])));
    } catch (err) {
      setError(err instanceof Error ? err.message : "添加 corpus 失败");
    } finally {
      setAddingCorpusWord("");
    }
  }

  async function markMastered() {
    try {
      const updated = await apiUpdatePodcastSentence(sentence.id, { mastered: !sentence.mastered });
      onSentenceUpdate(updated);
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新句子状态失败");
    }
  }

  async function toggleFavorite() {
    try {
      const updated = await apiUpdatePodcastSentence(sentence.id, { favorite: !sentence.favorite });
      onSentenceUpdate(updated);
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新收藏失败");
    }
  }

  const score = Math.max(0, Math.round(100 - (revealedEver.length / Math.max(tokens.length, 1)) * 80 - Math.max(0, playCount - 1) * 6));

  return (
    <main className="grid gap-4">
      <div className={`${mobilePodcastPage === "tools" ? "hidden" : ""} rounded-2xl border border-line bg-white p-4 shadow-hairline md:hidden`}>
        <div className="flex items-start justify-between gap-3">
          <div>
            <div className="text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Podcast Sentence {sentence.position}</div>
            <div className="mt-1 text-xs font-semibold text-slate-400">
              {formatSeconds(sentence.startSeconds)} - {formatSeconds(sentence.endSeconds)} · 播放 {playCount} 次
            </div>
          </div>
          <button type="button" onClick={onBrowseSentences} className="text-sm font-semibold text-things-700">句子列表</button>
        </div>
        <div className="mt-4 [&>button]:w-full">
          <PrimaryButton onClick={playSentence} disabled={playing}>{buttonIcon(playing ? "▮▮" : "▶", playing ? "播放中" : "播放本句")}</PrimaryButton>
        </div>
        <div className="mobile-action-grid mt-2">
          <SecondaryButton size="sm" onClick={onPrevious}>‹ 上一句</SecondaryButton>
          <SecondaryButton size="sm" onClick={onNext}>下一句 ›</SecondaryButton>
          <SecondaryButton size="sm" onClick={toggleFavorite}>{sentence.favorite ? "★ 已收藏" : "☆ 收藏"}</SecondaryButton>
          <PrimaryButton size="sm" onClick={markMastered}>{sentence.mastered ? "恢复练习" : "✓ 已掌握"}</PrimaryButton>
        </div>
        <button type="button" onClick={() => setMobilePodcastPage("tools")} className="mt-3 flex w-full items-center justify-between rounded-xl bg-slate-50 px-3 py-3 text-left text-sm font-semibold text-things-700">
          <span>播放设置、跟读与重点词</span>
          <span aria-hidden="true">›</span>
        </button>
        {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
      </div>
      <div className="hidden rounded-2xl border border-line bg-white p-4 shadow-hairline md:block">
        <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
          <div>
            <div className="text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Podcast Sentence {sentence.position}</div>
            <div className="mt-1 text-sm font-semibold text-slate-500">
              {formatSeconds(sentence.startSeconds)} - {formatSeconds(sentence.endSeconds)} · 播放 {playCount} 次 · {tokens.length} 个听力块
              {sentence.favorite && <span className="ml-2 text-warm-600">★ 已收藏</span>}
            </div>
          </div>
          <div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap sm:justify-end">
            <SecondaryButton size="sm" onClick={onBrowseSentences}>列表</SecondaryButton>
            <SecondaryButton size="sm" onClick={onPrevious}>上一句</SecondaryButton>
            <SecondaryButton size="sm" onClick={onNext}>下一句</SecondaryButton>
            <SecondaryButton size="sm" onClick={toggleFavorite}>{sentence.favorite ? "取消收藏" : "收藏"}</SecondaryButton>
            <PrimaryButton size="sm" onClick={markMastered}>{sentence.mastered ? "恢复" : "已掌握"}</PrimaryButton>
          </div>
        </div>
        <div className="mobile-action-grid mt-3">
          <PrimaryButton size="sm" onClick={playSentence} disabled={playing}>{buttonIcon(playing ? "▮▮" : "▶", playing ? "播放中" : "播放本句")}</PrimaryButton>
          <SecondaryButton size="sm" onClick={stopAudio} disabled={!playing}>{buttonIcon("■", "停止")}</SecondaryButton>
          <SecondaryButton size="sm" onClick={openShadowingModal}>{buttonIcon("●", "跟读练习")}</SecondaryButton>
          <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
            <input type="checkbox" checked={loop} onChange={(event) => setLoop(event.target.checked)} className="h-4 w-4 accent-things-600" />
            循环本句
          </label>
          <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
            <input type="checkbox" checked={autoPlay} onChange={(event) => onAutoPlayChange(event.target.checked)} className="h-4 w-4 accent-things-600" />
            自动播放
          </label>
          <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
            <input type="checkbox" checked={autoNext} onChange={(event) => onAutoNextChange(event.target.checked)} className="h-4 w-4 accent-things-600" />
            自动下一句
          </label>
          <SecondaryButton onClick={() => {
            const allIndexes = tokens.map((_, index) => index);
            setRevealed(allIndexes);
            setRevealedEver((current) => Array.from(new Set([...current, ...allIndexes])));
          }} size="sm">显示全部</SecondaryButton>
        </div>
        <div className="mt-3 flex flex-wrap items-center gap-1.5">
          <span className="mr-1 text-xs font-semibold text-slate-400">速度</span>
          {listeningRateOptions.map((rate) => (
            <button
              key={rate}
              onClick={() => onPlaybackRateChange(rate)}
              className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                playbackRate === rate ? "bg-things-600 text-white" : "bg-slate-100 text-slate-500 hover:bg-things-50 hover:text-things-800"
              }`}
            >
              {rate}x
            </button>
          ))}
        </div>
        {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
      </div>

      {mobilePodcastPage === "tools" && (
        <section className="rounded-2xl border border-line bg-white p-4 shadow-hairline md:hidden">
          <div className="flex items-center justify-between gap-3">
            <button type="button" onClick={() => setMobilePodcastPage("practice")} className="text-sm font-semibold text-things-700">‹ 返回精听</button>
            <span className="text-sm font-semibold text-slate-500">辅助工具</span>
          </div>
          <div className="mobile-action-grid mt-4">
            <PrimaryButton size="sm" onClick={playSentence} disabled={playing}>{buttonIcon(playing ? "▮▮" : "▶", playing ? "播放中" : "播放本句")}</PrimaryButton>
            <SecondaryButton size="sm" onClick={stopAudio} disabled={!playing}>{buttonIcon("■", "停止")}</SecondaryButton>
            <SecondaryButton size="sm" onClick={openShadowingModal}>{buttonIcon("●", "跟读练习")}</SecondaryButton>
            <SecondaryButton size="sm" onClick={() => {
              const allIndexes = tokens.map((_, index) => index);
              setRevealed(allIndexes);
              setRevealedEver((current) => Array.from(new Set([...current, ...allIndexes])));
            }}>显示全部词块</SecondaryButton>
          </div>
          <div className="mt-3 grid gap-2">
            <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
              <input type="checkbox" checked={loop} onChange={(event) => setLoop(event.target.checked)} className="h-4 w-4 accent-things-600" />
              循环本句
            </label>
            <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
              <input type="checkbox" checked={autoPlay} onChange={(event) => onAutoPlayChange(event.target.checked)} className="h-4 w-4 accent-things-600" />
              自动播放
            </label>
            <label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
              <input type="checkbox" checked={autoNext} onChange={(event) => onAutoNextChange(event.target.checked)} className="h-4 w-4 accent-things-600" />
              自动下一句
            </label>
          </div>
          <div className="mt-3 flex flex-wrap items-center gap-1.5">
            <span className="mr-1 text-xs font-semibold text-slate-400">速度</span>
            {listeningRateOptions.map((rate) => (
              <button
                key={rate}
                onClick={() => onPlaybackRateChange(rate)}
                className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${playbackRate === rate ? "bg-things-600 text-white" : "bg-slate-100 text-slate-500"}`}
              >
                {rate}x
              </button>
            ))}
          </div>
        </section>
      )}

      {showShadowingModal && (
        <div
          className="fixed inset-0 z-50 flex items-center justify-center bg-ink/35 p-4 backdrop-blur-sm"
          role="dialog"
          aria-modal="true"
          aria-labelledby="podcast-shadowing-title"
          onClick={closeShadowingModal}
        >
          <div
            className="max-h-[88vh] w-full max-w-2xl overflow-auto rounded-2xl border border-line bg-white p-5 shadow-soft"
            onClick={(event) => event.stopPropagation()}
          >
            <div className="flex items-start justify-between gap-4">
              <div>
                <div id="podcast-shadowing-title" className="text-xl font-semibold text-ink">跟读练习</div>
                <div className="mt-1 text-sm font-semibold text-slate-500">
                  Podcast Sentence {sentence.position} · {formatSeconds(sentence.startSeconds)} - {formatSeconds(sentence.endSeconds)}
                </div>
              </div>
              <button
                type="button"
                className="rounded-full bg-mist px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-things-50 hover:text-things-800"
                onClick={closeShadowingModal}
              >
                关闭
              </button>
            </div>

            <div className="mt-4 rounded-xl bg-slate-50 p-4 text-lg font-semibold leading-8 text-ink">
              {sentence.text}
            </div>

            <div className="mt-4 grid gap-3 rounded-2xl border border-line bg-white p-4">
              <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <div className="text-sm font-semibold text-things-700">原音频片段</div>
                  <div className="mt-0.5 text-xs text-slate-500">使用当前播客音频和时间戳，速度跟随外面的设置。</div>
                </div>
                <div className="mobile-action-grid">
                  <PrimaryButton size="sm" onClick={playPracticeSentence} disabled={practicePlaying}>
                    {buttonIcon(practicePlaying ? "▮▮" : "▶", practicePlaying ? "播放中" : "播放原音")}
                  </PrimaryButton>
                  <SecondaryButton size="sm" onClick={stopPracticeAudio} disabled={!practicePlaying}>{buttonIcon("■", "停止")}</SecondaryButton>
                </div>
              </div>
              {practiceError && <p className="text-sm text-red-600">{practiceError}</p>}
            </div>

            <div className="mt-3 grid gap-3 rounded-2xl border border-line bg-white p-4">
              <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <div className="text-sm font-semibold text-things-700">我的跟读</div>
                  <div className="mt-0.5 text-xs text-slate-500">录完后可直接回放自己的声音。</div>
                </div>
                <div className="mobile-action-grid">
                  <PrimaryButton size="sm" onClick={shadowingRecorder.start} disabled={shadowingRecorder.isRecording}>
                    {buttonIcon("●", shadowingRecorder.isRecording ? "录音中" : "开始录音")}
                  </PrimaryButton>
                  <SecondaryButton size="sm" onClick={shadowingRecorder.stop} disabled={!shadowingRecorder.isRecording}>{buttonIcon("■", "停止录音")}</SecondaryButton>
                </div>
              </div>
              {shadowingRecorder.isRecording && <span className="w-fit rounded-full bg-red-50 px-3 py-1.5 text-sm font-semibold text-red-600">录音中</span>}
              {shadowingRecorder.audioUrl && <audio controls src={shadowingRecorder.audioUrl} className="w-full" />}
              {shadowingRecorder.error && <p className="text-sm text-red-600">{shadowingRecorder.error}</p>}
            </div>

            <div className="mt-3 grid gap-3 rounded-2xl border border-line bg-white p-4">
              <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <div className="text-sm font-semibold text-things-700">AI 解析</div>
                  <div className="mt-0.5 text-xs text-slate-500">重点看词义、读音和语法表达。</div>
                </div>
                <div className="mobile-action-grid">
                  <PrimaryButton size="sm" onClick={analyzePracticeSentence} disabled={practiceAnalysisLoading}>
                    {buttonIcon(practiceAnalysisLoading ? "…" : "✦", practiceAnalysisLoading ? "解析中..." : practiceAnalysis ? "重新解析" : "AI 解析")}
                  </PrimaryButton>
                  {showPracticeAnalysis && <SecondaryButton size="sm" onClick={() => setShowPracticeAnalysis(false)}>收起</SecondaryButton>}
                </div>
              </div>

              {showPracticeAnalysis && (
                <div className="grid gap-3">
                  {practiceAnalysisLoading && <p className="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-500">正在解析...</p>}
                  {!practiceAnalysisLoading && practiceAnalysis && (
                    <>
                      <Info label="句子意思" value={practiceAnalysis.meaningZh || "AI 未返回整句中文含义"} />
                      <Info label="整句读音" value={practiceAnalysis.reading || "AI 未返回读音"} />
                      {(practiceAnalysis.wordMeanings || []).length > 0 && (
                        <div className="rounded-xl bg-slate-50 p-4">
                          <div className="text-sm font-semibold text-things-700">词义与读音</div>
                          <div className="mt-2 grid gap-2">
                            {(practiceAnalysis.wordMeanings || []).map((word) => (
                              <div key={`${word.text}_${word.reading}`} className="rounded-lg bg-white px-3 py-2 shadow-hairline">
                                <div className="flex flex-wrap items-baseline gap-2">
                                  <span className="text-sm font-semibold text-ink">{word.text}</span>
                                  {word.reading && <span className="text-xs font-semibold text-slate-500">{word.reading}</span>}
                                </div>
                                <div className="mt-1 text-sm leading-6 text-slate-700">{word.meaningZh || "未返回词义"}</div>
                                {word.note && <div className="mt-1 text-xs leading-5 text-slate-500">{word.note}</div>}
                              </div>
                            ))}
                          </div>
                        </div>
                      )}
                      <Info label="结构说明" value={practiceAnalysis.structureNotes} />
                      {practiceAnalysis.grammarPoints.length > 0 && (
                        <div className="rounded-xl bg-slate-50 p-4">
                          <div className="text-sm font-semibold text-things-700">语法表达</div>
                          <div className="mt-2 grid gap-2">
                            {practiceAnalysis.grammarPoints.map((point) => <p key={point} className="rounded-lg bg-white px-3 py-2 text-sm leading-7 text-slate-700 shadow-hairline">{point}</p>)}
                          </div>
                        </div>
                      )}
                      {practiceAnalysis.pronunciationTips.length > 0 && (
                        <div className="rounded-xl bg-slate-50 p-4">
                          <div className="text-sm font-semibold text-things-700">读音注意</div>
                          <div className="mt-2 grid gap-2">
                            {practiceAnalysis.pronunciationTips.map((tip) => <p key={tip} className="rounded-lg bg-white px-3 py-2 text-sm leading-7 text-slate-700 shadow-hairline">{tip}</p>)}
                          </div>
                        </div>
                      )}
                    </>
                  )}
                </div>
              )}
            </div>
          </div>
        </div>
      )}

      <section className={`${mobilePodcastPage === "tools" ? "mt-0" : ""} rounded-2xl border border-line bg-mist p-4`}>
        <div
          className={`${mobilePodcastPage === "practice" ? "hidden md:block" : ""} rounded-2xl border border-dashed border-red-200 bg-white p-3`}
          onDragOver={(event) => event.preventDefault()}
          onDrop={handleDropDifficultWord}
        >
          <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
            <div>
              <div className="text-sm font-semibold text-red-700">重点词</div>
              <p className="mt-0.5 text-xs text-slate-500">把已揭示的词拖进来；默认隐藏明文。</p>
            </div>
            <div className="flex flex-wrap items-center gap-2">
              <SecondaryButton onClick={() => setShowDifficultWords(!showDifficultWords)}>{showDifficultWords ? "隐藏难词" : "显示难词"}</SecondaryButton>
              {wordSaving && <span className="text-xs font-semibold text-slate-400">保存中...</span>}
            </div>
          </div>
          <div className="mt-3 flex min-h-10 flex-wrap gap-2 rounded-xl bg-slate-50 p-2">
            {currentDifficultWords.map((word) => (
              <span key={word} className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-1 text-sm font-semibold text-red-700">
                <button
                  onClick={() => addPodcastWordToCorpus(word)}
                  disabled={addingCorpusWord === word || addedCorpusWords.includes(word)}
                  className="grid h-7 w-7 place-items-center rounded-full bg-white text-xs font-bold text-red-700 disabled:text-red-300"
                  title="添加 corpus"
                >
                  {addingCorpusWord === word ? "..." : addedCorpusWords.includes(word) ? "✓" : "+C"}
                </button>
                <span className="min-w-16 px-1.5 text-center">{showDifficultWords ? word : "••••"}</span>
                <button onClick={() => saveDifficultWords(currentDifficultWords.filter((item) => item !== word))} className="grid h-7 w-7 place-items-center rounded-full text-base leading-none hover:bg-red-100">×</button>
              </span>
            ))}
            {!currentDifficultWords.length && <span className="px-1 py-2 text-sm text-slate-400">暂无难词</span>}
          </div>
        </div>

        <div className={`${mobilePodcastPage === "tools" ? "hidden md:flex" : "flex"} mt-4 mb-3 flex-col gap-1 md:flex-row md:items-center md:justify-between`}>
          <div className="text-sm font-semibold text-things-700">听到的词</div>
          <div className="text-xs font-semibold text-slate-400">点击揭示，揭示后可拖入重点词</div>
        </div>
        <div className={`${mobilePodcastPage === "tools" ? "hidden md:flex" : "flex"} flex-wrap gap-2`}>
          {tokens.map((token, index) => {
            const isRevealed = revealed.includes(index);
            const isDifficult = currentDifficultWords.includes(token);
            return (
              <button
                key={`${sentence.id}_${token}_${index}`}
                onClick={() => reveal(index)}
                draggable={isRevealed}
                onDragStart={(event) => {
                  if (!isRevealed) return;
                  event.dataTransfer.setData("text/plain", token);
                }}
                onTouchStart={(event) => { if (isRevealed) wordDrag.start(token, event.touches[0]); }}
                onTouchMove={(event) => wordDrag.move(event.touches[0])}
                onTouchEnd={wordDrag.end}
                style={isRevealed ? { touchAction: "none" } : undefined}
                className={`min-h-12 rounded-xl px-3 py-2 text-sm font-semibold shadow-hairline ${
                  isRevealed
                    ? isDifficult ? "bg-red-50 text-red-700 ring-1 ring-red-200" : "bg-white text-ink"
                    : isDifficult ? "bg-red-100 text-red-100 ring-1 ring-red-200" : "bg-slate-200 text-slate-400"
                }`}
              >
                {isRevealed ? token : "••••"}
              </button>
            );
          })}
        </div>
        <MobileWordDragLayer drag={wordDrag} label="重点词" />
        <div className={`${mobilePodcastPage === "tools" ? "hidden md:block" : ""} mt-3 rounded-xl bg-white/70 px-3 py-2 shadow-hairline`}>
          <div className="flex flex-wrap items-center gap-2">
            <span className="text-xs font-semibold text-slate-500">精听评价</span>
            <span className={`text-sm font-semibold ${scoreTextColor(score)}`}>{score}/100</span>
            <span className="text-xs font-semibold text-slate-500">揭示 {revealedEver.length} / {tokens.length} 个词</span>
            <span className="text-xs font-semibold text-slate-500">播放 {playCount} 次</span>
            <button
              type="button"
              onClick={() => {
                setRevealed([]);
                setRevealedEver([]);
                setPlayCount(0);
              }}
              className="text-xs font-semibold text-slate-300 underline-offset-2 hover:text-slate-500 hover:underline"
            >
              初始化评分
            </button>
          </div>
          <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
            <div className={`h-full rounded-full ${scoreBarColor(score)}`} style={{ width: `${clampScore(score)}%` }} />
          </div>
        </div>
        <div className={`${mobilePodcastPage === "practice" ? "hidden md:block" : ""} mt-3 rounded-xl bg-white p-3`}>
          <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="text-sm font-semibold text-slate-500">完整原文</div>
            <SecondaryButton onClick={() => setShowSentenceText(!showSentenceText)}>
              {showSentenceText ? "隐藏原文" : "显示原文"}
            </SecondaryButton>
          </div>
          {showSentenceText ? (
            <div className="mt-3 text-sm leading-7 text-slate-600">{sentence.text}</div>
          ) : (
            <div className="mt-3 rounded-xl bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-400">原文已隐藏</div>
          )}
        </div>
      </section>
    </main>
  );
}

function TranslationDrill({
  appState,
  onCorpusChange,
  listeningPlaybackRate,
  onListeningPlaybackRateChange,
  listeningScoreSettings,
  onParseTaskCreated,
  onOpenParseTasks
}: {
  appState: AppState;
  onCorpusChange: (corpusItems: CorpusItem[]) => Promise<void> | void;
  listeningPlaybackRate: number;
  onListeningPlaybackRateChange: (rate: number) => void;
  listeningScoreSettings: ListeningScoreSettings;
  onParseTaskCreated: (task: ParseTask) => void;
  onOpenParseTasks: () => void;
}) {
  const [items, setItems] = useState<TranslationItem[]>([]);
  const [batchText, setBatchText] = useState("");
  const [batchTagText, setBatchTagText] = useState("");
  const [showInspiration, setShowInspiration] = useState(false);
  const [inspirationScene, setInspirationScene] = useState("");
  const [inspirationDomain, setInspirationDomain] = useState("");
  const [inspirationTerms, setInspirationTerms] = useState("");
  const [inspirationGoal, setInspirationGoal] = useState("听力 + 口语");
  const [inspirationLevel, setInspirationLevel] = useState("日常自然");
  const [inspirationCount, setInspirationCount] = useState(10);
  const [inspirationNotes, setInspirationNotes] = useState("");
  const [inspirationResult, setInspirationResult] = useState<TranslationInspirationResult | null>(null);
  const [inspirationLoading, setInspirationLoading] = useState(false);
  const [userAnswer, setUserAnswer] = useState("");
  const [evaluation, setEvaluation] = useState<TranslationEvaluation | null>(null);
  const [showEvaluationModal, setShowEvaluationModal] = useState(false);
  const [currentAttempt, setCurrentAttempt] = useState<TranslationAttempt | null>(null);
  const [attempts, setAttempts] = useState<TranslationAttempt[]>([]);
  const [noteDraft, setNoteDraft] = useState("");
  const [loading, setLoading] = useState(false);
  const [notice, setNotice] = useState("");
  const [error, setError] = useState("");
  const [selectedId, setSelectedId] = useState("");
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
  const [statusFilter, setStatusFilter] = useState<"active" | "archived" | "all">("active");
  const [listeningMasteryFilter, setListeningMasteryFilter] = useState<"all" | "learning" | "mastered">("all");
  const [dateFilter, setDateFilter] = useState<"all" | "today" | "week" | "older">("all");
  const [tagDraft, setTagDraft] = useState("");
  const [practicePanel, setPracticePanel] = useState<"hint" | "review" | "history">("review");
  const [mobilePracticePage, setMobilePracticePage] = useState<"answer" | "tools">("answer");
  const [hintRevealLevel, setHintRevealLevel] = useState(0);
  const [showPracticeTagEditor, setShowPracticeTagEditor] = useState(false);
  const [addedExpressions, setAddedExpressions] = useState<string[]>([]);
  const [addingExpression, setAddingExpression] = useState("");
  const [activeTab, setActiveTab] = useState<"practice" | "listening" | "library" | "detail" | "session" | "import">("practice");
  const [completedItemId, setCompletedItemId] = useState("");
  const [drillSessions, setDrillSessions] = useState<DrillSession[]>([]);
  const [activeTranslationSessionId, setActiveTranslationSessionId] = useState("");
  const [activeListeningSessionId, setActiveListeningSessionId] = useState("");
  const [selectedDrillSessionId, setSelectedDrillSessionId] = useState("");
  const [detailReturnTab, setDetailReturnTab] = useState<"library" | "session">("library");
  const [selectedSessionItemIds, setSelectedSessionItemIds] = useState<string[]>([]);
  const [libraryTrashMode, setLibraryTrashMode] = useState(false);
  const [drillUndoStack, setDrillUndoStack] = useState<DrillUndoEntry[]>([]);
  const [libraryPage, setLibraryPage] = useState(1);
  const [drillPriority, setDrillPriority] = useState<DrillPriority>(() => (localStorage.getItem("drill_priority") === "new" ? "new" : "old"));
  const [showLibraryMeta, setShowLibraryMeta] = useState(() => localStorage.getItem("translation_show_meta") !== "0");
  const [showLibrarySettings, setShowLibrarySettings] = useState(false);
  const [showArchivedDrillSessions, setShowArchivedDrillSessions] = useState(false);
  const [listeningProgress, setListeningProgress] = useState<Record<string, ListeningProgress>>({});
  const [difficultWords, setDifficultWords] = useState<DifficultWordMap>({});
  const shadowingRecorder = useRecorder();
  const answerRecorder = useRecorder();
  const [answerTranscribing, setAnswerTranscribing] = useState(false);
  const [answerTranscribeError, setAnswerTranscribeError] = useState("");

  const liveItems = items.filter((item) => !item.deletedAt);
  const deletedItems = items.filter((item) => item.deletedAt);
  const librarySourceItems = libraryTrashMode ? deletedItems : liveItems;
  const activeItems = liveItems.filter((item) => item.status !== "archived");
  const archivedItems = liveItems.filter((item) => item.status === "archived");
  const statusItems = libraryTrashMode ? librarySourceItems : statusFilter === "active" ? activeItems : statusFilter === "archived" ? archivedItems : liveItems;
  const listeningFilteredItems = statusItems.filter((item) => (
    listeningMasteryFilter === "all" ? true : listeningMasteryFilter === "mastered" ? item.listeningMastered : !item.listeningMastered
  ));
  const dateItems = listeningFilteredItems.filter((item) => matchesImportDateFilter(item.createdAt, dateFilter));
  const allTags = Array.from(new Set(dateItems.flatMap((item) => item.tags || []))).sort();
  const frequentTags = getFrequentTags(items);
  const filteredItems = selectedTags.length === 0
    ? dateItems
    : dateItems.filter((item) => selectedTags.every((tag) => (item.tags || []).includes(tag)));
  const activeTranslationSession = drillSessions.find((session) => session.id === activeTranslationSessionId && session.mode === "translation" && session.status === "active");
  const activeListeningSession = drillSessions.find((session) => session.id === activeListeningSessionId && session.mode === "listening" && session.status === "active");
  const selectedDrillSession = drillSessions.find((session) => session.id === selectedDrillSessionId) || drillSessions.find((session) => session.id === activeTranslationSessionId) || drillSessions.find((session) => session.id === activeListeningSessionId) || null;
  const translationSessionItems = activeTranslationSession ? activeTranslationSession.itemIds.map((itemId) => items.find((item) => item.id === itemId)).filter(Boolean) as TranslationItem[] : [];
  const listeningSessionItems = activeListeningSession ? activeListeningSession.itemIds.map((itemId) => items.find((item) => item.id === itemId)).filter(Boolean) as TranslationItem[] : [];
  const practiceItems = (activeTranslationSession ? translationSessionItems : filteredItems).filter((item) => item.id !== completedItemId || item.id === selectedId);
  const dueItems = practiceItems.filter((item) => new Date(item.nextDueAt).getTime() <= Date.now());
  const activeDueCount = activeItems.filter((item) => new Date(item.nextDueAt).getTime() <= Date.now()).length;
  const sessionCurrentItem = activeTranslationSession ? translationSessionItems[activeTranslationSession.currentIndex] : null;
  const currentItem = sessionCurrentItem || items.find((item) => item.id === selectedId) || dueItems[0] || practiceItems[0];
  const selectedItem = items.find((item) => item.id === selectedId) || currentItem;
  const archivedCount = liveItems.filter((item) => item.status === "archived").length;
  const deletedCount = deletedItems.length;
  const recommendedTags = currentItem ? suggestTranslationTags(currentItem, frequentTags) : frequentTags.slice(0, 6);
  const pageSize = 10;
  const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
  const currentLibraryPage = Math.min(libraryPage, pageCount);
  const pagedItems = filteredItems.slice((currentLibraryPage - 1) * pageSize, currentLibraryPage * pageSize);
  const pageNumbers = getPageNumbers(currentLibraryPage, pageCount);
  const filteredItemIds = filteredItems.map((item) => item.id);
  const pagedItemIds = pagedItems.map((item) => item.id);
  const selectedVisibleCount = selectedSessionItemIds.filter((itemId) => filteredItemIds.includes(itemId)).length;
  const pageAllSelected = pagedItemIds.length > 0 && pagedItemIds.every((itemId) => selectedSessionItemIds.includes(itemId));
  const filterAllSelected = filteredItemIds.length > 0 && filteredItemIds.every((itemId) => selectedSessionItemIds.includes(itemId));

  useEffect(() => {
    localStorage.setItem("translation_show_meta", showLibraryMeta ? "1" : "0");
  }, [showLibraryMeta]);

  useEffect(() => {
    localStorage.setItem("drill_priority", drillPriority);
  }, [drillPriority]);

  function chooseNextPracticeItem(sourceItems: TranslationItem[], excludedId = currentItem?.id || "") {
    const candidates = sourceItems
      .filter((item) => item.status !== "archived")
      .filter((item) => item.id !== excludedId)
      .filter((item) => matchesImportDateFilter(item.createdAt, dateFilter))
      .filter((item) => selectedTags.length === 0 || selectedTags.every((tag) => (item.tags || []).includes(tag)))
      .sort((a, b) => new Date(a.nextDueAt).getTime() - new Date(b.nextDueAt).getTime());
    return candidates.find((item) => new Date(item.nextDueAt).getTime() <= Date.now()) || candidates[0] || null;
  }

  function moveToNextPractice(sourceItems = items, excludedId = currentItem?.id || "") {
    const next = chooseNextPracticeItem(sourceItems, excludedId);
    setSelectedId(next?.id || "");
    setCompletedItemId(next ? "" : excludedId);
    setUserAnswer("");
    setEvaluation(null);
    setShowEvaluationModal(false);
    setCurrentAttempt(null);
    setNoteDraft("");
    setHintRevealLevel(0);
    return next;
  }

  function activeSessionForMode(mode: "translation" | "listening") {
    const sessionId = mode === "translation" ? activeTranslationSessionId : activeListeningSessionId;
    return drillSessions.find((session) => session.id === sessionId && session.mode === mode && session.status === "active") || null;
  }

  async function createSessionForItems(mode: "translation" | "listening", sessionItems: TranslationItem[], source = "manual", plannedDueAt = "") {
    const uniqueItems = sortDrillItems(Array.from(new Map(sessionItems.map((item) => [item.id, item])).values()), mode, drillPriority, listeningProgress);
    if (!uniqueItems.length) {
      setError("当前没有可加入本次练习的句子。");
      return null;
    }
    setLoading(true);
    setError("");
    try {
      const session = await apiCreateDrillSession({
        mode,
        title: `${mode === "translation" ? "翻译" : "听力"}练习 · ${uniqueItems.length} 句`,
        itemIds: uniqueItems.map((item) => item.id),
        filters: { statusFilter, listeningMasteryFilter, dateFilter, selectedTags, priority: drillPriority },
        source,
        plannedDueAt
      });
      setDrillSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]);
      if (mode === "translation") {
        setActiveTranslationSessionId(session.id);
        setSelectedId(session.itemIds[0] || "");
        setActiveTab("practice");
      } else {
        setActiveListeningSessionId(session.id);
        setSelectedId(session.itemIds[0] || "");
        setActiveTab("listening");
      }
      setCompletedItemId("");
      setNotice(`已开始${session.title}。`);
      return session;
    } catch (err) {
      setError(err instanceof Error ? err.message : "创建本次练习失败");
      return null;
    } finally {
      setLoading(false);
    }
  }

  async function createSessionFromSelection(mode: "translation" | "listening", plannedDueAt = "") {
    const selectedItems = selectedSessionItemIds
      .map((itemId) => items.find((item) => item.id === itemId))
      .filter(Boolean) as TranslationItem[];
    const sourceItems = selectedItems.length ? selectedItems : filteredItems;
    const eligibleItems = sourceItems.filter((item) => !item.deletedAt && (mode === "translation" ? item.status !== "archived" : !item.listeningMastered));
    await createSessionForItems(mode, eligibleItems, selectedItems.length ? "manual-selection" : "current-filter", plannedDueAt);
  }

  async function advanceActiveSession(
    mode: "translation" | "listening",
    finishedId: string,
    latestItems = items,
    latestProgress = listeningProgress
  ) {
    const session = activeSessionForMode(mode);
    if (!session) return false;
    const nextIndex = session.currentIndex + 1;
    const reachedEnd = nextIndex >= session.itemIds.length;
    const latestSessionItems = sessionItemsFromIds(session.itemIds, latestItems);
    const nextRoundItems = sortDrillItems(incompleteDrillItems(mode, latestSessionItems, latestProgress), mode, sessionPriority(session), latestProgress);
    const completed = reachedEnd && nextRoundItems.length === 0;
    const nextItemIds = completed
      ? session.itemIds
      : reachedEnd
        ? nextRoundItems.map((item) => item.id)
        : reorderDrillSessionItemIds({ ...session, currentIndex: nextIndex }, latestItems, sessionPriority(session), latestProgress);
    const nextCurrentIndex = completed ? Math.min(nextIndex, Math.max(0, session.itemIds.length - 1)) : reachedEnd ? 0 : Math.min(nextIndex, Math.max(0, nextItemIds.length - 1));
    const updated = await apiUpdateDrillSession(session.id, {
      currentIndex: nextCurrentIndex,
      status: completed ? "completed" : "active",
      itemIds: nextItemIds
    });
    setDrillSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
    if (completed) {
      if (mode === "translation") setActiveTranslationSessionId("");
      if (mode === "listening") setActiveListeningSessionId("");
      setCompletedItemId(finishedId);
      setNotice("本次练习已完成。");
    } else {
      setSelectedId(updated.itemIds[updated.currentIndex] || "");
      setCompletedItemId("");
      setNotice(reachedEnd ? "本轮已过完，已按策略开始下一轮未掌握卡。" : "已切换到本次练习的下一句。");
    }
    return true;
  }

  async function updateSessionPriority(session: DrillSession, priority: DrillPriority) {
    setDrillPriority(priority);
    setLoading(true);
    setError("");
    try {
      const nextItemIds = reorderDrillSessionItemIds(session, items, priority, listeningProgress);
      const updated = await apiUpdateDrillSession(session.id, {
        itemIds: nextItemIds,
        filters: { ...session.filters, priority }
      });
      setDrillSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
      if (updated.id === activeTranslationSessionId || updated.id === activeListeningSessionId) {
        setSelectedId(updated.itemIds[updated.currentIndex] || "");
      }
      setNotice(priority === "old" ? "已切换为旧卡优先，剩余队列已重排。" : "已切换为新卡优先，剩余队列已重排。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新队列策略失败");
    } finally {
      setLoading(false);
    }
  }

  async function continueDrillSession(session: DrillSession) {
    setLoading(true);
    setError("");
    try {
      const nextItemIds = recoverableDrillSessionItemIds(session, items, listeningProgress);
      if (!nextItemIds.length) {
        setNotice("这个任务里的内容已经全部掌握。");
        return;
      }
      const needsResume = session.status !== "active" || session.currentIndex >= session.itemIds.length;
      const baseSession = needsResume ? { ...session, currentIndex: 0, itemIds: nextItemIds, status: "active" as const } : session;
      const reorderedItemIds = reorderDrillSessionItemIds(baseSession, items, sessionPriority(session), listeningProgress);
      const nextSession = !needsResume && arraysEqual(reorderedItemIds, session.itemIds)
        ? session
        : await apiUpdateDrillSession(session.id, {
          currentIndex: needsResume ? 0 : session.currentIndex,
          status: "active",
          itemIds: reorderedItemIds,
          filters: { ...session.filters, priority: sessionPriority(session) }
        });
      setDrillSessions((current) => current.map((item) => item.id === nextSession.id ? nextSession : item));
      if (nextSession.mode === "translation") {
        setActiveTranslationSessionId(nextSession.id);
        setActiveTab("practice");
      } else {
        setActiveListeningSessionId(nextSession.id);
        setActiveTab("listening");
      }
      setSelectedId(nextSession.itemIds[nextSession.currentIndex] || "");
      setCompletedItemId("");
    } catch (err) {
      setError(err instanceof Error ? err.message : "继续任务失败");
    } finally {
      setLoading(false);
    }
  }

  async function stopActiveSession(mode: "translation" | "listening") {
    const session = activeSessionForMode(mode);
    if (!session) return;
    setLoading(true);
    try {
      const updated = await apiUpdateDrillSession(session.id, { status: "stopped" });
      setDrillSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
      if (mode === "translation") setActiveTranslationSessionId("");
      if (mode === "listening") setActiveListeningSessionId("");
      setNotice("已中止当前练习。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "中止本次练习失败");
    } finally {
      setLoading(false);
    }
  }

  async function archiveDrillSession(session: DrillSession) {
    setLoading(true);
    setError("");
    try {
      const updated = await apiUpdateDrillSession(session.id, { status: "archived" });
      setDrillSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
      if (session.id === activeTranslationSessionId) setActiveTranslationSessionId("");
      if (session.id === activeListeningSessionId) setActiveListeningSessionId("");
      if (session.id === selectedDrillSessionId) setActiveTab(session.mode === "translation" ? "practice" : "listening");
      setNotice("练习记录已归档。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "归档练习记录失败");
    } finally {
      setLoading(false);
    }
  }

  async function restoreDrillSession(session: DrillSession) {
    setLoading(true);
    setError("");
    try {
      const updated = await apiUpdateDrillSession(session.id, { status: "stopped" });
      setDrillSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
      setNotice("练习记录已恢复到履历。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "恢复练习记录失败");
    } finally {
      setLoading(false);
    }
  }

  async function refresh() {
    try {
      const nextItems = await apiGetTranslationItems(true);
      setItems(nextItems);
      return nextItems;
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取翻译练习失败");
      return items;
    }
  }

  async function refreshListeningProgress() {
    try {
      const nextProgress = await apiGetListeningProgress();
      setListeningProgress(nextProgress);
      return nextProgress;
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取听力练习进度失败");
      return listeningProgress;
    }
  }

  async function refreshDifficultWords() {
    try {
      const nextWords = await apiGetListeningDifficultWords();
      setDifficultWords(nextWords);
      return nextWords;
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取听力难词失败");
      return difficultWords;
    }
  }

  async function refreshDrillSessions() {
    try {
      const nextSessions = await apiGetDrillSessions();
      setDrillSessions(nextSessions);
      return nextSessions;
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取练习履历失败");
      return drillSessions;
    }
  }

  function updateTranslationItemInState(updatedItem: TranslationItem) {
    const normalized = normalizeTranslationItem(updatedItem);
    setItems((current) => current.map((item) => (item.id === normalized.id ? normalized : item)));
  }

  function captureDrillUndo(mode: "translation" | "listening", item: TranslationItem, message: string): DrillUndoEntry {
    return {
      mode,
      item: { ...item, tags: [...item.tags], keyExpressions: [...item.keyExpressions], keyExpressionReadings: { ...(item.keyExpressionReadings || {}) }, focusExpressions: [...(item.focusExpressions || [])], listeningTokens: [...item.listeningTokens] },
      progress: listeningProgress[item.id] ? { ...listeningProgress[item.id] } : null,
      session: activeSessionForMode(mode) ? { ...activeSessionForMode(mode)!, itemIds: [...activeSessionForMode(mode)!.itemIds], filters: { ...activeSessionForMode(mode)!.filters } } : null,
      message
    };
  }

  function pushDrillUndo(entry: DrillUndoEntry) {
    setDrillUndoStack((current) => [entry, ...current].slice(0, 20));
  }

  async function undoLastDrillAction() {
    const entry = drillUndoStack[0];
    if (!entry) return;
    setLoading(true);
    setError("");
    try {
      const restoredItem = await apiRestoreTranslationState(entry.item, entry.progress);
      updateTranslationItemInState(restoredItem);
      if (entry.mode === "listening") {
        setListeningProgress((current) => {
          const next = { ...current };
          if (entry.progress) next[entry.item.id] = entry.progress;
          else delete next[entry.item.id];
          return next;
        });
      }
      if (entry.session) {
        const restoredSession = await apiUpdateDrillSession(entry.session.id, {
          currentIndex: entry.session.currentIndex,
          status: entry.session.status,
          itemIds: entry.session.itemIds,
          filters: entry.session.filters
        });
        setDrillSessions((current) => current.map((session) => session.id === restoredSession.id ? restoredSession : session));
        if (entry.mode === "translation") setActiveTranslationSessionId(restoredSession.id);
        if (entry.mode === "listening") setActiveListeningSessionId(restoredSession.id);
      }
      setActiveTab(entry.mode === "translation" ? "practice" : "listening");
      setSelectedId(entry.item.id);
      setCompletedItemId("");
      setDrillUndoStack((current) => current.slice(1));
      setNotice(`已撤销：${entry.message}`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "撤销失败");
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    refresh();
    refreshListeningProgress();
    refreshDifficultWords();
    refreshDrillSessions();
  }, []);

  async function importSentences() {
    const sentences = batchText.split("\n").map((line) => line.trim()).filter(Boolean);
    const tags = batchTagText.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean);
    if (!sentences.length) return;
    setLoading(true);
    setError("");
    setNotice("已提交导入解析任务，可以在任务进度里查看或中止。");
    try {
      const result = await apiImportTranslationSentences(sentences, tags);
      setBatchText("");
      setBatchTagText("");
      onParseTaskCreated(result.task);
      const parts = [`任务已创建：${sentences.length} 条`];
      if (tags.length) parts.push(`标签：${tags.join(" / ")}`);
      setNotice(`${parts.join("，")}。`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "导入失败");
    } finally {
      setLoading(false);
    }
  }

  async function generateInspiration() {
    if (!inspirationScene.trim()) {
      setError("请先输入想练习的场景。");
      return;
    }
    setInspirationLoading(true);
    setError("");
    setNotice("");
    try {
      const result = await apiGenerateTranslationInspiration({
        scene: inspirationScene.trim(),
        domain: inspirationDomain.trim(),
        terms: inspirationTerms.split(/[,，\n]/).map((term) => term.trim()).filter(Boolean),
        goal: inspirationGoal,
        count: inspirationCount,
        level: inspirationLevel,
        notes: inspirationNotes.trim()
      });
      setInspirationResult(result);
      if (!batchTagText.trim() && result.tags.length) setBatchTagText(result.tags.join(", "));
      setNotice(`已生成 ${result.sentences.length} 条灵感句子。`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "生成灵感失败");
    } finally {
      setInspirationLoading(false);
    }
  }

  function appendInspiredSentences(sentences = inspirationResult?.sentences || []) {
    if (!sentences.length) return;
    setBatchText((current) => [current.trim(), sentences.join("\n")].filter(Boolean).join("\n"));
    if (inspirationResult?.tags.length) {
      const nextTags = Array.from(new Set([
        ...batchTagText.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean),
        ...inspirationResult.tags
      ]));
      setBatchTagText(nextTags.join(", "));
    }
    setNotice("已追加到导入文本框。");
  }

  function replaceWithInspiredSentences(sentences = inspirationResult?.sentences || []) {
    if (!sentences.length) return;
    setBatchText(sentences.join("\n"));
    if (inspirationResult?.tags.length) setBatchTagText(inspirationResult.tags.join(", "));
    setNotice("已替换导入文本框内容。");
  }

  async function evaluate() {
    if (!currentItem || !userAnswer.trim()) return;
    setLoading(true);
    setError("");
    try {
      const result = await apiEvaluateTranslation(currentItem.id, userAnswer, noteDraft);
      setEvaluation(result.evaluation);
      setShowEvaluationModal(true);
      setCurrentAttempt(result.attempt);
      setAttempts([result.attempt, ...attempts.filter((attempt) => attempt.id !== result.attempt.id)]);
    } catch (err) {
      setError(err instanceof Error ? err.message : "AI 评估失败");
    } finally {
      setLoading(false);
    }
  }

  async function schedule(intervalMinutes: number) {
    if (!currentItem) return;
    const finishedId = currentItem.id;
    pushDrillUndo(captureDrillUndo("translation", currentItem, "翻译复习安排"));
    setLoading(true);
    try {
      await apiScheduleTranslation(currentItem.id, intervalMinutes);
      const nextItems = await refresh();
      if (await advanceActiveSession("translation", finishedId, nextItems)) return;
      const next = moveToNextPractice(nextItems, finishedId);
      setNotice(next ? "已设置下次练习时间，并切换到下一条。" : "已设置下次练习时间。当前筛选下没有更多待练句子。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "设置复习间隔失败");
    } finally {
      setLoading(false);
    }
  }

  async function archive() {
    if (!currentItem) return;
    const archivedId = currentItem.id;
    pushDrillUndo(captureDrillUndo("translation", currentItem, "翻译归档"));
    setLoading(true);
    try {
      await apiArchiveTranslation(currentItem.id);
      const nextItems = await refresh();
      if (await advanceActiveSession("translation", archivedId, nextItems)) return;
      const next = moveToNextPractice(nextItems, archivedId);
      setNotice(next ? "已归档，并切换到下一条。" : "已归档。当前筛选下没有更多待练句子。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "归档失败");
    } finally {
      setLoading(false);
    }
  }

  async function setTranslationArchived(item: TranslationItem, archived: boolean) {
    setLoading(true);
    setError("");
    try {
      const updated = archived ? await apiArchiveTranslation(item.id) : await apiScheduleTranslation(item.id, 0);
      updateTranslationItemInState(updated);
      setSelectedId(updated.id);
      setCompletedItemId("");
      setNotice(archived ? "已标记为翻译已掌握。" : "已恢复为翻译待练。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新翻译状态失败");
    } finally {
      setLoading(false);
    }
  }

  async function setListeningMastered(item: TranslationItem, mastered: boolean) {
    setLoading(true);
    setError("");
    try {
      const updated = await apiSetListeningMastered(item.id, mastered, 0, 0, item.listeningTokens.length);
      updateTranslationItemInState(updated);
      setSelectedId(updated.id);
      setNotice(mastered ? "已标记为听力已掌握。" : "已恢复为听力未掌握。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "更新听力状态失败");
    } finally {
      setLoading(false);
    }
  }

  function openTranslationPractice(item: TranslationItem) {
    createSessionForItems("translation", [item], "detail");
  }

  function openListeningPractice(item: TranslationItem) {
    createSessionForItems("listening", [item], "detail");
  }

  async function skipCurrent() {
    if (!currentItem) return;
    pushDrillUndo(captureDrillUndo("translation", currentItem, "跳过翻译卡片"));
    if (await advanceActiveSession("translation", currentItem.id)) return;
    const next = moveToNextPractice(items, currentItem.id);
    setNotice(next ? "已跳过当前句子。" : "当前筛选下没有更多可练句子。");
  }

  async function updateTags() {
    if (!currentItem) return;
    const tags = tagDraft.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean);
    setLoading(true);
    try {
      const updated = await apiUpdateTranslation(currentItem.id, { tags });
      await refresh();
      setSelectedId(updated.id);
      setCompletedItemId("");
      setNotice("标签已保存。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存标签失败");
    } finally {
      setLoading(false);
    }
  }

  async function updateSelectedItemTags(item: TranslationItem, tagsText: string) {
    const tags = tagsText.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean);
    setLoading(true);
    try {
      const updated = await apiUpdateTranslation(item.id, { tags });
      updateTranslationItemInState(updated);
      setSelectedId(updated.id);
      setNotice("标签已保存。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存标签失败");
    } finally {
      setLoading(false);
    }
  }

  async function toggleFocusExpression(expression: string) {
    if (!currentItem) return;
    const currentFocus = currentItem.focusExpressions || [];
    const nextFocus = currentFocus.includes(expression)
      ? currentFocus.filter((item) => item !== expression)
      : [...currentFocus, expression];
    updateTranslationItemInState({ ...currentItem, focusExpressions: nextFocus });
    try {
      const updated = await apiUpdateTranslation(currentItem.id, { focusExpressions: nextFocus });
      updateTranslationItemInState(updated);
    } catch (err) {
      updateTranslationItemInState(currentItem);
      setError(err instanceof Error ? err.message : "保存重点表达失败");
    }
  }

  async function ensureCurrentKeyExpressionReadings() {
    if (!currentItem?.keyExpressions.length) return;
    const readings = currentItem.keyExpressionReadings || {};
    if (currentItem.keyExpressions.every((expression) => readings[expression])) return;
    try {
      const updated = await apiEnsureKeyExpressionReadings(currentItem.id);
      updateTranslationItemInState(updated);
    } catch (err) {
      setError(err instanceof Error ? err.message : "生成重点表达读音失败");
    }
  }

  function addTag(tag: string) {
    const next = Array.from(new Set([...tagDraft.split(/[,，\n]/).map((item) => item.trim()).filter(Boolean), tag]));
    setTagDraft(next.join(", "));
  }

  async function deleteItem(itemId: string) {
    setLoading(true);
    try {
      await apiDeleteTranslation(itemId);
      if (selectedId === itemId) setSelectedId("");
      if (completedItemId === itemId) setCompletedItemId("");
      setSelectedSessionItemIds((current) => current.filter((id) => id !== itemId));
      setUserAnswer("");
      setEvaluation(null);
      setShowEvaluationModal(false);
      setCurrentAttempt(null);
      setNoteDraft("");
      await refresh();
      setNotice("句子已移入回收站。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "删除失败");
    } finally {
      setLoading(false);
    }
  }

  function setSelectionForIds(itemIds: string[], selected: boolean) {
    setSelectedSessionItemIds((current) => selected
      ? Array.from(new Set([...current, ...itemIds]))
      : current.filter((itemId) => !itemIds.includes(itemId)));
  }

  function toggleLibraryTag(tag: string) {
    setSelectedTags((current) => current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag]);
    setSelectedId("");
    setCompletedItemId("");
    setLibraryPage(1);
  }

  async function deleteSelectedItems(scope: "page" | "filter") {
    const itemIds = (scope === "page" ? pagedItemIds : filteredItemIds).filter((itemId) => selectedSessionItemIds.includes(itemId));
    if (!itemIds.length) return;
    setLoading(true);
    setError("");
    try {
      await apiDeleteTranslations(itemIds);
      setSelectedSessionItemIds((current) => current.filter((itemId) => !itemIds.includes(itemId)));
      if (selectedId && itemIds.includes(selectedId)) setSelectedId("");
      await refresh();
      setNotice(`已将 ${itemIds.length} 句移入回收站。`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "批量删除失败");
    } finally {
      setLoading(false);
    }
  }

  async function restoreSelectedItems(scope: "page" | "filter") {
    const itemIds = (scope === "page" ? pagedItemIds : filteredItemIds).filter((itemId) => selectedSessionItemIds.includes(itemId));
    if (!itemIds.length) return;
    setLoading(true);
    setError("");
    try {
      await apiRestoreTranslations(itemIds);
      setSelectedSessionItemIds((current) => current.filter((itemId) => !itemIds.includes(itemId)));
      await refresh();
      setNotice(`已从回收站恢复 ${itemIds.length} 句。`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "恢复失败");
    } finally {
      setLoading(false);
    }
  }

  async function restoreItem(itemId: string) {
    setLoading(true);
    setError("");
    try {
      await apiRestoreTranslations([itemId]);
      setSelectedSessionItemIds((current) => current.filter((id) => id !== itemId));
      await refresh();
      setNotice("句子已从回收站恢复。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "恢复失败");
    } finally {
      setLoading(false);
    }
  }

  async function addExpressionToCorpus(expression: string) {
    if (!expression.trim() || !currentItem) return;
    setAddingExpression(expression.trim());
    setError("");
    const item: CorpusItem = {
      id: id("corpus"),
      chunk: expression.trim(),
      meaningZh: currentItem.zhSentence,
      usageScene: "中译日练习中需要巩固的表达",
      exampleJa: currentItem.recommendedJa || evaluation?.correctedJa || expression.trim(),
      masteryStatus: "未练习",
      tags: currentItem.tags || [],
      status: "active",
      sourceType: "translation",
      sourceLabel: sourceLabel("translation"),
      sourceRef: currentItem.id,
      detailStatus: "pending"
    };
    try {
      await onCorpusChange(mergeCorpus(appState.corpusItems, [item]));
      setAddedExpressions((current) => Array.from(new Set([...current, expression.trim()])));
      setNotice(`已添加corpus：${expression.trim()}`);
    } catch (err) {
      setError(err instanceof Error ? err.message : "添加corpus失败");
    } finally {
      setAddingExpression("");
    }
  }

  async function transcribeAnswerRecording() {
    if (!answerRecorder.audioBlob) return;
    setAnswerTranscribeError("");
    setAnswerTranscribing(true);
    try {
      const text = await apiTranscribeAudio(answerRecorder.audioBlob, "ja");
      setUserAnswer((current) => [current.trim(), text].filter(Boolean).join("\n"));
    } catch (err) {
      setAnswerTranscribeError(err instanceof Error ? err.message : "录音转写失败");
    } finally {
      setAnswerTranscribing(false);
    }
  }

  async function refreshAttempts(itemId: string) {
    if (!itemId) {
      setAttempts([]);
      return;
    }
    try {
      setAttempts(await apiGetTranslationAttempts(itemId));
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取练习履历失败");
    }
  }

  async function saveAttemptNote() {
    if (!currentAttempt) return;
    setLoading(true);
    try {
      const updated = await apiUpdateTranslationAttemptNote(currentAttempt.id, noteDraft);
      setCurrentAttempt(updated);
      setAttempts(attempts.map((attempt) => (attempt.id === updated.id ? updated : attempt)));
      setNotice("备注已保存。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "保存备注失败");
    } finally {
      setLoading(false);
    }
  }

  const displayedItem = activeTab === "detail" ? selectedItem : currentItem;
  const addableExpressions = Array.from(new Set([...(currentItem?.keyExpressions || []), ...(evaluation?.missedExpressions || [])].filter(Boolean)));
  const focusExpressions = currentItem?.focusExpressions || [];
  const isRepeatTranslationPractice = attempts.length > 0;
  const visibleHintLevel = hintRevealLevel;

  useEffect(() => {
    if (!showEvaluationModal) return;
    function closeOnEscape(event: KeyboardEvent) {
      if (event.key === "Escape") setShowEvaluationModal(false);
    }
    window.addEventListener("keydown", closeOnEscape);
    return () => window.removeEventListener("keydown", closeOnEscape);
  }, [showEvaluationModal]);

  useEffect(() => {
    setTagDraft((displayedItem?.tags || []).join(", "));
    setUserAnswer("");
    setEvaluation(null);
    setShowEvaluationModal(false);
    setCurrentAttempt(null);
    setNoteDraft("");
    setAddedExpressions([]);
    setAnswerTranscribeError("");
    setPracticePanel("review");
    setMobilePracticePage("answer");
    setHintRevealLevel(0);
    refreshAttempts(displayedItem?.id || "");
  }, [displayedItem?.id]);

  useEffect(() => {
    if (practicePanel === "hint" && visibleHintLevel >= 2) {
      ensureCurrentKeyExpressionReadings();
    }
  }, [practicePanel, visibleHintLevel, currentItem?.id]);

  return (
    <section>
      <div className={(activeTab === "practice" && activeTranslationSession) || (activeTab === "listening" && activeListeningSession) ? "hidden md:block" : ""}>
        <Header eyebrow="Sentence Studio" title="句子训练" description="同一个句库可用于翻译练习、听力练习和复习管理。" />
      </div>
      {notice && <p className="mb-4 rounded-2xl bg-things-50 px-4 py-3 text-sm font-semibold text-things-800">{notice}</p>}
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      <ModuleTabs
        themeKey="translation"
        className={`mb-4 ${(activeTab === "practice" && activeTranslationSession) || (activeTab === "listening" && activeListeningSession) ? "hidden md:inline-flex" : ""}`}
        active={activeTab === "detail" || activeTab === "session" ? "library" : activeTab}
        onChange={(key) => setActiveTab(key)}
        tabs={[
          { key: "practice", label: "翻译练习", icon: "訳" },
          { key: "listening", label: "听力练习", icon: "耳" },
          { key: "library", label: "句库", icon: "庫" },
          { key: "import", label: "导入", icon: "+" }
        ]}
      />

      {activeTab === "import" && (
        <div className="grid gap-4">
          <div className="rounded-2xl border border-line bg-white p-5">
            <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
              <div>
                <div className="text-sm font-semibold text-things-700">批量导入源句</div>
                <p className="mt-1 text-xs text-slate-500">提交后会创建全局解析任务，进度和中止入口在“解析任务”模块统一管理。</p>
              </div>
              <SecondaryButton onClick={onOpenParseTasks}>查看解析任务</SecondaryButton>
            </div>
            <label className="mt-3 grid gap-1.5">
              <span className="text-sm font-semibold text-slate-700">本批标签</span>
              <input
                className="input"
                value={batchTagText}
                onChange={(event) => setBatchTagText(event.target.value)}
                placeholder="用逗号分隔，例如：自我介绍, 面试, 敬语"
              />
            </label>
            <textarea
              className="textarea mt-3 min-h-56"
              value={batchText}
              onChange={(event) => setBatchText(event.target.value)}
              placeholder={"每行一个源句，可中文也可日语\n例如：\n我想先简单介绍一下自己。\nまずは簡単に自己紹介をさせていただきます。"}
            />
            <div className="mobile-action-grid mt-3">
              <PrimaryButton onClick={importSentences} disabled={loading || !batchText.trim()}>{loading ? "提交中..." : "创建导入解析任务"}</PrimaryButton>
            </div>
          </div>

          <section className="rounded-2xl border border-line bg-white p-5">
            <button
              type="button"
              onClick={() => setShowInspiration(!showInspiration)}
              className="flex w-full items-start justify-between gap-3 text-left"
            >
              <span>
                <span className="block text-sm font-semibold text-things-700">灵感生成</span>
                <span className="mt-1 block text-xs leading-5 text-slate-500">按场景、领域和特定名词自动生成可导入的练习句子。</span>
              </span>
              <span className="rounded-full bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-600">{showInspiration ? "折叠" : "展开"}</span>
            </button>

            {showInspiration && (
              <div className="mt-4 border-t border-line pt-4">
                <div className="grid gap-3 md:grid-cols-2">
                  <label className="grid gap-1.5">
                    <span className="text-sm font-semibold text-slate-700">练习场景</span>
                    <input
                      className="input"
                      value={inspirationScene}
                      onChange={(event) => setInspirationScene(event.target.value)}
                      placeholder="例如：面试自我介绍 / 餐厅点餐"
                    />
                  </label>
                  <label className="grid gap-1.5">
                    <span className="text-sm font-semibold text-slate-700">领域</span>
                    <input
                      className="input"
                      value={inspirationDomain}
                      onChange={(event) => setInspirationDomain(event.target.value)}
                      placeholder="例如：IT 项目管理 / 医疗 / 留学生活"
                    />
                  </label>
                </div>
                <label className="mt-3 grid gap-1.5">
                  <span className="text-sm font-semibold text-slate-700">特定名词 / 关键词</span>
                  <input
                    className="input"
                    value={inspirationTerms}
                    onChange={(event) => setInspirationTerms(event.target.value)}
                    placeholder="用逗号分隔，例如：React, API, 納期, 要件定義"
                  />
                </label>
                <div className="mt-3 grid gap-3 md:grid-cols-3">
                  <label className="grid gap-1.5">
                    <span className="text-sm font-semibold text-slate-700">目标</span>
                    <select className="input" value={inspirationGoal} onChange={(event) => setInspirationGoal(event.target.value)}>
                      <option>听力 + 口语</option>
                      <option>听力</option>
                      <option>口语</option>
                      <option>翻译</option>
                    </select>
                  </label>
                  <label className="grid gap-1.5">
                    <span className="text-sm font-semibold text-slate-700">数量</span>
                    <input
                      className="input"
                      type="number"
                      min={3}
                      max={30}
                      value={inspirationCount}
                      onChange={(event) => setInspirationCount(Math.max(3, Math.min(30, Number(event.target.value) || 10)))}
                    />
                  </label>
                  <label className="grid gap-1.5">
                    <span className="text-sm font-semibold text-slate-700">难度 / 风格</span>
                    <input
                      className="input"
                      value={inspirationLevel}
                      onChange={(event) => setInspirationLevel(event.target.value)}
                      placeholder="例如：N3、商务、日常自然"
                    />
                  </label>
                </div>
                <label className="mt-3 grid gap-1.5">
                  <span className="text-sm font-semibold text-slate-700">补充要求</span>
                  <textarea
                    className="textarea min-h-24"
                    value={inspirationNotes}
                    onChange={(event) => setInspirationNotes(event.target.value)}
                    placeholder="例如：多包含敬语、适合跟读、句子不要太长"
                  />
                </label>
                <div className="mobile-action-grid mt-3">
                  <PrimaryButton onClick={generateInspiration} disabled={inspirationLoading || !inspirationScene.trim()}>
                    {inspirationLoading ? "生成中..." : "生成句子"}
                  </PrimaryButton>
                </div>

                {inspirationResult && (
                  <div className="mt-4 rounded-xl bg-slate-50 p-4">
                    <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
                      <div className="min-w-0">
                        <div className="text-sm font-semibold text-ink">{inspirationResult.title}</div>
                        <div className="mt-1 text-xs text-slate-400">{inspirationResult.sentences.length} 条</div>
                        {inspirationResult.tags.length > 0 && (
                          <div className="mt-2 flex flex-wrap gap-1.5">
                            {inspirationResult.tags.map((tag) => <span key={tag} className="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-things-700 shadow-hairline"># {tag}</span>)}
                          </div>
                        )}
                      </div>
                      <div className="mobile-action-grid shrink-0">
                        <PrimaryButton onClick={() => appendInspiredSentences()} disabled={!inspirationResult.sentences.length}>追加到导入</PrimaryButton>
                        <SecondaryButton onClick={() => replaceWithInspiredSentences()} disabled={!inspirationResult.sentences.length}>替换导入</SecondaryButton>
                      </div>
                    </div>
                    <div className="mt-3 max-h-72 overflow-auto rounded-xl bg-white p-3 shadow-hairline">
                      {inspirationResult.sentences.map((sentence, index) => (
                        <div key={`${sentence}_${index}`} className="border-b border-slate-100 py-2 text-sm leading-6 text-slate-700 last:border-0">
                          {sentence}
                        </div>
                      ))}
                    </div>
                  </div>
                )}
              </div>
            )}
          </section>
        </div>
      )}

      {activeTab === "session" && selectedDrillSession && (
        <DrillSessionDetail
          session={selectedDrillSession}
          items={items}
          progress={listeningProgress}
          loading={loading}
          onBack={() => setActiveTab(selectedDrillSession.mode === "translation" ? "practice" : "listening")}
          onContinue={(session) => {
            continueDrillSession(session);
          }}
          onPriorityChange={(priority) => updateSessionPriority(selectedDrillSession, priority)}
          onOpenItem={(itemId) => {
            setSelectedId(itemId);
            setCompletedItemId("");
            setDetailReturnTab("session");
            setActiveTab("detail");
          }}
        />
      )}

      {activeTab === "library" && (
        <div className="grid gap-4">
          <div className="grid grid-cols-2 gap-2 md:grid-cols-4">
            <Metric label="待练" value={`${activeDueCount}`} />
            <Metric label="总数" value={`${filteredItems.length}`} />
            <Metric label="归档" value={`${archivedCount}`} />
            <Metric label="回收站" value={`${deletedCount}`} />
          </div>
          <div className="rounded-2xl border border-line bg-white p-4">
            <div className="mb-3 flex items-end justify-between gap-3">
              <div>
                <div className="text-2xl font-semibold tracking-normal text-ink">练习句库</div>
                <p className="mt-0.5 text-xs text-slate-500">按状态、时间和标签筛选。</p>
              </div>
              <div className="flex items-center gap-3">
                <button
                  onClick={() => setShowLibrarySettings(!showLibrarySettings)}
                  className="rounded-full bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-things-50 hover:text-things-800"
                >
                  设置
                </button>
                <button
                  onClick={() => {
                    setLibraryTrashMode(!libraryTrashMode);
                    setSelectedSessionItemIds([]);
                    setSelectedId("");
                    setLibraryPage(1);
                  }}
                  className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                    libraryTrashMode ? "bg-red-50 text-red-700 hover:bg-red-100" : "bg-slate-50 text-slate-600 hover:bg-things-50 hover:text-things-800"
                  }`}
                >
                  {libraryTrashMode ? "退出回收站" : `回收站 ${deletedCount}`}
                </button>
                <div className="hidden text-xs font-semibold text-slate-400 md:block">
                  {filteredItems.length ? `${(currentLibraryPage - 1) * pageSize + 1}-${Math.min(currentLibraryPage * pageSize, filteredItems.length)} / ${filteredItems.length} 条` : "0 条"}
                </div>
              </div>
            </div>
            {showLibrarySettings && (
              <div className="mb-3 rounded-2xl bg-slate-50 p-3">
                <label className="flex items-center justify-between gap-3">
                  <span>
                    <span className="block text-sm font-semibold text-ink">展示句子元数据</span>
                    <span className="block text-xs text-slate-500">原句、状态、导入时间、更新时间、下次复习时间</span>
                  </span>
                  <input
                    type="checkbox"
                    checked={showLibraryMeta}
                    onChange={(event) => setShowLibraryMeta(event.target.checked)}
                    className="h-5 w-5 accent-things-600"
                  />
                </label>
              </div>
            )}
            <div className="mb-4 grid gap-2 rounded-2xl bg-slate-50 p-2">
              {libraryTrashMode && (
                <div className="rounded-xl bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
                  回收站里的句子不会进入练习队列；选中后可以恢复。
                </div>
              )}
              <div className="grid gap-2 lg:grid-cols-[64px_1fr] lg:items-center">
                <div className="text-xs font-semibold text-slate-500">翻译</div>
                <div className="flex flex-wrap gap-1.5">
                {[
                  { key: "all", label: "全部翻译" },
                  { key: "active", label: "翻译待练" },
                  { key: "archived", label: "翻译已掌握" }
                ].map((option) => (
                  <FilterPill
                    key={option.key}
                    active={statusFilter === option.key}
                    onClick={() => {
                      setStatusFilter(option.key as "active" | "archived" | "all");
                      setSelectedId("");
                      setCompletedItemId("");
                      setLibraryPage(1);
                    }}
                  >
                    {option.label}
                  </FilterPill>
                ))}
                </div>
              </div>
              <div className="grid gap-2 lg:grid-cols-[64px_1fr] lg:items-center">
                <div className="text-xs font-semibold text-slate-500">听力</div>
                <div className="flex flex-wrap gap-1.5">
                {[
                  { key: "all", label: "全部听力" },
                  { key: "learning", label: "听力未掌握" },
                  { key: "mastered", label: "听力已掌握" }
                ].map((option) => (
                  <FilterPill
                    key={option.key}
                    active={listeningMasteryFilter === option.key}
                    onClick={() => {
                      setListeningMasteryFilter(option.key as "all" | "learning" | "mastered");
                      setSelectedId("");
                      setCompletedItemId("");
                      setLibraryPage(1);
                    }}
                  >
                    {option.label}
                  </FilterPill>
                ))}
                </div>
              </div>
              <div className="grid gap-2 lg:grid-cols-[64px_1fr] lg:items-center">
                <div className="text-xs font-semibold text-slate-500">时间</div>
                <div className="flex flex-wrap gap-1.5">
                {[
                  { key: "all", label: "全部时间" },
                  { key: "today", label: "今天" },
                  { key: "week", label: "本周" },
                  { key: "older", label: "更早" }
                ].map((option) => (
                  <FilterPill
                    key={option.key}
                    active={dateFilter === option.key}
                    onClick={() => {
                      setDateFilter(option.key as "all" | "today" | "week" | "older");
                      setSelectedTags([]);
                      setSelectedId("");
                      setCompletedItemId("");
                      setLibraryPage(1);
                    }}
                  >
                    {option.label}
                  </FilterPill>
                ))}
                </div>
              </div>
              <div className="grid gap-2 lg:grid-cols-[64px_1fr] lg:items-start">
                <div className="pt-1.5 text-xs font-semibold text-slate-500">标签{selectedTags.length ? ` · ${selectedTags.length}` : ""}</div>
                <div className="flex max-h-20 flex-wrap gap-1.5 overflow-auto pr-1">
                <FilterPill active={selectedTags.length === 0} onClick={() => {
                  setSelectedTags([]);
                  setSelectedId("");
                  setCompletedItemId("");
                  setLibraryPage(1);
                }}>全部标签</FilterPill>
                {allTags.map((tag) => (
                  <FilterPill key={tag} active={selectedTags.includes(tag)} onClick={() => toggleLibraryTag(tag)}>{tag}</FilterPill>
                ))}
                </div>
              </div>
            </div>
            <div className="mb-3 flex flex-col gap-2 rounded-2xl border border-line bg-white p-3 shadow-hairline md:flex-row md:items-center md:justify-between">
              <div className="text-sm font-semibold text-slate-600">已选 {selectedVisibleCount} / {filteredItems.length} 句</div>
              <div className="mobile-action-grid">
                <SecondaryButton onClick={() => setSelectionForIds(pagedItemIds, !pageAllSelected)} disabled={!pagedItemIds.length}>
                  {pageAllSelected ? "取消本页" : "全选本页"}
                </SecondaryButton>
                <SecondaryButton onClick={() => setSelectionForIds(filteredItemIds, !filterAllSelected)} disabled={!filteredItemIds.length}>
                  {filterAllSelected ? "取消筛选结果" : "全选筛选结果"}
                </SecondaryButton>
                <SecondaryButton onClick={() => setSelectedSessionItemIds([])} disabled={!selectedSessionItemIds.length}>清空选择</SecondaryButton>
                {libraryTrashMode ? (
                  <PrimaryButton onClick={() => restoreSelectedItems("filter")} disabled={loading || !selectedVisibleCount}>恢复已选</PrimaryButton>
                ) : (
                  <button
                    onClick={() => deleteSelectedItems("filter")}
                    disabled={loading || !selectedVisibleCount}
                    className="rounded-xl bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
                  >
                    删除已选
                  </button>
                )}
              </div>
            </div>
            <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
              {pagedItems.map((item) => (
                <div
                  key={item.id}
                  className={`rounded-xl p-3 text-sm ${item.id === selectedItem?.id ? "bg-things-50 text-things-900 shadow-hairline" : "bg-slate-50 text-slate-600"}`}
                >
                  <label className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
                    <input
                      type="checkbox"
                      checked={selectedSessionItemIds.includes(item.id)}
                      onChange={(event) => {
                        setSelectedSessionItemIds((current) => event.target.checked
                          ? Array.from(new Set([...current, item.id]))
                          : current.filter((itemId) => itemId !== item.id));
                      }}
                      className="h-4 w-4 accent-things-600"
                    />
                    {libraryTrashMode ? "选择恢复" : "加入本次练习"}
                  </label>
                  <button
                    onClick={() => {
                      setSelectedId(item.id);
                      setCompletedItemId("");
                      setDetailReturnTab("library");
                      setActiveTab("detail");
                    }}
                    className="w-full text-left hover:text-things-800"
                  >
                    <div>{item.zhSentence}</div>
                    {(item.tags || []).length > 0 && (
                      <div className="mt-2 flex flex-wrap gap-1">
                        {item.tags.map((tag) => (
                          <span key={tag} className="rounded-full bg-white px-2 py-0.5 text-xs text-things-700">{tag}</span>
                        ))}
                      </div>
                    )}
                    {showLibraryMeta && (
                      <div className="mt-2 grid gap-1 text-xs text-slate-500">
                        {item.sourceSentence && item.sourceSentence !== item.zhSentence && <MetaLine icon="source" label="原句" value={item.sourceSentence} />}
                        <MetaLine icon="status" label="翻译" value={item.status === "archived" ? "已掌握" : "待练"} />
                        <MetaLine icon="status" label="听力" value={item.listeningMastered ? "已掌握" : "未掌握"} />
                        <MetaLine icon="created" label="导入" value={formatDateTime(item.createdAt)} />
                        <MetaLine icon="updated" label="更新" value={formatDateTime(item.updatedAt)} />
                        {item.status !== "archived" && <MetaLine icon="due" label="下次" value={formatDue(item.nextDueAt)} />}
                      </div>
                    )}
                  </button>
                  {libraryTrashMode ? (
                    <button
                      onClick={() => restoreItem(item.id)}
                      disabled={loading}
                      className="mt-2 rounded-lg px-2 py-1 text-xs font-semibold text-things-700 hover:bg-things-50 disabled:cursor-not-allowed disabled:opacity-50"
                    >
                      恢复已选
                    </button>
                  ) : (
                    <button
                      onClick={() => deleteItem(item.id)}
                      disabled={loading}
                      className="mt-2 rounded-lg px-2 py-1 text-xs font-semibold text-slate-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
                    >
                      删除
                    </button>
                  )}
                </div>
              ))}
              {!filteredItems.length && <p className="text-sm text-slate-500">当前筛选下没有句子。</p>}
            </div>
            {filteredItems.length > pageSize && (
              <div className="mt-4 flex flex-col gap-2 border-t border-line pt-3 md:flex-row md:items-center md:justify-between">
                <div className="text-xs font-semibold text-slate-400">
                  第 {currentLibraryPage} / {pageCount} 页，每页 10 条
                </div>
                <div className="flex flex-wrap gap-1.5">
                  <SecondaryButton onClick={() => setLibraryPage(Math.max(1, currentLibraryPage - 1))} disabled={currentLibraryPage <= 1}>上一页</SecondaryButton>
                  {pageNumbers.map((page) => (
                    <button
                      key={page}
                      onClick={() => setLibraryPage(page)}
                      className={`h-9 min-w-9 rounded-xl px-3 text-sm font-semibold ${
                        page === currentLibraryPage ? "bg-things-600 text-white" : "bg-white text-slate-600 shadow-hairline hover:bg-things-50 hover:text-things-800"
                      }`}
                    >
                      {page}
                    </button>
                  ))}
                  <SecondaryButton onClick={() => setLibraryPage(Math.min(pageCount, currentLibraryPage + 1))} disabled={currentLibraryPage >= pageCount}>下一页</SecondaryButton>
                </div>
              </div>
            )}
          </div>
        </div>
      )}

      {activeTab === "detail" && (
        <main className="rounded-2xl border border-line bg-white p-4">
          {!selectedItem ? (
            <Locked title="还没有选中句子" text="从句库中点击一句，先进入句子详情。" />
          ) : (
            <div className="grid gap-4">
              <div className="rounded-2xl bg-slate-50 p-4">
                <div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Sentence Detail</div>
                <h3 className="text-2xl font-semibold leading-9 text-ink">{selectedItem.zhSentence}</h3>
                {selectedItem.sourceSentence && selectedItem.sourceSentence !== selectedItem.zhSentence && (
                  <p className="mt-3 rounded-xl bg-white p-3 text-sm leading-7 text-slate-600 shadow-hairline">{selectedItem.sourceSentence}</p>
                )}
                <div className="mt-4 grid gap-2 text-sm text-slate-600 md:grid-cols-2">
                  <MetaLine icon="created" label="导入" value={formatDateTime(selectedItem.createdAt)} />
                  <MetaLine icon="updated" label="更新" value={formatDateTime(selectedItem.updatedAt)} />
                  <MetaLine icon="status" label="翻译" value={selectedItem.status === "archived" ? "已掌握" : "待练"} />
                  <MetaLine icon="status" label="听力" value={selectedItem.listeningMastered ? "已掌握" : "未掌握"} />
                </div>
                {(selectedItem.tags || []).length > 0 && (
                  <div className="mt-3 flex flex-wrap gap-2">
                    {selectedItem.tags.map((tag) => (
                      <span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-things-700 shadow-hairline">{tag}</span>
                    ))}
                  </div>
                )}
                <div className="mobile-action-grid mt-4">
                  <PrimaryButton onClick={() => openTranslationPractice(selectedItem)}>练这句翻译</PrimaryButton>
                  <SecondaryButton onClick={() => openListeningPractice(selectedItem)} disabled={selectedItem.listeningMastered}>练这句听力</SecondaryButton>
                  <SecondaryButton onClick={() => setActiveTab(detailReturnTab === "session" && selectedDrillSession ? "session" : "library")}>
                    {detailReturnTab === "session" && selectedDrillSession ? "返回任务详情" : "返回句库"}
                  </SecondaryButton>
                </div>
              </div>

              <div className="grid gap-3 md:grid-cols-2">
                <div className="rounded-2xl bg-slate-50 p-4">
                  <div className="text-sm font-semibold text-things-700">翻译练习状态</div>
                  <div className="mt-3 grid gap-2 text-sm text-slate-600">
                    <MetaLine icon="status" label="状态" value={selectedItem.status === "archived" ? "已掌握" : "待练"} />
                    <MetaLine icon="due" label="下次" value={selectedItem.status === "archived" ? "已归档" : formatDue(selectedItem.nextDueAt)} />
                    <MetaLine icon="updated" label="练习次数" value={`${attempts.length} 次`} />
                    {attempts[0] && <MetaLine icon="status" label="最近评分" value={`${attempts[0].score}/100`} />}
                  </div>
                  <div className="mobile-action-grid mt-4">
                    {selectedItem.status === "archived" ? (
                      <SecondaryButton onClick={() => setTranslationArchived(selectedItem, false)} disabled={loading}>恢复为翻译待练</SecondaryButton>
                    ) : (
                      <PrimaryButton onClick={() => setTranslationArchived(selectedItem, true)} disabled={loading}>标记翻译已掌握</PrimaryButton>
                    )}
                    <SecondaryButton onClick={() => openTranslationPractice(selectedItem)}>进入翻译练习</SecondaryButton>
                  </div>
                </div>

                <div className="rounded-2xl bg-slate-50 p-4">
                  <div className="text-sm font-semibold text-things-700">听力练习状态</div>
                  <div className="mt-3 grid gap-2 text-sm text-slate-600">
                    <MetaLine icon="status" label="状态" value={selectedItem.listeningMastered ? "已掌握" : "未掌握"} />
                    <MetaLine icon="due" label="下次" value={formatDue(getListeningDue(selectedItem, listeningProgress).toISOString())} />
                    <MetaLine icon="updated" label="最近评分" value={listeningProgress[selectedItem.id] ? `${listeningProgress[selectedItem.id].lastScore}/100` : "暂无"} />
                    <MetaLine icon="status" label="分词" value={`${selectedItem.listeningTokens.length} 个听力块`} />
                    <MetaLine icon="status" label="难词" value={`${(difficultWords[selectedItem.id] || []).length} 个`} />
                  </div>
                  <div className="mobile-action-grid mt-4">
                    {selectedItem.listeningMastered ? (
                      <SecondaryButton onClick={() => setListeningMastered(selectedItem, false)} disabled={loading}>恢复为听力未掌握</SecondaryButton>
                    ) : (
                      <PrimaryButton onClick={() => setListeningMastered(selectedItem, true)} disabled={loading}>标记听力已掌握</PrimaryButton>
                    )}
                    <SecondaryButton onClick={() => openListeningPractice(selectedItem)} disabled={selectedItem.listeningMastered}>进入听力练习</SecondaryButton>
                  </div>
                </div>
              </div>

              <div className="rounded-2xl bg-slate-50 p-4">
                <div className="text-sm font-semibold text-things-700">句子内容</div>
                <div className="mt-3 grid gap-3 md:grid-cols-2">
                  <Info label="AI 推荐日语" value={selectedItem.recommendedJa} />
                  <Info label="句子结构" value={selectedItem.structureNotes} />
                </div>
                {selectedItem.keyExpressions.length > 0 && (
                  <div className="mt-3 flex flex-wrap gap-2">
                    {selectedItem.keyExpressions.map((expr) => (
                      <span key={expr} className="rounded-full bg-white px-3 py-1.5 text-sm font-semibold text-things-800 shadow-hairline">
                        <ExpressionRuby expression={expr} readings={selectedItem.keyExpressionReadings || {}} />
                      </span>
                    ))}
                  </div>
                )}
              </div>

              <div className="rounded-2xl bg-slate-50 p-4">
                <label className="grid gap-2">
                  <span className="text-sm font-semibold text-things-700">标签</span>
                  <input className="input" value={tagDraft} onChange={(event) => setTagDraft(event.target.value)} placeholder="用逗号分隔，例如：自我介绍, 面试, 敬语" />
                </label>
                {(frequentTags.length > 0 || selectedItem.keyExpressions.length > 0) && (
                  <div className="mt-3 grid gap-2 rounded-xl bg-white p-3 shadow-hairline">
                    <TagSuggestionRow title="推荐标签" tags={suggestTranslationTags(selectedItem, frequentTags)} onAdd={addTag} />
                    {frequentTags.length > 0 && <TagSuggestionRow title="常用标签" tags={frequentTags.slice(0, 8)} onAdd={addTag} />}
                  </div>
                )}
                <div className="mt-3">
                  <SecondaryButton onClick={() => updateSelectedItemTags(selectedItem, tagDraft)} disabled={loading}>保存标签</SecondaryButton>
                </div>
              </div>

              <div className="rounded-2xl border border-line bg-white p-5">
                <div className="text-sm font-semibold text-things-700">翻译练习履历</div>
                <div className="mt-3 grid gap-3">
                  {attempts.map((attempt) => (
                    <article key={attempt.id} className="rounded-xl bg-slate-50 p-4">
                      <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
                        <div className="text-sm font-semibold text-ink">评分：{attempt.score}/100</div>
                        <div className="text-xs text-slate-400">{formatDateTime(attempt.createdAt)}</div>
                      </div>
                      <div className="mt-2 text-sm text-slate-600">你的答案：{attempt.userAnswer}</div>
                      <div className="mt-2 text-sm text-slate-600">修正版：{attempt.correctedJa}</div>
                      {attempt.note && <div className="mt-2 text-sm text-things-800">备注：{attempt.note}</div>}
                    </article>
                  ))}
                  {!attempts.length && <p className="text-sm text-slate-500">这句话还没有翻译练习记录。</p>}
                </div>
              </div>
            </div>
          )}
        </main>
      )}

      {activeTab === "practice" && !activeTranslationSession && (
        <DrillSetup
          mode="translation"
          items={filteredItems.filter((item) => item.status !== "archived")}
          selectedCount={selectedSessionItemIds.length}
          sessions={drillSessions.filter((session) => session.mode === "translation")}
          progress={listeningProgress}
          priority={drillPriority}
          loading={loading}
          onPriorityChange={setDrillPriority}
          onStart={(plannedDueAt) => createSessionFromSelection("translation", plannedDueAt)}
          onClearSelection={() => setSelectedSessionItemIds([])}
          onRefresh={refreshDrillSessions}
          onOpenLibrary={() => setActiveTab("library")}
          onContinue={(session) => {
            continueDrillSession(session);
          }}
          onArchive={archiveDrillSession}
          onRestore={restoreDrillSession}
          showArchived={showArchivedDrillSessions}
          onShowArchivedChange={setShowArchivedDrillSessions}
          onOpenDetail={(session) => {
            setSelectedDrillSessionId(session.id);
            setDetailReturnTab("session");
            setActiveTab("session");
          }}
        />
      )}

      {activeTab === "practice" && activeTranslationSession && (
        <main className="rounded-2xl border border-line bg-white p-3">
          <div className="mb-3 flex items-center justify-between gap-3 border-b border-line pb-3 md:hidden">
            {mobilePracticePage === "tools" ? (
              <button type="button" onClick={() => setMobilePracticePage("answer")} className="text-sm font-semibold text-things-700">‹ 返回答题</button>
            ) : (
              <button type="button" onClick={() => setActiveTranslationSessionId("")} className="text-sm font-semibold text-things-700">‹ 返回任务</button>
            )}
            <span className="text-sm font-semibold text-slate-500">{mobilePracticePage === "tools" ? "辅助工具" : "翻译练习"}</span>
          </div>
          {activeTranslationSession && (
            <div className="mb-3 flex flex-col gap-2 rounded-xl border border-things-100 bg-things-50/70 px-3 py-2 lg:flex-row lg:items-center lg:justify-between">
              <div className="min-w-0 text-sm font-semibold text-things-900">
                <span className="truncate">{activeTranslationSession.title}</span>
                <span className="ml-2 text-things-700">{activeTranslationSession.currentIndex + 1}/{activeTranslationSession.itemIds.length}</span>
              </div>
              <div className="flex flex-wrap gap-1.5">
                <CompactButton onClick={undoLastDrillAction} disabled={loading || !drillUndoStack.length}>↶ 撤销</CompactButton>
                <CompactButton onClick={() => {
                  setSelectedDrillSessionId(activeTranslationSession.id);
                  setDetailReturnTab("session");
                  setActiveTab("session");
                }}>▦ 详情</CompactButton>
                <CompactButton onClick={() => setActiveTranslationSessionId("")}>⇄ 切换/新建</CompactButton>
                <CompactButton onClick={() => stopActiveSession("translation")} disabled={loading}>■ 中止</CompactButton>
              </div>
            </div>
          )}
          {!currentItem ? (
            <Locked
              title={filteredItems.length ? "当前筛选下没有更多可练句子" : "还没有待练句子"}
              text={filteredItems.length ? "可以切到句库选择其他筛选条件，或等待下一次复习时间。" : "批量导入中文句子后，系统会在后台分析并存入数据库。"}
            />
          ) : (
            <div className="grid gap-3">
              {currentItem.status === "archived" && (
                <div className="rounded-xl bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-600">
                  已归档句子：可查看内容和履历，不进入待练队列。
                </div>
              )}
              <div className={`${mobilePracticePage === "tools" ? "hidden md:block" : ""} z-10 rounded-xl border border-line bg-white/95 p-3 shadow-hairline backdrop-blur md:sticky md:top-3`}>
                <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
                  <div className="min-w-0">
                    <div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Chinese Prompt</div>
                    <h3 className="text-lg font-semibold leading-7 text-ink">{currentItem.zhSentence}</h3>
                    <div className="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
                      <span>{currentItem.status === "archived" ? "已归档" : "活跃"}</span>
                      {currentItem.status !== "archived" && <span>下次：{formatDue(currentItem.nextDueAt)}</span>}
                    </div>
                  </div>
                  {currentItem.status !== "archived" && (
                    <SecondaryButton onClick={skipCurrent} disabled={loading}>跳过当前</SecondaryButton>
                  )}
                </div>
                {currentItem.status !== "archived" && (
                  <section className="mt-3 border-t border-line pt-3">
                    <label className="grid gap-1.5">
                      <span className="flex flex-col gap-2 text-sm font-semibold text-slate-700 sm:flex-row sm:items-center sm:justify-between">
                        <span>你的日语回答</span>
                        <span className="flex flex-wrap items-center gap-1.5">
                          <button
                            type="button"
                            onClick={answerRecorder.start}
                            disabled={answerRecorder.isRecording || loading || answerTranscribing}
                            className="grid h-8 w-8 place-items-center rounded-lg bg-white text-xs font-bold text-red-600 shadow-hairline transition hover:bg-red-50 disabled:cursor-not-allowed disabled:text-slate-300"
                            title="录音回答"
                            aria-label="录音回答"
                          >
                            ●
                          </button>
                          <button
                            type="button"
                            onClick={answerRecorder.stop}
                            disabled={!answerRecorder.isRecording}
                            className="grid h-8 w-8 place-items-center rounded-lg bg-white text-xs font-bold text-slate-600 shadow-hairline transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
                            title="停止录音"
                            aria-label="停止录音"
                          >
                            ■
                          </button>
                          <button
                            type="button"
                            onClick={transcribeAnswerRecording}
                            disabled={!answerRecorder.audioBlob || answerRecorder.isRecording || answerTranscribing || loading}
                            className="grid h-8 min-w-8 place-items-center rounded-lg bg-things-50 px-2 text-xs font-bold text-things-800 shadow-hairline transition hover:bg-things-100 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
                            title="转写到答案"
                            aria-label="转写到答案"
                          >
                            {answerTranscribing ? "..." : "⌁"}
                          </button>
                          <button
                            type="button"
                            onClick={() => setUserAnswer("")}
                            disabled={!userAnswer.trim()}
                            className="grid h-8 w-8 place-items-center rounded-lg bg-white text-sm font-bold text-slate-500 shadow-hairline transition hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:text-slate-300"
                            title="清空回答"
                            aria-label="清空回答"
                          >
                            ×
                          </button>
                        </span>
                      </span>
                      <textarea className="textarea min-h-24" value={userAnswer} onChange={(event) => setUserAnswer(event.target.value)} placeholder="试着用日语写出这句话" />
                    </label>
                    {(answerRecorder.isRecording || answerRecorder.audioUrl || answerRecorder.error || answerTranscribeError) && (
                      <div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-semibold">
                        {answerRecorder.isRecording && <span className="rounded-full bg-red-50 px-2.5 py-1 text-red-600">录音中</span>}
                        {answerRecorder.audioUrl && !answerRecorder.isRecording && <span className="rounded-full bg-white px-2.5 py-1 text-slate-500 shadow-hairline">已录音，可转写</span>}
                        {answerRecorder.error && <span className="text-red-600">{answerRecorder.error}</span>}
                        {answerTranscribeError && <span className="text-red-600">{answerTranscribeError}</span>}
                      </div>
                    )}
                    <div className="mt-2 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
                      <label className="grid gap-1.5">
                        <span className="text-sm font-semibold text-slate-700">备注</span>
                        <input className="input" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} placeholder="敬语不稳 / 继续练 / 已掌握" />
                      </label>
                      <PrimaryButton onClick={evaluate} disabled={loading || !userAnswer.trim()}>{loading ? "评估中..." : "AI 评估"}</PrimaryButton>
                    </div>
                  </section>
                )}
              </div>
              {mobilePracticePage === "answer" && (
                <button type="button" onClick={() => setMobilePracticePage("tools")} className="flex w-full items-center justify-between rounded-xl border border-line bg-slate-50 px-4 py-3 text-left text-sm font-semibold text-things-700 md:hidden">
                  <span>提示、复习与履历</span>
                  <span aria-hidden="true">›</span>
                </button>
              )}
              <div className={`${mobilePracticePage === "answer" ? "hidden md:block" : ""} rounded-xl bg-slate-50 p-3`}>
                <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                  <div className="min-w-0">
                    <div className="text-sm font-semibold text-slate-700">标签</div>
                    <div className="mt-1 truncate text-xs text-slate-500">{currentItem.tags.length ? currentItem.tags.join(" / ") : "未设置标签"}</div>
                  </div>
                  <SecondaryButton onClick={() => setShowPracticeTagEditor(!showPracticeTagEditor)}>
                    {showPracticeTagEditor ? "收起标签" : "编辑标签"}
                  </SecondaryButton>
                </div>
                {showPracticeTagEditor && (
                  <div className="mt-3 grid gap-2">
                    <label className="grid gap-1.5">
                      <span className="text-sm font-semibold text-slate-700">标签内容</span>
                      <input className="input" value={tagDraft} onChange={(event) => setTagDraft(event.target.value)} placeholder="用逗号分隔，例如：自我介绍, 面试, 敬语" />
                    </label>
                    {(frequentTags.length > 0 || recommendedTags.length > 0) && (
                      <div className="grid gap-2 rounded-xl bg-white p-2 shadow-hairline">
                        {recommendedTags.length > 0 && (
                          <TagSuggestionRow title="推荐标签" tags={recommendedTags} onAdd={addTag} />
                        )}
                        {frequentTags.length > 0 && (
                          <TagSuggestionRow title="常用标签" tags={frequentTags.slice(0, 8)} onAdd={addTag} />
                        )}
                      </div>
                    )}
                    <div>
                      <SecondaryButton onClick={updateTags} disabled={loading}>保存标签</SecondaryButton>
                    </div>
                  </div>
                )}
              </div>
              {evaluation && (
                <div className={`${mobilePracticePage === "answer" ? "hidden md:block" : ""} rounded-xl border border-line bg-mist p-3`}>
                  <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                    <div>
                      <div className="text-xs font-semibold text-slate-500">AI 评估</div>
                      <div className="mt-1 text-lg font-semibold text-ink">评分：{evaluation.score}/100</div>
                    </div>
                    <PrimaryButton onClick={() => setShowEvaluationModal(true)}>查看 AI 评估</PrimaryButton>
                  </div>
                </div>
              )}
              {evaluation && showEvaluationModal && (
                <div
                  className="fixed inset-0 z-50 flex items-center justify-center bg-ink/35 p-4 backdrop-blur-sm"
                  role="dialog"
                  aria-modal="true"
                  aria-labelledby="translation-evaluation-title"
                  onClick={() => setShowEvaluationModal(false)}
                >
                  <div
                    className="max-h-[85vh] w-full max-w-3xl overflow-auto rounded-2xl border border-line bg-white p-5 shadow-soft"
                    onClick={(event) => event.stopPropagation()}
                  >
                    <div className="flex items-start justify-between gap-4">
                      <div>
                        <div id="translation-evaluation-title" className="text-xl font-semibold text-ink">AI 评估</div>
                        <div className="mt-1 text-2xl font-bold text-things-800">评分：{evaluation.score}/100</div>
                      </div>
                      <button
                        type="button"
                        className="rounded-full bg-mist px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-things-50 hover:text-things-800"
                        onClick={() => setShowEvaluationModal(false)}
                      >
                        关闭
                      </button>
                    </div>
                    <div className="mt-4 rounded-xl bg-mist p-4">
                      <p className="whitespace-pre-wrap text-sm leading-6 text-slate-700">{evaluation.feedback}</p>
                      <Info label="AI 修正版" value={evaluation.correctedJa} />
                      <ShadowingPanel text={evaluation.correctedJa || currentItem.recommendedJa} recorder={shadowingRecorder} compact />
                      {evaluation.missedExpressions.length > 0 && (
                        <div className="mt-3 text-sm text-slate-600">建议继续练：{evaluation.missedExpressions.join(" / ")}</div>
                      )}
                    </div>
                    {currentAttempt && (
                      <div className="mt-3 rounded-xl bg-mist p-3">
                        <label className="grid gap-1.5">
                          <span className="text-sm font-semibold text-things-700">更新本次备注</span>
                          <input className="input" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
                        </label>
                        <div className="mt-2">
                          <SecondaryButton onClick={saveAttemptNote} disabled={loading}>保存备注</SecondaryButton>
                        </div>
                      </div>
                    )}
                    {addableExpressions.length > 0 && (
                      <div className="mt-3 rounded-xl bg-mist p-3">
                        <div className="text-sm font-semibold text-things-700">添加corpus</div>
                        <div className="mt-2 flex flex-wrap gap-2">
                          {addableExpressions.map((expression) => (
                            <SecondaryButton
                              key={expression}
                              onClick={() => addExpressionToCorpus(expression)}
                              disabled={addingExpression === expression || addedExpressions.includes(expression) || appState.corpusItems.some((item) => item.chunk === expression)}
                            >
                              {addingExpression === expression
                                ? `添加中：${expression}`
                                : addedExpressions.includes(expression) || appState.corpusItems.some((item) => item.chunk === expression)
                                  ? `已添加corpus：${expression}`
                                  : `添加corpus：${expression}`}
                            </SecondaryButton>
                          ))}
                        </div>
                      </div>
                    )}
                    {currentItem.status !== "archived" && (
                      <div className="mobile-action-grid mt-4">
                        <SecondaryButton onClick={() => schedule(10)} disabled={loading}>10 分钟后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(1440)} disabled={loading}>1 天后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(4320)} disabled={loading}>3 天后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(10080)} disabled={loading}>7 天后</SecondaryButton>
                        <PrimaryButton onClick={archive} disabled={loading}>完成并归档</PrimaryButton>
                      </div>
                    )}
                  </div>
                </div>
              )}
              <div className={`${mobilePracticePage === "answer" ? "hidden md:block" : ""} z-10 shadow-hairline backdrop-blur md:sticky md:top-[19rem]`}>
                <ModuleTabs
                  themeKey="translation"
                  active={practicePanel}
                  onChange={setPracticePanel}
                  tabs={[
                    { key: "hint", label: "提示", icon: "?" },
                    { key: "review", label: "掌握与复习", icon: "✓" },
                    { key: "history", label: `履历 ${attempts.length}`, icon: "時" }
                  ]}
                />
              </div>
              <section className={`${mobilePracticePage === "answer" ? "hidden md:block" : ""} -mt-3 rounded-b-xl rounded-t-none border border-line bg-white p-3 pt-5`}>
                {practicePanel === "hint" && (
                  <div className="grid gap-3">
                    <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
                      <div>
                        <div className="text-sm font-semibold text-things-700">分级提示</div>
                        <p className="mt-0.5 text-xs text-slate-500">
                          第一次展开完整提示后选择重点表达；第二次起可先看已选重点表达，再展开完整提示。
                        </p>
                      </div>
                      <div className="mobile-action-grid">
                        {isRepeatTranslationPractice && focusExpressions.length > 0 && visibleHintLevel < 1 && (
                          <SecondaryButton onClick={() => setHintRevealLevel(1)}>显示重点表达</SecondaryButton>
                        )}
                        {visibleHintLevel < 2 ? (
                          <PrimaryButton onClick={() => setHintRevealLevel(2)}>显示完整提示</PrimaryButton>
                        ) : (
                          <SecondaryButton onClick={() => setHintRevealLevel(0)}>收起完整提示</SecondaryButton>
                        )}
                      </div>
                    </div>

                    {isRepeatTranslationPractice && visibleHintLevel >= 1 && focusExpressions.length > 0 && (
                      <div className="rounded-xl bg-things-50 p-3">
                        <div className="text-xs font-semibold text-things-700">先看重点表达</div>
                        <div className="mt-2 flex flex-wrap gap-2">
                          {focusExpressions.map((expr) => (
                            <span key={expr} className="rounded-full bg-white px-3 py-1.5 text-sm font-semibold text-things-800 shadow-hairline">
                              <ExpressionRuby expression={expr} readings={currentItem.keyExpressionReadings || {}} />
                            </span>
                          ))}
                        </div>
                      </div>
                    )}

                    {visibleHintLevel >= 2 && (
                      <>
                        <div className="grid gap-3 md:grid-cols-2">
                          <Info label="AI 推荐日语" value={currentItem.recommendedJa} />
                          <Info label="句子结构" value={currentItem.structureNotes} />
                        </div>
                        {currentItem.keyExpressions.length > 0 && (
                          <div className="rounded-xl bg-slate-50 p-3">
                            <div className="mb-2 flex items-center justify-between gap-2">
                              <span className="text-xs font-semibold text-slate-500">重点表达选择</span>
                              <span className="text-xs font-semibold text-slate-400">{focusExpressions.length} / {currentItem.keyExpressions.length}</span>
                            </div>
                            <div className="flex flex-wrap gap-2">
                              {currentItem.keyExpressions.map((expr) => {
                                const selected = focusExpressions.includes(expr);
                                return (
                                  <button
                                    key={expr}
                                    type="button"
                                    onClick={() => toggleFocusExpression(expr)}
                                    className={`rounded-full px-3 py-1.5 text-sm font-semibold transition ${
                                      selected ? "bg-things-600 text-white shadow-soft" : "bg-white text-things-800 shadow-hairline hover:bg-things-50"
                                    }`}
                                  >
                                    <ExpressionRuby expression={expr} readings={currentItem.keyExpressionReadings || {}} />
                                  </button>
                                );
                              })}
                            </div>
                          </div>
                        )}
                        <ShadowingPanel text={currentItem.recommendedJa} recorder={shadowingRecorder} compact />
                      </>
                    )}

                    {!isRepeatTranslationPractice && visibleHintLevel < 2 && (
                      <p className="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-500">这是第一次练这句。展开完整提示后，可以选择下次优先展示的重点表达。</p>
                    )}
                  </div>
                )}

                {practicePanel === "review" && (
                  <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                    <div>
                      <div className="text-sm font-semibold text-things-700">掌握与复习</div>
                      <p className="mt-0.5 text-xs text-slate-500">不需要 AI 评估也可以直接安排复习或标记翻译已掌握。</p>
                    </div>
                    {currentItem.status === "archived" ? (
                      <SecondaryButton onClick={() => setTranslationArchived(currentItem, false)} disabled={loading}>恢复为翻译待练</SecondaryButton>
                    ) : (
                      <div className="mobile-action-grid">
                        <SecondaryButton onClick={() => schedule(10)} disabled={loading}>10 分钟后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(1440)} disabled={loading}>1 天后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(4320)} disabled={loading}>3 天后</SecondaryButton>
                        <SecondaryButton onClick={() => schedule(10080)} disabled={loading}>7 天后</SecondaryButton>
                        <PrimaryButton onClick={archive} disabled={loading}>标记翻译已掌握</PrimaryButton>
                      </div>
                    )}
                  </div>
                )}

                {practicePanel === "history" && (
                  <div className="grid gap-2">
                    {attempts.map((attempt) => (
                      <article key={attempt.id} className="rounded-xl bg-slate-50 p-3">
                        <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
                          <div className="text-sm font-semibold text-ink">评分：{attempt.score}/100</div>
                          <div className="text-xs text-slate-400">{formatDateTime(attempt.createdAt)}</div>
                        </div>
                        <div className="mt-2 text-sm text-slate-600">你的答案：{attempt.userAnswer}</div>
                        <div className="mt-2 text-sm text-slate-600">修正版：{attempt.correctedJa}</div>
                        {attempt.note && <div className="mt-2 text-sm text-things-800">备注：{attempt.note}</div>}
                      </article>
                    ))}
                    {!attempts.length && <p className="text-sm text-slate-500">这句话还没有练习记录。</p>}
                  </div>
                )}
              </section>
            </div>
          )}
        </main>
      )}

      {activeTab === "listening" && !activeListeningSession && (
        <DrillSetup
          mode="listening"
          items={filteredItems.filter((item) => !item.listeningMastered)}
          selectedCount={selectedSessionItemIds.length}
          sessions={drillSessions.filter((session) => session.mode === "listening")}
          progress={listeningProgress}
          priority={drillPriority}
          loading={loading}
          onPriorityChange={setDrillPriority}
          onStart={(plannedDueAt) => createSessionFromSelection("listening", plannedDueAt)}
          onClearSelection={() => setSelectedSessionItemIds([])}
          onRefresh={refreshDrillSessions}
          onOpenLibrary={() => setActiveTab("library")}
          onContinue={(session) => {
            continueDrillSession(session);
          }}
          onArchive={archiveDrillSession}
          onRestore={restoreDrillSession}
          showArchived={showArchivedDrillSessions}
          onShowArchivedChange={setShowArchivedDrillSessions}
          onOpenDetail={(session) => {
            setSelectedDrillSessionId(session.id);
            setDetailReturnTab("session");
            setActiveTab("session");
          }}
        />
      )}

      {activeTab === "listening" && activeListeningSession && (
        <ListeningPractice
          items={activeListeningSession ? listeningSessionItems : Array.from(new Map([
            ...filteredItems.filter((item) => !item.listeningMastered),
            ...(selectedItem && !selectedItem.listeningMastered ? [selectedItem] : [])
          ].map((item) => [item.id, item])).values())}
          targetItemId={selectedId}
          activeSession={activeListeningSession}
          onSessionDetail={() => {
            setSelectedDrillSessionId(activeListeningSession.id);
            setDetailReturnTab("session");
            setActiveTab("session");
          }}
          onSessionBrowse={() => setActiveListeningSessionId("")}
          onSessionAdvance={(finishedId, nextItems, nextProgress) => advanceActiveSession("listening", finishedId, nextItems, nextProgress)}
          onSessionStop={() => stopActiveSession("listening")}
          onUndoCapture={(item, message) => pushDrillUndo(captureDrillUndo("listening", item, message))}
          onUndoLast={undoLastDrillAction}
          canUndo={drillUndoStack.length > 0}
          progress={listeningProgress}
          onProgressChange={setListeningProgress}
          difficultWords={difficultWords}
          onDifficultWordsChange={setDifficultWords}
          playbackRate={listeningPlaybackRate}
          onPlaybackRateChange={onListeningPlaybackRateChange}
          scoreSettings={listeningScoreSettings}
          onItemUpdate={updateTranslationItemInState}
        />
      )}
    </section>
  );
}

function DrillSetup({
  mode,
  items,
  selectedCount,
  sessions,
  progress,
  priority,
  loading,
  onPriorityChange,
  onStart,
  onClearSelection,
  onRefresh,
  onOpenLibrary,
  onContinue,
  onArchive,
  onRestore,
  showArchived,
  onShowArchivedChange,
  onOpenDetail
}: {
  mode: "translation" | "listening";
  items: TranslationItem[];
  selectedCount: number;
  sessions: DrillSession[];
  progress: Record<string, ListeningProgress>;
  priority: DrillPriority;
  loading: boolean;
  onPriorityChange: (priority: DrillPriority) => void;
  onStart: (plannedDueAt: string) => void;
  onClearSelection: () => void;
  onRefresh: () => void;
  onOpenLibrary: () => void;
  onContinue: (session: DrillSession) => void;
  onArchive: (session: DrillSession) => void;
  onRestore: (session: DrillSession) => void;
  showArchived: boolean;
  onShowArchivedChange: (show: boolean) => void;
  onOpenDetail: (session: DrillSession) => void;
}) {
  const label = mode === "translation" ? "翻译" : "听力";
  const [deadlineMode, setDeadlineMode] = useState<"none" | "scheduled">("none");
  const [plannedDueInput, setPlannedDueInput] = useState(() => localDateTimeInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000)));
  const archivedSessions = sessions.filter((session) => session.status === "archived");
  const historySessions = sessions.filter((session) => session.status !== "archived");
  const activeSessions = historySessions.filter((session) => session.status === "active");
  const resumableSessions = historySessions.filter((session) => drillSessionIncompleteCount(session, items, progress) > 0);
  const primarySession = resumableSessions[0];
  const recentSessions = (showArchived ? archivedSessions : historySessions).slice(0, 8);
  const candidateStats = drillItemStats(mode, items, progress);
  const primaryIncompleteCount = primarySession ? drillSessionIncompleteCount(primarySession, items, progress) : 0;
  let plannedDueAt = "";
  if (deadlineMode === "scheduled" && plannedDueInput) {
    const plannedDate = new Date(plannedDueInput);
    if (!Number.isNaN(plannedDate.getTime())) plannedDueAt = plannedDate.toISOString();
  }

  return (
    <main className="grid gap-4">
      {primarySession && (
        <div className="rounded-2xl border border-things-200 bg-things-50 p-5 shadow-hairline">
          <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
            <div className="min-w-0">
              <div className="text-sm font-semibold text-things-700">继续上次{label}练习</div>
              <h3 className="mt-1 truncate text-2xl font-semibold text-ink">{primarySession.title}</h3>
              <div className="mt-2 text-sm text-slate-600">
                剩余未掌握 {primaryIncompleteCount} 句 · 队列 {drillQueueDoneCount(primarySession)}/{primarySession.itemIds.length} · {drillPriorityLabel(sessionPriority(primarySession))} · 更新 {formatDateTime(primarySession.updatedAt)}
              </div>
              <div className="mt-1 text-xs font-semibold text-things-700">{drillSessionDeadlineLabel(primarySession)}</div>
              <div className="mt-3 max-w-xl">
                <MiniBar label="队列推进" value={drillQueueDoneCount(primarySession)} total={primarySession.itemIds.length} tone="things" />
              </div>
            </div>
            <div className="mobile-action-grid">
              <PrimaryButton onClick={() => onContinue(primarySession)} disabled={loading}>继续练习</PrimaryButton>
              <SecondaryButton onClick={() => onOpenDetail(primarySession)}>任务详情</SecondaryButton>
            </div>
          </div>
          {activeSessions.length > 1 && (
            <div className="mt-3 text-xs font-semibold text-things-700">还有 {activeSessions.length - 1} 个进行中的{label}任务在下方履历里。</div>
          )}
          {resumableSessions.length > activeSessions.length && (
            <div className="mt-3 text-xs font-semibold text-warm-600">有 {resumableSessions.length - activeSessions.length} 个历史任务仍有未掌握内容，可以继续。</div>
          )}
        </div>
      )}

      <div className="rounded-2xl border border-line bg-white p-5">
        <div className="text-2xl font-semibold tracking-normal text-ink">新建{label}练习任务</div>
        <p className="mt-1 text-sm text-slate-500">
          当前可练 {items.length} 句。已从句库多选 {selectedCount} 句；没有多选时，会使用当前句库筛选结果。
        </p>
        <div className="mt-4 grid gap-3 md:grid-cols-3">
          <Metric label="本次候选" value={`${selectedCount || items.length}`} />
          <Metric label="进行中" value={`${activeSessions.length}`} />
          <Metric label="历史" value={`${historySessions.length}`} />
        </div>
        <div className="mt-4 rounded-2xl bg-slate-50 p-3">
          <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
            <div>
              <div className="text-sm font-semibold text-ink">队列策略</div>
              <p className="mt-1 text-xs leading-5 text-slate-500">旧卡优先先按下次复习时间处理已有记录的卡；新卡优先先抽还没有学习记录的卡。</p>
            </div>
            <div className="flex flex-wrap gap-2">
              <FilterPill active={priority === "old"} onClick={() => onPriorityChange("old")}>旧卡优先</FilterPill>
              <FilterPill active={priority === "new"} onClick={() => onPriorityChange("new")}>新卡优先</FilterPill>
            </div>
          </div>
          <div className="mt-3 grid gap-2 md:grid-cols-3">
            <MiniBar label={mode === "translation" ? "翻译已掌握" : "听力已掌握"} value={candidateStats.mastered} total={candidateStats.total} tone="emerald" />
            <MiniBar label="当前到期" value={candidateStats.due} total={candidateStats.total} tone="things" />
            <MiniBar label={mode === "translation" ? "仍需练习" : "未掌握"} value={candidateStats.learning} total={candidateStats.total} tone="slate" />
          </div>
        </div>
        <div className="mt-4 rounded-2xl bg-slate-50 p-3">
          <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
            <div>
              <div className="text-sm font-semibold text-ink">计划完成时间</div>
              <p className="mt-1 text-xs leading-5 text-slate-500">只用于标记这次任务的目标完成时间，不影响句子的间隔复习。</p>
            </div>
            <div className="flex flex-wrap gap-2">
              <FilterPill active={deadlineMode === "none"} onClick={() => setDeadlineMode("none")}>无期限</FilterPill>
              <FilterPill active={deadlineMode === "scheduled"} onClick={() => setDeadlineMode("scheduled")}>指定时间</FilterPill>
            </div>
          </div>
          {deadlineMode === "scheduled" && (
            <label className="mt-3 grid gap-1.5 md:max-w-xs">
              <span className="text-sm font-semibold text-slate-700">计划完成于</span>
              <input
                className="input"
                type="datetime-local"
                value={plannedDueInput}
                onChange={(event) => setPlannedDueInput(event.target.value)}
              />
            </label>
          )}
        </div>
        <div className="mobile-action-grid mt-4">
          <PrimaryButton onClick={() => onStart(plannedDueAt)} disabled={loading || (!selectedCount && !items.length)}>开始本次练习</PrimaryButton>
          <SecondaryButton onClick={onOpenLibrary}>去句库选择内容</SecondaryButton>
          <SecondaryButton onClick={onClearSelection} disabled={!selectedCount}>清空多选</SecondaryButton>
          <SecondaryButton onClick={onRefresh}>刷新履历</SecondaryButton>
        </div>
      </div>

      <div className="rounded-2xl border border-line bg-white p-4">
        <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
          <div>
            <div className="text-sm font-semibold text-things-700">练习履历</div>
            <div className="mt-0.5 text-xs font-semibold text-slate-400">当前 {historySessions.length} 条 · 已归档 {archivedSessions.length} 条</div>
          </div>
          <div className="flex flex-wrap items-center gap-1.5 rounded-full bg-slate-50 p-1">
            <button
              type="button"
              onClick={() => onShowArchivedChange(false)}
              className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                !showArchived ? "bg-white text-things-800 shadow-hairline" : "text-slate-500 hover:text-slate-700"
              }`}
            >
              当前履历
            </button>
            <button
              type="button"
              onClick={() => onShowArchivedChange(true)}
              className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                showArchived ? "bg-white text-things-800 shadow-hairline" : "text-slate-500 hover:text-slate-700"
              }`}
            >
              已归档 {archivedSessions.length}
            </button>
          </div>
        </div>
        <div className="mt-3 grid gap-2">
          {recentSessions.map((session) => {
            const incompleteCount = drillSessionIncompleteCount(session, items, progress);
            const canContinue = incompleteCount > 0;
            const isArchived = session.status === "archived";
            const statusLabel = session.status === "completed" && canContinue ? "待继续" : drillSessionStatusLabel(session.status);
            return (
            <article key={session.id} className={`rounded-xl p-3 ${isArchived ? "bg-slate-100 opacity-80" : "bg-slate-50"}`}>
              <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
                <div>
                  <div className="text-sm font-semibold text-ink">{session.title}</div>
                  <div className="mt-1 text-xs text-slate-500">
                    {statusLabel} · 未掌握 {incompleteCount} · 队列 {drillQueueDoneCount(session)}/{session.itemIds.length} · {drillPriorityLabel(sessionPriority(session))}
                  </div>
                  <div className="mt-1 text-xs font-semibold text-slate-400">{drillSessionDeadlineLabel(session)}</div>
                </div>
                <div className="mobile-action-grid">
                  <SecondaryButton onClick={() => onOpenDetail(session)}>详情</SecondaryButton>
                  {!isArchived && canContinue && <SecondaryButton onClick={() => onContinue(session)}>继续</SecondaryButton>}
                  {isArchived ? (
                    <button
                      onClick={() => onRestore(session)}
                      disabled={loading}
                      className="rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-600 shadow-hairline transition hover:bg-things-50 hover:text-things-800 disabled:cursor-not-allowed disabled:opacity-50"
                    >
                      恢复
                    </button>
                  ) : (
                    <button
                      onClick={() => onArchive(session)}
                      disabled={loading}
                      className="rounded-xl bg-slate-100 px-4 py-2.5 text-sm font-semibold text-slate-500 transition hover:bg-slate-200 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
                    >
                      归档
                    </button>
                  )}
                </div>
              </div>
              <div className="mt-3">
                <MiniBar label="队列推进" value={drillQueueDoneCount(session)} total={session.itemIds.length} tone="things" />
              </div>
              <div className="mt-2 text-xs text-slate-400">创建：{formatDateTime(session.createdAt)} · 更新：{formatDateTime(session.updatedAt)}</div>
            </article>
          )})}
          {!recentSessions.length && <p className="text-sm text-slate-500">{showArchived ? `还没有已归档的${label}练习。` : `还没有${label}练习履历。`}</p>}
        </div>
      </div>
    </main>
  );
}

function DrillSessionDetail({
  session,
  items,
  progress,
  loading,
  onBack,
  onContinue,
  onPriorityChange,
  onOpenItem
}: {
  session: DrillSession;
  items: TranslationItem[];
  progress: Record<string, ListeningProgress>;
  loading: boolean;
  onBack: () => void;
  onContinue: (session: DrillSession) => void;
  onPriorityChange: (priority: DrillPriority) => void;
  onOpenItem: (itemId: string) => void;
}) {
  const label = session.mode === "translation" ? "翻译" : "听力";
  const sessionItems = session.itemIds.map((itemId) => items.find((item) => item.id === itemId)).filter(Boolean) as TranslationItem[];
  const stats = drillItemStats(session.mode, sessionItems, progress);
  const queueDone = drillQueueDoneCount(session);
  const queueTotal = session.itemIds.length;
  const currentId = session.itemIds[session.currentIndex] || "";
  const priority = sessionPriority(session);
  const incompleteCount = drillSessionIncompleteCount(session, items, progress);
  const canContinue = incompleteCount > 0;
  const statusLabel = session.status === "completed" && canContinue ? "待继续" : drillSessionStatusLabel(session.status);
  const queuePageSize = 50;
  const queuePageCount = Math.max(1, Math.ceil(session.itemIds.length / queuePageSize));
  const [queuePage, setQueuePage] = useState(() => Math.floor(Math.max(0, session.currentIndex) / queuePageSize) + 1);
  const currentQueuePage = Math.min(queuePage, queuePageCount);
  const queuePageNumbers = getPageNumbers(currentQueuePage, queuePageCount);
  const pagedItemIds = session.itemIds.slice((currentQueuePage - 1) * queuePageSize, currentQueuePage * queuePageSize);

  useEffect(() => {
    setQueuePage(Math.floor(Math.max(0, session.currentIndex) / queuePageSize) + 1);
  }, [session.id, session.currentIndex]);

  return (
    <main className="grid gap-4">
      <div className="rounded-2xl border border-line bg-white p-5">
        <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
          <div>
            <div className="text-sm font-semibold text-things-700">任务详情</div>
            <h2 className="mt-1 text-2xl font-semibold text-ink">{session.title}</h2>
            <p className="mt-1 text-sm text-slate-500">
              {label}任务 · {statusLabel} · 未掌握 {incompleteCount} · 创建 {formatDateTime(session.createdAt)}
            </p>
            <p className="mt-1 text-xs font-semibold text-slate-400">{drillSessionDeadlineLabel(session)}</p>
          </div>
          <div className="mobile-action-grid">
            <SecondaryButton onClick={onBack}>返回练习</SecondaryButton>
            {canContinue && <PrimaryButton onClick={() => onContinue(session)} disabled={loading}>继续任务</PrimaryButton>}
          </div>
        </div>

        <div className="mt-5 grid gap-3 md:grid-cols-5">
          <Metric label="队列推进" value={`${queueDone}/${queueTotal}`} />
          <Metric label={session.mode === "translation" ? "翻译已掌握" : "听力已掌握"} value={`${stats.mastered}/${stats.total}`} />
          <Metric label="当前到期" value={`${stats.due}`} />
          <Metric label="队列策略" value={drillPriorityLabel(priority)} />
          <Metric label="计划完成" value={session.plannedDueAt ? formatDateTime(session.plannedDueAt) : "无期限"} />
        </div>

        <div className="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
          <div className="rounded-2xl bg-slate-50 p-4">
            <div className="text-sm font-semibold text-ink">掌握情况</div>
            <div className="mt-3 grid gap-3">
              <MiniBar label={session.mode === "translation" ? "已归档/掌握" : "听力已掌握"} value={stats.mastered} total={stats.total} tone="emerald" />
              <MiniBar label="到期需要回顾" value={stats.due} total={stats.total} tone="things" />
              <MiniBar label={session.mode === "translation" ? "仍在练习" : "听力未掌握"} value={stats.learning} total={stats.total} tone="slate" />
            </div>
          </div>
          <div className="rounded-2xl bg-slate-50 p-4">
            <div className="text-sm font-semibold text-ink">队列策略</div>
            <p className="mt-1 text-xs leading-5 text-slate-500">切换后只重排尚未练到的部分；相同复习时间会打散，已经走过的部分会保留。</p>
            <div className="mt-3 flex flex-wrap gap-2">
              <FilterPill active={priority === "old"} onClick={() => onPriorityChange("old")}>旧卡优先</FilterPill>
              <FilterPill active={priority === "new"} onClick={() => onPriorityChange("new")}>新卡优先</FilterPill>
            </div>
          </div>
        </div>
      </div>

      <div className="rounded-2xl border border-line bg-white p-4">
        <div className="mb-3 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
          <div>
            <div className="text-sm font-semibold text-things-700">队列可视化</div>
            <p className="mt-1 text-xs text-slate-500">区分本次任务进度和句子本身的掌握状态。</p>
          </div>
          <div className="text-xs font-semibold text-slate-400">
            当前：{Math.min(session.currentIndex + 1, queueTotal)}/{queueTotal} · 第 {currentQueuePage}/{queuePageCount} 页
          </div>
        </div>
        <div className="grid gap-2">
          {pagedItemIds.map((itemId, pageIndex) => {
            const index = (currentQueuePage - 1) * queuePageSize + pageIndex;
            const item = items.find((candidate) => candidate.id === itemId);
            const queueState = session.status === "completed" && canContinue
              ? item && drillItemPhase(item, session.mode, progress) === "mastered" ? "done" : "pending"
              : session.status === "completed" || index < session.currentIndex ? "done" : itemId === currentId && session.status === "active" ? "current" : "pending";
            const mastery = item ? drillItemMasteryLabel(session.mode, item, progress) : "句子不存在";
            return (
              <button
                key={`${session.id}_${itemId}_${index}`}
                onClick={() => item && onOpenItem(item.id)}
                disabled={!item}
                className={`grid gap-2 rounded-xl px-3 py-2 text-left text-sm transition md:grid-cols-[5rem_minmax(0,1fr)_8rem] md:items-center ${
                queueState === "current" ? "bg-things-50 text-things-900" : queueState === "done" ? "bg-emerald-50 text-emerald-900" : "bg-slate-50 text-slate-600"
              } ${item ? "hover:bg-things-50 hover:text-things-900" : "cursor-not-allowed opacity-60"}`}
              >
                <div className="text-xs font-semibold">{queueState === "current" ? "当前" : queueState === "done" ? "已过" : "待练"} · {index + 1}</div>
                <div className="min-w-0 truncate font-semibold">{item?.zhSentence || itemId}</div>
                <div className="text-xs font-semibold text-slate-500">{mastery}</div>
              </button>
            );
          })}
          {!session.itemIds.length && <p className="text-sm text-slate-500">这个任务没有句子。</p>}
        </div>
        {session.itemIds.length > queuePageSize && (
          <div className="mt-4 flex flex-col gap-2 border-t border-line pt-3 md:flex-row md:items-center md:justify-between">
            <div className="text-xs font-semibold text-slate-400">
              显示 {(currentQueuePage - 1) * queuePageSize + 1}-{Math.min(currentQueuePage * queuePageSize, session.itemIds.length)} / {session.itemIds.length}，每页 50 条
            </div>
            <div className="flex flex-wrap gap-1.5">
              <SecondaryButton onClick={() => setQueuePage(Math.max(1, currentQueuePage - 1))} disabled={currentQueuePage <= 1}>上一页</SecondaryButton>
              {queuePageNumbers.map((page) => (
                <button
                  key={page}
                  onClick={() => setQueuePage(page)}
                  className={`h-9 min-w-9 rounded-xl px-3 text-sm font-semibold ${
                    page === currentQueuePage ? "bg-things-600 text-white" : "bg-white text-slate-600 shadow-hairline hover:bg-things-50 hover:text-things-800"
                  }`}
                >
                  {page}
                </button>
              ))}
              <SecondaryButton onClick={() => setQueuePage(Math.min(queuePageCount, currentQueuePage + 1))} disabled={currentQueuePage >= queuePageCount}>下一页</SecondaryButton>
            </div>
          </div>
        )}
      </div>
    </main>
  );
}

function ListeningPractice({
  items,
  targetItemId,
  activeSession,
  onSessionAdvance,
  onSessionDetail,
  onSessionBrowse,
  onSessionStop,
  onUndoCapture,
  onUndoLast,
  canUndo,
  progress,
  onProgressChange,
  difficultWords,
  onDifficultWordsChange,
  playbackRate,
  onPlaybackRateChange,
  scoreSettings,
  onItemUpdate
}: {
  items: TranslationItem[];
  targetItemId: string;
  activeSession: DrillSession | null | undefined;
  onSessionAdvance: (finishedId: string, nextItems?: TranslationItem[], nextProgress?: Record<string, ListeningProgress>) => Promise<boolean>;
  onSessionDetail: () => void;
  onSessionBrowse: () => void;
  onSessionStop: () => Promise<void>;
  onUndoCapture: (item: TranslationItem, message: string) => void;
  onUndoLast: () => void;
  canUndo: boolean;
  progress: Record<string, ListeningProgress>;
  onProgressChange: (progress: Record<string, ListeningProgress>) => void;
  difficultWords: DifficultWordMap;
  onDifficultWordsChange: (words: DifficultWordMap) => void;
  playbackRate: number;
  onPlaybackRateChange: (rate: number) => void;
  scoreSettings: ListeningScoreSettings;
  onItemUpdate: (item: TranslationItem) => void;
}) {
  const sortedItems = [...items].sort((a, b) => getListeningDue(a, progress).getTime() - getListeningDue(b, progress).getTime());
  const [listeningSelectedId, setListeningSelectedId] = useState("");
  const sessionCurrentItem = activeSession ? items.find((item) => item.id === activeSession.itemIds[activeSession.currentIndex]) : null;
  const currentItem = sessionCurrentItem || sortedItems.find((item) => item.id === listeningSelectedId) || sortedItems.find((item) => item.id === targetItemId) || sortedItems[0];
  const [generatedTokens, setGeneratedTokens] = useState<Record<string, string[]>>({});
  const tokens = currentItem ? (generatedTokens[currentItem.id] || currentItem.listeningTokens || []) : [];
  const [tokenLoading, setTokenLoading] = useState(false);
  const [tokenError, setTokenError] = useState("");
  const [revealed, setRevealed] = useState<number[]>([]);
  const [revealedEver, setRevealedEver] = useState<number[]>([]);
  const [playCount, setPlayCount] = useState(0);
  const [systemPlayCount, setSystemPlayCount] = useState(0);
  const [usedPromptHint, setUsedPromptHint] = useState(false);
  const [usedSentenceAnalysis, setUsedSentenceAnalysis] = useState(false);
  const [audioUrl, setAudioUrl] = useState("");
  const [audioLoading, setAudioLoading] = useState(false);
  const [audioPlaying, setAudioPlaying] = useState(false);
  const [audioError, setAudioError] = useState("");
  const [speaking, setSpeaking] = useState(false);
  const [autoPlay, setAutoPlay] = useState(() => readListeningAutoPlay());
  const [mastering, setMastering] = useState(false);
  const [actionMessage, setActionMessage] = useState("");
  const [showPrompt, setShowPrompt] = useState(false);
  const [wordSaving, setWordSaving] = useState(false);
  const [addingCorpusWord, setAddingCorpusWord] = useState("");
  const [addedCorpusWords, setAddedCorpusWords] = useState<string[]>([]);
  const [sentenceAnalysis, setSentenceAnalysis] = useState<ListeningSentenceAnalysis | null>(null);
  const [analysisLoading, setAnalysisLoading] = useState(false);
  const [showAnalysis, setShowAnalysis] = useState(false);
  const [showDifficultWords, setShowDifficultWords] = useState(false);
  const [mobileListeningPage, setMobileListeningPage] = useState<"practice" | "tools">("practice");
  const [showMobileListeningResult, setShowMobileListeningResult] = useState(false);
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const currentDifficultWords = currentItem ? (difficultWords[currentItem.id] || []) : [];
  const wordDrag = useTouchWordDrag((word) => addDifficultWord(word));

  useEffect(() => {
    setRevealed([]);
    setRevealedEver([]);
    setPlayCount(0);
    setSystemPlayCount(0);
    setUsedPromptHint(false);
    setUsedSentenceAnalysis(false);
    setAudioError("");
    setAudioUrl("");
    audioRef.current?.pause();
    audioRef.current = null;
    setAudioPlaying(false);
    setShowPrompt(false);
    setTokenError("");
    setSentenceAnalysis(null);
    setShowAnalysis(false);
    setShowDifficultWords(false);
    setMobileListeningPage("practice");
    setShowMobileListeningResult(false);
    window.speechSynthesis?.cancel();
    setSpeaking(false);
  }, [currentItem?.id]);

  useEffect(() => {
    localStorage.setItem("pjct_listening_auto_play", autoPlay ? "1" : "0");
  }, [autoPlay]);

  useEffect(() => {
    if (!autoPlay || !currentItem?.recommendedJa) return;
    const timer = window.setTimeout(() => {
      playSentence(true);
    }, 250);
    return () => window.clearTimeout(timer);
  }, [autoPlay, currentItem?.id, playbackRate]);

  useEffect(() => {
    if (targetItemId && sortedItems.some((item) => item.id === targetItemId)) {
      setListeningSelectedId(targetItemId);
    }
  }, [targetItemId, items.length]);

  async function generateListeningTokens() {
    if (!currentItem) return;
    setTokenError("");
    setTokenLoading(true);
    try {
      const updated = await apiTokenizeTranslationItem(currentItem.id);
      onItemUpdate(updated);
      setGeneratedTokens((current) => ({ ...current, [updated.id]: updated.listeningTokens || [] }));
    } catch (error) {
      setTokenError(error instanceof Error ? error.message : "生成分词失败");
    } finally {
      setTokenLoading(false);
    }
  }

  async function playSentence(forceFresh = false) {
    if (!currentItem?.recommendedJa) return;
    setAudioError("");
    setAudioLoading(true);
    try {
      const url = forceFresh ? await apiSpeakText(currentItem.recommendedJa) : audioUrl || await apiSpeakText(currentItem.recommendedJa);
      setAudioUrl(url);
      audioRef.current?.pause();
      const audio = new Audio(url);
      audioRef.current = audio;
      audio.playbackRate = playbackRate;
      audio.onended = () => setAudioPlaying(false);
      audio.onpause = () => setAudioPlaying(false);
      audio.onerror = () => setAudioPlaying(false);
      setAudioPlaying(true);
      await audio.play();
      setPlayCount((current) => current + 1);
    } catch (error) {
      setAudioPlaying(false);
      setAudioError(error instanceof Error ? error.message : "AI 语音播放失败");
    } finally {
      setAudioLoading(false);
    }
  }

  function stopSentenceAudio() {
    audioRef.current?.pause();
    audioRef.current = null;
    setAudioPlaying(false);
  }

  function playSystemSentence() {
    if (!currentItem?.recommendedJa || !("speechSynthesis" in window)) return;
    window.speechSynthesis.cancel();
    const utterance = new SpeechSynthesisUtterance(currentItem.recommendedJa);
    utterance.lang = "ja-JP";
    utterance.rate = Math.max(0.5, Math.min(2, playbackRate * 0.88));
    utterance.onend = () => setSpeaking(false);
    utterance.onerror = () => setSpeaking(false);
    setSpeaking(true);
    window.speechSynthesis.speak(utterance);
    setPlayCount((current) => current + 1);
    setSystemPlayCount((current) => current + 1);
  }

  function stopSystemSentence() {
    window.speechSynthesis?.cancel();
    setSpeaking(false);
  }

  function reveal(index: number) {
    setRevealed((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index]);
    setRevealedEver((current) => current.includes(index) ? current : [...current, index]);
  }

  function resetListeningScore() {
    setRevealed([]);
    setRevealedEver([]);
    setPlayCount(0);
    setSystemPlayCount(0);
    setUsedPromptHint(false);
    setUsedSentenceAnalysis(false);
  }

  async function saveDifficultWords(words: string[]) {
    if (!currentItem) return;
    const uniqueWords = Array.from(new Set(words.map((word) => word.trim()).filter(Boolean)));
    onDifficultWordsChange({ ...difficultWords, [currentItem.id]: uniqueWords });
    setWordSaving(true);
    try {
      const savedWords = await apiUpdateListeningDifficultWords(currentItem.id, uniqueWords);
      onDifficultWordsChange({ ...difficultWords, [currentItem.id]: savedWords });
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "保存难词失败");
    } finally {
      setWordSaving(false);
    }
  }

  async function addListeningWordToCorpus(word: string) {
    if (!currentItem || !word.trim()) return;
    setAddingCorpusWord(word.trim());
    setAudioError("");
    try {
      await apiAddCorpusItem({
        chunk: word.trim(),
        meaningZh: currentItem.zhSentence,
        usageScene: "听力练习中听不稳、需要回顾的表达",
        exampleJa: currentItem.recommendedJa || word.trim(),
        masteryStatus: "未练习",
        tags: currentItem.tags || [],
        status: "active",
        sourceType: "listening",
        sourceLabel: sourceLabel("listening"),
        sourceRef: currentItem.id,
        detailStatus: "pending"
      });
      setAddedCorpusWords((current) => Array.from(new Set([...current, word.trim()])));
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "添加corpus失败");
    } finally {
      setAddingCorpusWord("");
    }
  }

  async function analyzeSentence() {
    if (!currentItem) return;
    setAnalysisLoading(true);
    setAudioError("");
    setShowAnalysis(true);
    setMobileListeningPage("tools");
    setUsedSentenceAnalysis(true);
    try {
      setSentenceAnalysis(await apiAnalyzeListeningSentence(currentItem.id));
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "句子解析失败");
    } finally {
      setAnalysisLoading(false);
    }
  }

  function addDifficultWord(word: string) {
    if (!word || currentDifficultWords.includes(word)) return;
    saveDifficultWords([...currentDifficultWords, word]);
  }

  function removeDifficultWord(word: string) {
    saveDifficultWords(currentDifficultWords.filter((item) => item !== word));
  }

  function handleDropDifficultWord(event: React.DragEvent<HTMLDivElement>) {
    event.preventDefault();
    const word = event.dataTransfer.getData("text/plain");
    addDifficultWord(word);
  }

  async function skipCurrent() {
    if (!currentItem) return;
    setAudioError("");
    onUndoCapture(currentItem, "跳过听力卡片");
    try {
      await apiSkipListening(currentItem.id);
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "记录跳过失败");
    }
    if (await onSessionAdvance(currentItem.id, items, progress)) return;
    const next = sortedItems.find((item) => item.id !== currentItem.id);
    setListeningSelectedId(next?.id || "");
  }

  async function markMastered() {
    if (!currentItem) return;
    setAudioError("");
    setActionMessage("");
    onUndoCapture(currentItem, "听力已掌握");
    setMastering(true);
    try {
      const masteredSentence = currentItem.zhSentence;
      const updatedItem = await apiSetListeningMastered(currentItem.id, true, score, revealedCount, tokens.length);
      onItemUpdate(updatedItem);
      const nextItems = items.map((item) => item.id === updatedItem.id ? updatedItem : item);
      setActionMessage(`已标记听力掌握：${masteredSentence}`);
      if (await onSessionAdvance(currentItem.id, nextItems, progress)) return;
      const next = sortedItems.find((item) => item.id !== currentItem.id);
      setListeningSelectedId(next?.id || "");
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "设置听力已掌握失败");
    } finally {
      setMastering(false);
    }
  }

  async function schedule(intervalMinutes: number) {
    if (!currentItem) return;
    setAudioError("");
    onUndoCapture(currentItem, "听力复习安排");
    try {
      const updated = await apiScheduleListening(currentItem.id, intervalMinutes, score, revealedCount, tokens.length);
      const nextProgress = { ...progress, [currentItem.id]: updated };
      onProgressChange(nextProgress);
      if (await onSessionAdvance(currentItem.id, items, nextProgress)) return;
      const next = sortedItems.find((item) => item.id !== currentItem.id);
      setListeningSelectedId(next?.id || "");
    } catch (error) {
      setAudioError(error instanceof Error ? error.message : "设置听力复习间隔失败");
    }
  }

  if (!currentItem) {
    return <Locked title="没有可练听力的句子" text="请先在句库导入句子，或切换筛选条件。" />;
  }

  const revealedCount = revealedEver.length;
  const total = Math.max(tokens.length, 1);
  const normalizedScoreSettings = normalizeListeningScoreSettings(scoreSettings);
  const revealPenalty = (revealedCount / total) * normalizedScoreSettings.revealPenaltyMax;
  const replayPenalty = Math.min(
    normalizedScoreSettings.replayPenaltyMax,
    Math.max(0, playCount - 1) * normalizedScoreSettings.replayPenaltyPerExtraPlay
  );
  const assistancePenalty = usedSentenceAnalysis
    ? normalizedScoreSettings.analysisPenalty
    : Math.min(
      100,
      (usedPromptHint ? normalizedScoreSettings.promptHintPenalty : 0) +
        Math.min(normalizedScoreSettings.systemPlayPenaltyMax, systemPlayCount * normalizedScoreSettings.systemPlayPenalty)
    );
  const score = Math.max(0, Math.round(100 - revealPenalty - replayPenalty - assistancePenalty));
  const currentProgress = progress[currentItem.id];
  const suggestedMastered = !currentItem.listeningMastered && (currentProgress?.reviewCount || 0) > 3 && (currentProgress?.averageScore || 0) > 90;

  return (
    <main className="rounded-2xl border border-line bg-white p-4">
      <div className="mb-3 flex items-center justify-between gap-3 border-b border-line pb-3 md:hidden">
        {mobileListeningPage === "tools" ? (
          <button type="button" onClick={() => setMobileListeningPage("practice")} className="text-sm font-semibold text-things-700">‹ 返回精听</button>
        ) : (
          <button type="button" onClick={onSessionBrowse} className="text-sm font-semibold text-things-700">‹ 返回任务</button>
        )}
        <span className="text-sm font-semibold text-slate-500">{mobileListeningPage === "tools" ? "辅助工具" : "听力练习"}</span>
      </div>
      {activeSession && (
        <div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-things-100 bg-things-50/70 px-3 py-2 md:hidden">
          <div className="min-w-0">
            <div className="truncate text-sm font-semibold text-things-900">{activeSession.title}</div>
            <div className="mt-0.5 text-xs font-semibold text-things-700">{activeSession.currentIndex + 1}/{activeSession.itemIds.length}</div>
          </div>
          <CompactButton onClick={onUndoLast} disabled={!canUndo}>↶ 撤销</CompactButton>
        </div>
      )}
      {activeSession && (
        <div className="mb-3 hidden flex-col gap-2 rounded-xl border border-things-100 bg-things-50/70 px-3 py-2 md:flex lg:flex-row lg:items-center lg:justify-between">
          <div className="min-w-0 text-sm font-semibold text-things-900">
            <span className="truncate">{activeSession.title}</span>
            <span className="ml-2 text-things-700">{activeSession.currentIndex + 1}/{activeSession.itemIds.length}</span>
          </div>
          <div className="flex flex-wrap gap-1.5">
            <CompactButton onClick={onUndoLast} disabled={!canUndo}>↶ 撤销</CompactButton>
            <CompactButton onClick={onSessionDetail}>▦ 详情</CompactButton>
            <CompactButton onClick={onSessionBrowse}>⇄ 切换/新建</CompactButton>
            <CompactButton onClick={onSessionStop}>■ 中止</CompactButton>
          </div>
        </div>
      )}
      <div className={`${mobileListeningPage === "tools" ? "hidden" : ""} rounded-2xl border border-line bg-white p-4 shadow-hairline md:hidden`}>
        <div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Listening Prompt</div>
        {showPrompt ? (
          <h3 className="text-lg font-semibold leading-7 text-ink">{currentItem.zhSentence}</h3>
        ) : (
          <div className="rounded-xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-500">中文提示已隐藏</div>
        )}
        <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-slate-400">
          <span>播放 {playCount} 次</span>
          <span>{tokens.length} 个听力块</span>
          <span className={scoreTextColor(score)}>{score}/100</span>
        </div>
        {suggestedMastered && (
          <div className="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
            ✓ 建议已掌握
          </div>
        )}
        <div className="mt-4 [&>button]:w-full">
          <PrimaryButton onClick={() => playSentence()} disabled={audioLoading || audioPlaying}>
            {buttonIcon(audioLoading ? "…" : audioPlaying ? "▮▮" : "▶", audioLoading ? "生成中..." : audioPlaying ? "播放中" : "播放日语")}
          </PrimaryButton>
        </div>
        <div className="mobile-action-grid mt-2">
          <SecondaryButton onClick={skipCurrent}>{buttonIcon("↷", "跳过当前")}</SecondaryButton>
          <SecondaryButton onClick={() => {
            if (!showPrompt) setUsedPromptHint(true);
            setShowPrompt(!showPrompt);
          }}>{buttonIcon(showPrompt ? "◌" : "◍", showPrompt ? "隐藏提示" : "显示提示")}</SecondaryButton>
        </div>
        <button type="button" onClick={() => setMobileListeningPage("tools")} className="mt-3 flex w-full items-center justify-between rounded-xl bg-slate-50 px-3 py-3 text-left text-sm font-semibold text-things-700">
          <span>辅助工具与设置</span>
          <span aria-hidden="true">›</span>
        </button>
        {actionMessage && <p className="mt-2 rounded-xl bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">{actionMessage}</p>}
        {audioError && <p className="mt-2 text-sm text-red-600">{audioError}</p>}
      </div>
      <div className="z-10 hidden rounded-2xl border border-line bg-white/95 p-4 shadow-hairline backdrop-blur md:sticky md:top-3 md:block">
        <div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Listening Prompt</div>
        {showPrompt ? (
          <h3 className="text-xl font-semibold leading-8 text-ink">{currentItem.zhSentence}</h3>
        ) : (
          <div className="rounded-xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-500">中文提示已隐藏</div>
        )}
        <div className="mt-3 grid gap-2 lg:grid-cols-2 lg:items-stretch">
          <div className="flex h-full flex-wrap items-center gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-500">
            <span>下次复习：{formatDue(getListeningDue(currentItem, progress).toISOString())}</span>
            <span>播放 {playCount} 次</span>
            <span>{tokens.length} 个听力块</span>
            {currentItem.listeningMastered && <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-emerald-700">✓ 已掌握</span>}
          </div>
          <ListeningScoreSummary progress={currentProgress} />
        </div>
        {suggestedMastered && (
          <div className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
            ✓ 建议已掌握：已练 {currentProgress?.reviewCount} 次，平均 {currentProgress?.averageScore}/100。可手动点击“听力已掌握”确认。
          </div>
        )}
        <div className="mobile-action-grid mt-3">
          <PrimaryButton onClick={() => playSentence()} disabled={audioLoading || audioPlaying}>
            {buttonIcon(audioLoading ? "…" : audioPlaying ? "▮▮" : "▶", audioLoading ? "生成中..." : audioPlaying ? "播放中" : "播放日语")}
          </PrimaryButton>
          <SecondaryButton onClick={stopSentenceAudio} disabled={!audioPlaying}>{buttonIcon("■", "停止音频")}</SecondaryButton>
          <SecondaryButton onClick={playSystemSentence} disabled={speaking}>{buttonIcon("◉", speaking ? "系统播放中" : "系统播放")}</SecondaryButton>
          <SecondaryButton onClick={stopSystemSentence} disabled={!speaking}>{buttonIcon("■", "停止系统")}</SecondaryButton>
          <SecondaryButton onClick={skipCurrent}>{buttonIcon("↷", "跳过当前")}</SecondaryButton>
          <SecondaryButton onClick={() => {
            if (!showPrompt) setUsedPromptHint(true);
            setShowPrompt(!showPrompt);
          }}>{buttonIcon(showPrompt ? "◌" : "◍", showPrompt ? "隐藏提示" : "显示提示")}</SecondaryButton>
          <SecondaryButton onClick={analyzeSentence} disabled={analysisLoading}>{buttonIcon(analysisLoading ? "…" : "✦", analysisLoading ? "解析中..." : "句子解析")}</SecondaryButton>
          <SecondaryButton onClick={() => {
            const allIndexes = tokens.map((_, index) => index);
            setRevealed(allIndexes);
            setRevealedEver((current) => Array.from(new Set([...current, ...allIndexes])));
          }} disabled={!tokens.length || tokenLoading}>{buttonIcon("☷", "显示全部")}</SecondaryButton>
        </div>
        <div className="mt-3 flex flex-wrap items-center gap-2">
          <label className="inline-flex cursor-pointer items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-things-50 hover:text-things-800">
            <input
              type="checkbox"
              checked={autoPlay}
              onChange={(event) => setAutoPlay(event.target.checked)}
              className="h-4 w-4 accent-things-600"
            />
            自动播放
          </label>
          {autoPlay && <span className="text-xs font-semibold text-slate-400">切换到下一句后自动播放日语</span>}
        </div>
        <div className="mt-3 flex flex-wrap items-center gap-1.5">
          <span className="mr-1 text-xs font-semibold text-slate-400">速度</span>
          {listeningRateOptions.map((rate) => (
            <button
              key={rate}
              onClick={() => onPlaybackRateChange(rate)}
              className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                playbackRate === rate ? "bg-things-600 text-white" : "bg-slate-100 text-slate-500 hover:bg-things-50 hover:text-things-800"
              }`}
            >
              {rate}x
            </button>
          ))}
        </div>
        {actionMessage && <p className="mt-2 rounded-xl bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">{actionMessage}</p>}
        {audioError && <p className="mt-2 text-sm text-red-600">{audioError}</p>}
      </div>

      {mobileListeningPage === "tools" && (
        <section className="rounded-2xl border border-line bg-white p-4 shadow-hairline md:hidden">
          <div className="text-sm font-semibold text-things-700">播放设置</div>
          <p className="mt-1 text-xs leading-5 text-slate-500">系统语音、自动播放和速度属于辅助操作，不占用精听主页面。</p>
          <div className="mobile-action-grid mt-3">
            <PrimaryButton onClick={() => playSentence()} disabled={audioLoading || audioPlaying}>
              {buttonIcon(audioLoading ? "…" : audioPlaying ? "▮▮" : "▶", audioLoading ? "生成中..." : audioPlaying ? "播放中" : "播放日语")}
            </PrimaryButton>
            <SecondaryButton onClick={stopSentenceAudio} disabled={!audioPlaying}>{buttonIcon("■", "停止音频")}</SecondaryButton>
            <SecondaryButton onClick={playSystemSentence} disabled={speaking}>{buttonIcon("◉", speaking ? "系统播放中" : "系统播放")}</SecondaryButton>
            <SecondaryButton onClick={stopSystemSentence} disabled={!speaking}>{buttonIcon("■", "停止系统")}</SecondaryButton>
          </div>
          <label className="mt-3 inline-flex cursor-pointer items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600">
            <input type="checkbox" checked={autoPlay} onChange={(event) => setAutoPlay(event.target.checked)} className="h-4 w-4 accent-things-600" />
            自动播放下一句
          </label>
          <div className="mt-3 flex flex-wrap items-center gap-1.5">
            <span className="mr-1 text-xs font-semibold text-slate-400">速度</span>
            {listeningRateOptions.map((rate) => (
              <button
                key={rate}
                onClick={() => onPlaybackRateChange(rate)}
                className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
                  playbackRate === rate ? "bg-things-600 text-white" : "bg-slate-100 text-slate-500"
                }`}
              >
                {rate}x
              </button>
            ))}
          </div>
          <div className="mobile-action-grid mt-4">
            <SecondaryButton onClick={analyzeSentence} disabled={analysisLoading}>{buttonIcon(analysisLoading ? "…" : "✦", analysisLoading ? "解析中..." : "句子解析")}</SecondaryButton>
            <SecondaryButton onClick={() => {
              const allIndexes = tokens.map((_, index) => index);
              setRevealed(allIndexes);
              setRevealedEver((current) => Array.from(new Set([...current, ...allIndexes])));
            }} disabled={!tokens.length || tokenLoading}>{buttonIcon("☷", "显示全部词块")}</SecondaryButton>
          </div>
        </section>
      )}

      {showAnalysis && (
        <section className={`${mobileListeningPage === "practice" ? "hidden md:block" : ""} mt-4 rounded-2xl border border-line bg-white p-4 shadow-hairline`}>
          <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
            <div>
              <div className="text-sm font-semibold text-things-700">句子解析</div>
              <p className="mt-1 text-sm leading-6 text-slate-600">重点看整句读音、语法结构和听力容易漏掉的声音。</p>
            </div>
            <SecondaryButton onClick={() => setShowAnalysis(false)}>收起</SecondaryButton>
          </div>
          {analysisLoading && <p className="mt-3 rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-500">正在解析...</p>}
          {!analysisLoading && sentenceAnalysis && (
            <div className="mt-4 grid gap-3">
              <Info label="日语原句" value={sentenceAnalysis.targetJa} />
              <Info label="句子意思" value={sentenceAnalysis.meaningZh || currentItem.zhSentence} />
              <Info label="整句读音" value={sentenceAnalysis.reading} />
              <Info label="结构说明" value={sentenceAnalysis.structureNotes} />
              {sentenceAnalysis.grammarPoints.length > 0 && (
                <div className="rounded-xl bg-slate-50 p-4">
                  <div className="text-sm font-semibold text-things-700">语法重点</div>
                  <div className="mt-2 grid gap-2">
                    {sentenceAnalysis.grammarPoints.map((point) => <p key={point} className="rounded-lg bg-white px-3 py-2 text-sm leading-7 text-slate-700 shadow-hairline">{point}</p>)}
                  </div>
                </div>
              )}
              {sentenceAnalysis.pronunciationTips.length > 0 && (
                <div className="rounded-xl bg-slate-50 p-4">
                  <div className="text-sm font-semibold text-things-700">听力读音注意</div>
                  <div className="mt-2 grid gap-2">
                    {sentenceAnalysis.pronunciationTips.map((tip) => <p key={tip} className="rounded-lg bg-white px-3 py-2 text-sm leading-7 text-slate-700 shadow-hairline">{tip}</p>)}
                  </div>
                </div>
              )}
            </div>
          )}
        </section>
      )}

      <section className="mt-4 rounded-2xl bg-slate-50 p-4">
        <div
          onDragOver={(event) => event.preventDefault()}
          onDrop={handleDropDifficultWord}
          className={`${mobileListeningPage === "practice" ? "hidden md:block" : ""} mb-4 rounded-2xl border border-dashed border-things-200 bg-white p-3 shadow-hairline`}
        >
          <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
            <div>
              <div className="text-sm font-semibold text-things-700">难词框</div>
              <p className="mt-0.5 text-xs text-slate-500">把已揭示的词拖进来；默认隐藏明文，下次遇到会标红。</p>
            </div>
            <div className="flex flex-wrap items-center gap-2">
              <SecondaryButton onClick={() => setShowDifficultWords(!showDifficultWords)}>
                {showDifficultWords ? "隐藏难词" : "显示难词"}
              </SecondaryButton>
              {wordSaving && <span className="text-xs font-semibold text-slate-400">保存中...</span>}
            </div>
          </div>
          <div className="mt-3 flex min-h-10 flex-wrap gap-2 rounded-xl bg-slate-50 p-2">
            {currentDifficultWords.map((word) => (
              <span key={word} className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-1 text-sm font-semibold text-red-700">
                <button
                  onClick={() => addListeningWordToCorpus(word)}
                  disabled={addingCorpusWord === word || addedCorpusWords.includes(word)}
                  title={addedCorpusWords.includes(word) ? "已添加corpus" : "添加corpus"}
                  aria-label={`${addedCorpusWords.includes(word) ? "已添加corpus" : "添加corpus"}：${word}`}
                  className={`grid h-7 w-7 place-items-center rounded-full text-xs font-bold transition ${
                    addedCorpusWords.includes(word)
                      ? "bg-white text-red-300"
                      : "bg-white text-red-700 hover:bg-red-100"
                  } disabled:cursor-not-allowed`}
                >
                  {addingCorpusWord === word ? "..." : addedCorpusWords.includes(word) ? "✓" : "+C"}
                </button>
                <span className="min-w-16 px-1.5 text-center">
                  {showDifficultWords ? word : "••••"}
                </span>
                <button
                  onClick={() => removeDifficultWord(word)}
                  title="移出难词"
                  aria-label={`移出难词：${word}`}
                  className="grid h-7 w-7 place-items-center rounded-full text-base leading-none hover:bg-red-100"
                >
                  ×
                </button>
              </span>
            ))}
            {!currentDifficultWords.length && <span className="px-1 py-2 text-sm text-slate-400">暂无难词</span>}
          </div>
        </div>
        <div className={mobileListeningPage === "tools" ? "hidden md:block" : ""}>
        <div className="mb-3 flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
          <div className="text-sm font-semibold text-things-700">听到的词</div>
          <div className="text-xs font-semibold text-slate-400">{tokenLoading ? "生成并保存分词中..." : `${tokens.length} 个听力块`}</div>
        </div>
        {tokenError && <p className="mb-3 rounded-xl bg-warm-50 px-3 py-2 text-sm text-warm-700">{tokenError}</p>}
        {!tokenLoading && !tokens.length && !tokenError && (
          <div className="rounded-xl bg-white p-3 shadow-hairline">
            <p className="text-sm text-slate-500">这个句子还没有入库分词。新导入的句子会自动分词；旧句子可以手动生成一次。</p>
            <div className="mt-3">
              <SecondaryButton onClick={generateListeningTokens}>生成并保存分词</SecondaryButton>
            </div>
          </div>
        )}
        <div className="flex flex-wrap gap-2">
          {tokens.map((token, index) => {
            const isRevealed = revealed.includes(index);
            const isDifficult = currentDifficultWords.includes(token);
            return (
              <button
                key={`${token}_${index}`}
                onClick={() => reveal(index)}
                draggable={isRevealed}
                onDragStart={(event) => {
                  if (!isRevealed) return;
                  event.dataTransfer.setData("text/plain", token);
                }}
                onTouchStart={(event) => { if (isRevealed) wordDrag.start(token, event.touches[0]); }}
                onTouchMove={(event) => wordDrag.move(event.touches[0])}
                onTouchEnd={wordDrag.end}
                style={isRevealed ? { touchAction: "none" } : undefined}
                className={`min-h-12 rounded-xl px-3 py-2 text-sm font-semibold shadow-hairline ${
                  isRevealed
                    ? isDifficult ? "bg-red-50 text-red-700 ring-1 ring-red-200" : "bg-white text-ink"
                    : isDifficult ? "bg-red-100 text-red-100 ring-1 ring-red-200" : "bg-slate-200 text-slate-400"
                }`}
              >
                {isRevealed ? token : "••••"}
              </button>
            );
          })}
        </div>
        <MobileWordDragLayer drag={wordDrag} label="难词" />
        <div className="mt-3 rounded-xl bg-white px-3 py-2 shadow-hairline md:hidden">
          <div className="flex items-center justify-between gap-3">
            <span className="text-xs font-semibold text-slate-500">本轮评分</span>
            <span className={`text-base font-semibold ${scoreTextColor(score)}`}>{score}/100</span>
          </div>
          <div className="mt-1 text-xs font-semibold text-slate-400">揭示 {revealedCount}/{tokens.length} · 播放 {playCount} 次</div>
        </div>
        <div className="hidden md:block">
          <ListeningScoreCard
            score={score}
            revealedCount={revealedCount}
            tokenCount={tokens.length}
            playCount={playCount}
            replayPenalty={replayPenalty}
            assistancePenalty={assistancePenalty}
            progress={currentProgress}
            suggestedMastered={suggestedMastered}
            mastered={currentItem.listeningMastered}
            onReset={resetListeningScore}
          />
        </div>
        {showPrompt && <div className="mt-3 text-sm text-slate-500">中文含义：{currentItem.zhSentence}</div>}
        {!showMobileListeningResult ? (
          <div className="mt-4 [&>button]:w-full md:hidden">
            <PrimaryButton onClick={() => setShowMobileListeningResult(true)}>完成本句</PrimaryButton>
          </div>
        ) : (
          <div className="mt-4 rounded-xl border border-things-100 bg-white p-3 shadow-hairline md:hidden">
            <div className="flex items-center justify-between gap-3">
              <div>
                <div className="text-sm font-semibold text-ink">安排下次复习</div>
                <div className="mt-1 text-xs font-semibold text-slate-400">本轮评分 {score}/100</div>
              </div>
              <button type="button" onClick={() => setShowMobileListeningResult(false)} className="text-xs font-semibold text-things-700">继续精听</button>
            </div>
            <div className="mobile-action-grid mt-3">
              <SecondaryButton onClick={() => schedule(10)}>10 分钟后</SecondaryButton>
              <SecondaryButton onClick={() => schedule(1440)}>1 天后</SecondaryButton>
              <SecondaryButton onClick={() => schedule(4320)}>3 天后</SecondaryButton>
              <SecondaryButton onClick={() => schedule(10080)}>7 天后</SecondaryButton>
              <PrimaryButton onClick={markMastered} disabled={!tokens.length || tokenLoading || mastering}>{mastering ? "标记中..." : "听力已掌握"}</PrimaryButton>
            </div>
          </div>
        )}
        <div className="mt-4 hidden md:block">
          <div className="mobile-action-grid">
            <SecondaryButton onClick={() => schedule(10)}>10 分钟后</SecondaryButton>
            <SecondaryButton onClick={() => schedule(1440)}>1 天后</SecondaryButton>
            <SecondaryButton onClick={() => schedule(4320)}>3 天后</SecondaryButton>
            <SecondaryButton onClick={() => schedule(10080)}>7 天后</SecondaryButton>
            <PrimaryButton onClick={markMastered} disabled={!tokens.length || tokenLoading || mastering}>{mastering ? "标记中..." : "听力已掌握"}</PrimaryButton>
          </div>
        </div>
        </div>
      </section>
    </main>
  );
}

function ListeningScoreSummary({ progress }: { progress?: ListeningProgress }) {
  if (!progress?.updatedAt) {
    return (
      <div className="flex h-full items-center rounded-xl bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-400">
        还没有上次分数
      </div>
    );
  }
  const lastScore = clampScore(progress.lastScore);
  const averageScore = clampScore(progress.averageScore || 0);
  return (
    <div className="flex h-full flex-wrap items-center justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-500">
      <span>上次 {lastScore}/100</span>
      <span>平均 {averageScore}</span>
      <span>{progress.reviewCount || 0} 次</span>
    </div>
  );
}

function ListeningScoreCard({
  score,
  revealedCount,
  tokenCount,
  playCount,
  replayPenalty,
  assistancePenalty,
  progress,
  suggestedMastered,
  mastered,
  onReset
}: {
  score: number;
  revealedCount: number;
  tokenCount: number;
  playCount: number;
  replayPenalty: number;
  assistancePenalty: number;
  progress?: ListeningProgress;
  suggestedMastered: boolean;
  mastered: boolean;
  onReset: () => void;
}) {
  const averageScore = clampScore(progress?.averageScore || 0);
  const reviewCount = progress?.reviewCount || 0;
  return (
    <div className="mt-3 rounded-xl bg-white/70 px-3 py-2 shadow-hairline">
      <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
        <div className="flex min-w-0 flex-wrap items-center gap-2">
          <span className="text-xs font-semibold text-slate-500">听力评价</span>
          <span className={`text-sm font-semibold ${scoreTextColor(score)}`}>{score}/100</span>
          <span className="text-xs font-semibold text-slate-500">揭示 {revealedCount} / {tokenCount} 个词</span>
          <span className="text-xs font-semibold text-slate-500">播放 {playCount} 次</span>
          {replayPenalty > 0 && <span className="text-xs font-semibold text-slate-400">重听 -{replayPenalty}</span>}
          {assistancePenalty > 0 && <span className="text-xs font-semibold text-slate-400">辅助 -{assistancePenalty}</span>}
          <span className="text-xs font-semibold text-slate-400">藏回不恢复分数</span>
        </div>
        <div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-500">
          <span>上次 {progress?.updatedAt ? clampScore(progress.lastScore) : "—"}</span>
          <span>平均 {reviewCount ? averageScore : "—"}</span>
          <span>{reviewCount} 次</span>
          <button
            type="button"
            onClick={onReset}
            className="text-slate-300 underline-offset-2 hover:text-slate-500 hover:underline"
          >
            初始化评分
          </button>
        </div>
      </div>
      <div className="mt-2 grid gap-2 md:grid-cols-[minmax(0,1fr)_7rem] md:items-end">
        <div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
          <div className={`h-full rounded-full ${scoreBarColor(score)}`} style={{ width: `${clampScore(score)}%` }} />
        </div>
        <ScoreSparkline scores={progress?.recentScores || []} compact />
      </div>
      <div className="mt-2 grid gap-1">
        {mastered && <div className="rounded-lg bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700">✓ 已掌握</div>}
        {suggestedMastered && <div className="rounded-lg bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-800">✓ 建议已掌握，不会自动修改状态。</div>}
      </div>
    </div>
  );
}

function ScoreSparkline({ scores, compact }: { scores: number[]; compact?: boolean }) {
  if (!scores.length) return null;
  return (
    <div className={`${compact ? "mt-1 h-5" : "mt-2 h-10"} flex items-end gap-1`} aria-label="最近分数走势">
      {scores.map((value, index) => {
        const score = clampScore(value);
        return (
          <div
            key={`${score}_${index}`}
            className={`w-full min-w-1 rounded-t ${scoreBarColor(score)}`}
            style={{ height: `${Math.max(compact ? 24 : 16, score)}%` }}
            title={`第 ${index + 1} 次：${score}/100`}
          />
        );
      })}
    </div>
  );
}

function clampScore(score: number): number {
  if (!Number.isFinite(score)) return 0;
  return Math.max(0, Math.min(100, Math.round(score)));
}

function scoreTextColor(score: number): string {
  if (score >= 90) return "text-emerald-700";
  if (score >= 75) return "text-things-700";
  if (score >= 60) return "text-warm-600";
  return "text-red-600";
}

function scoreBarColor(score: number): string {
  if (score >= 90) return "bg-emerald-500";
  if (score >= 75) return "bg-things-500";
  if (score >= 60) return "bg-warm-500";
  return "bg-red-500";
}

function matchesImportDateFilter(value: string, filter: "all" | "today" | "week" | "older"): boolean {
  if (filter === "all") return true;
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return false;
  const now = new Date();
  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const tomorrowStart = todayStart + 24 * 60 * 60 * 1000;
  const day = now.getDay() || 7;
  const weekStart = todayStart - (day - 1) * 24 * 60 * 60 * 1000;
  const time = date.getTime();
  if (filter === "today") return time >= todayStart && time < tomorrowStart;
  if (filter === "week") return time >= weekStart && time < tomorrowStart;
  return time < weekStart;
}

function sessionPriority(session: DrillSession): DrillPriority {
  return session.filters?.priority === "new" ? "new" : "old";
}

function drillPriorityLabel(priority: DrillPriority): string {
  return priority === "new" ? "新卡优先" : "旧卡优先";
}

function drillSessionStatusLabel(status: DrillSession["status"]): string {
  if (status === "completed") return "已完成";
  if (status === "stopped") return "已中止";
  if (status === "archived") return "已归档";
  return "进行中";
}

function drillSessionDeadlineLabel(session: DrillSession): string {
  if (!session.plannedDueAt) return "计划完成：无期限";
  const due = new Date(session.plannedDueAt);
  if (Number.isNaN(due.getTime())) return "计划完成：无期限";
  const overdue = due.getTime() < Date.now() && session.status === "active";
  return `计划完成：${formatDateTime(session.plannedDueAt)}${overdue ? " · 已超期" : ""}`;
}

function sortDrillItems(items: TranslationItem[], mode: "translation" | "listening", priority: DrillPriority, progress: Record<string, ListeningProgress>): TranslationItem[] {
  return [...items].sort((a, b) => {
    const aPhase = drillItemPhase(a, mode, progress);
    const bPhase = drillItemPhase(b, mode, progress);
    const aRank = drillPhaseRank(aPhase, priority);
    const bRank = drillPhaseRank(bPhase, priority);
    if (aRank !== bRank) return aRank - bRank;
    if (aPhase === "new" && bPhase === "new") {
      return stableRandomRank(a.id) - stableRandomRank(b.id);
    }
    return compareReviewDue(a, b, mode, progress);
  });
}

function reorderDrillSessionItemIds(session: DrillSession, items: TranslationItem[], priority: DrillPriority, progress: Record<string, ListeningProgress>): string[] {
  const itemMap = new Map(items.map((item) => [item.id, item]));
  if (session.status !== "active") {
    return sortDrillItems(session.itemIds.map((id) => itemMap.get(id)).filter(Boolean) as TranslationItem[], session.mode, priority, progress).map((item) => item.id);
  }
  const doneIds = session.itemIds.slice(0, session.currentIndex);
  const remainingIds = session.itemIds.slice(session.currentIndex);
  const remainingItems = remainingIds.map((id) => itemMap.get(id)).filter(Boolean) as TranslationItem[];
  const missingRemainingIds = remainingIds.filter((id) => !itemMap.has(id));
  return [...doneIds, ...sortDrillItems(remainingItems, session.mode, priority, progress).map((item) => item.id), ...missingRemainingIds];
}

function recoverableDrillSessionItemIds(session: DrillSession, items: TranslationItem[], progress: Record<string, ListeningProgress>): string[] {
  const sessionItems = sessionItemsFromIds(session.itemIds, items);
  const incompleteItems = incompleteDrillItems(session.mode, sessionItems, progress);
  if (session.status !== "active") return incompleteItems.map((item) => item.id);
  if (session.currentIndex >= session.itemIds.length) return incompleteItems.map((item) => item.id);
  return session.itemIds;
}

function sessionItemsFromIds(itemIds: string[], items: TranslationItem[]): TranslationItem[] {
  const itemMap = new Map(items.map((item) => [item.id, item]));
  return itemIds.map((id) => itemMap.get(id)).filter(Boolean) as TranslationItem[];
}

function incompleteDrillItems(mode: "translation" | "listening", items: TranslationItem[], progress: Record<string, ListeningProgress>): TranslationItem[] {
  return items.filter((item) => drillItemPhase(item, mode, progress) !== "mastered");
}

function arraysEqual(left: string[], right: string[]): boolean {
  return left.length === right.length && left.every((value, index) => value === right[index]);
}

function drillItemPhase(item: TranslationItem, mode: "translation" | "listening", progress: Record<string, ListeningProgress>): "mastered" | "new" | "due" | "waiting" {
  if (mode === "translation") {
    if (item.status === "archived") return "mastered";
    if (!item.attemptCount) return "new";
    return new Date(item.nextDueAt).getTime() <= Date.now() ? "due" : "waiting";
  }
  if (item.listeningMastered) return "mastered";
  if (!progress[item.id]) return "new";
  return getListeningDue(item, progress).getTime() <= Date.now() ? "due" : "waiting";
}

function drillPhaseRank(phase: "mastered" | "new" | "due" | "waiting", priority: DrillPriority): number {
  if (phase === "mastered") return 3;
  if (priority === "new") {
    if (phase === "new") return 0;
    if (phase === "due") return 1;
    return 2;
  }
  if (phase === "due") return 0;
  if (phase === "new") return 1;
  return 2;
}

function compareReviewDue(a: TranslationItem, b: TranslationItem, mode: "translation" | "listening", progress: Record<string, ListeningProgress>): number {
  const dueDiff = drillDueTime(a, mode, progress) - drillDueTime(b, mode, progress);
  if (dueDiff !== 0) return dueDiff;
  return stableRandomRank(a.id) - stableRandomRank(b.id);
}

function drillDueTime(item: TranslationItem, mode: "translation" | "listening", progress: Record<string, ListeningProgress>): number {
  return mode === "listening" ? getListeningDue(item, progress).getTime() : new Date(item.nextDueAt || item.createdAt || "").getTime();
}

function stableRandomRank(value: string): number {
  let hash = 2166136261;
  for (let index = 0; index < value.length; index += 1) {
    hash ^= value.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }
  return hash >>> 0;
}

function drillQueueDoneCount(session: DrillSession): number {
  if (session.status === "completed") return session.itemIds.length;
  return Math.min(session.currentIndex, session.itemIds.length);
}

function drillSessionIncompleteCount(session: DrillSession, items: TranslationItem[], progress: Record<string, ListeningProgress>): number {
  return incompleteDrillItems(session.mode, sessionItemsFromIds(session.itemIds, items), progress).length;
}

function drillItemStats(mode: "translation" | "listening", items: TranslationItem[], progress: Record<string, ListeningProgress>) {
  const total = items.length;
  const mastered = items.filter((item) => mode === "translation" ? item.status === "archived" : item.listeningMastered).length;
  const due = items.filter((item) => drillItemPhase(item, mode, progress) === "due").length;
  return {
    total,
    mastered,
    due,
    learning: Math.max(0, total - mastered)
  };
}

function drillItemMasteryLabel(mode: "translation" | "listening", item: TranslationItem, progress: Record<string, ListeningProgress>): string {
  const phase = drillItemPhase(item, mode, progress);
  const prefix = mode === "translation" ? "翻译" : "听力";
  if (phase === "mastered") return `${prefix}已掌握`;
  if (phase === "new") return `${prefix}新卡`;
  if (phase === "due") return `${prefix}到期`;
  return `${prefix}待复习`;
}

function getListeningDue(item: TranslationItem, progress: Record<string, ListeningProgress>): Date {
  return new Date(progress[item.id]?.nextDueAt || item.createdAt || new Date().toISOString());
}

function tokenizeJapanese(text: string): string[] {
  const cleaned = text
    .replace(/[「」『』（）()［］\[\]【】]/g, " ")
    .replace(/[。、！？!?；;：:、，,.]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
  if (!cleaned) return [];

  const groups = cleaned.match(/[一-龯々]+|[ぁ-んー]+|[ァ-ンー]+|[A-Za-z0-9]+/g) || [];
  const tokens: string[] = [];
  for (let index = 0; index < groups.length; index += 1) {
    const current = groups[index];
    const next = groups[index + 1] || "";
    if (isKanjiRun(current) && isHiraganaRun(next) && shouldAttachOkurigana(next)) {
      tokens.push(current + next);
      index += 1;
    } else if (isKanjiRun(current)) {
      tokens.push(...splitKanjiRun(current));
    } else if (isHiraganaRun(current)) {
      tokens.push(...splitHiraganaRun(current));
    } else {
      tokens.push(current);
    }
  }
  return mergeListeningTokens(tokens.filter(Boolean));
}

function isKanjiRun(value: string): boolean {
  return /^[一-龯々]+$/.test(value);
}

function isHiraganaRun(value: string): boolean {
  return /^[ぁ-んー]+$/.test(value);
}

function shouldAttachOkurigana(value: string): boolean {
  if (!value) return false;
  if (/^(は|が|を|に|へ|で|と|も|の|や|か|から|まで|より|ので|のに)/.test(value)) return false;
  return true;
}

function splitKanjiRun(value: string): string[] {
  const keep = new Set(["有頂天", "気分転換", "人間関係", "日本語", "中国語", "午前様"]);
  if (keep.has(value) || value.length <= 2) return [value];
  if (value.length === 3) {
    const tailSingle = new Set(["話", "場", "本", "人", "時", "日", "金", "者", "点", "数"]);
    return tailSingle.has(value.slice(-1)) ? [value.slice(0, 2), value.slice(2)] : [value];
  }
  const chunks: string[] = [];
  for (let index = 0; index < value.length; index += 2) {
    const rest = value.length - index;
    if (rest === 3) {
      chunks.push(value.slice(index, index + 2), value.slice(index + 2));
      break;
    }
    chunks.push(value.slice(index, index + 2));
  }
  return chunks;
}

function splitHiraganaRun(value: string): string[] {
  const multiParticles = ["とあって", "から", "まで", "より", "ので", "のに", "では", "には", "とは", "でも", "とも", "って"];
  const singleParticles = new Set(["は", "が", "を", "に", "へ", "で", "と", "も", "の", "や", "か", "ね", "よ"]);
  const tokens: string[] = [];
  let buffer = value;
  while (buffer) {
    const multi = multiParticles.find((particle) => buffer.startsWith(particle));
    if (multi) {
      tokens.push(multi);
      buffer = buffer.slice(multi.length);
      continue;
    }
    const char = buffer[0];
    if (singleParticles.has(char)) {
      tokens.push(char);
      buffer = buffer.slice(1);
      continue;
    }
    const nextBoundary = findNextParticleIndex(buffer, multiParticles, singleParticles);
    if (nextBoundary <= 0) {
      tokens.push(buffer);
      break;
    }
    tokens.push(buffer.slice(0, nextBoundary));
    buffer = buffer.slice(nextBoundary);
  }
  return tokens;
}

function findNextParticleIndex(value: string, multiParticles: string[], singleParticles: Set<string>): number {
  let best = -1;
  multiParticles.forEach((particle) => {
    const index = value.indexOf(particle, 1);
    if (index > 0 && (best < 0 || index < best)) best = index;
  });
  Array.from(value).forEach((char, index) => {
    if (index > 0 && singleParticles.has(char) && (best < 0 || index < best)) best = index;
  });
  return best;
}

function mergeListeningTokens(tokens: string[]): string[] {
  const merged: string[] = [];
  tokens.forEach((token) => {
    const previous = merged[merged.length - 1] || "";
    if (previous && /^[ぁ-んー]+$/.test(token) && /[一-龯々]/.test(previous) && shouldAttachOkurigana(token)) {
      merged[merged.length - 1] = previous + token;
    } else {
      merged.push(token);
    }
  });
  return merged;
}

function getFrequentTags(items: TranslationItem[]): string[] {
  const counts = new Map<string, number>();
  items.forEach((item) => {
    (item.tags || []).forEach((tag) => counts.set(tag, (counts.get(tag) || 0) + 1));
  });
  return Array.from(counts.entries())
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([tag]) => tag)
    .slice(0, 10);
}

function suggestTranslationTags(item: TranslationItem, frequentTags: string[]): string[] {
  const text = `${item.sourceSentence || ""} ${item.zhSentence} ${item.recommendedJa} ${(item.keyExpressions || []).join(" ")}`;
  const candidates: string[] = [];
  const add = (tag: string) => {
    if (!candidates.includes(tag) && !(item.tags || []).includes(tag)) candidates.push(tag);
  };
  if (/自己紹介|自我介绍|紹介/.test(text)) add("自我介绍");
  if (/仕事|入職|会社|面接|职|工作|上司|同僚/.test(text)) add("工作");
  if (/SNS|コンテンツ|再生回数|発信|民意/.test(text)) add("SNS");
  if (/睡眠|眠|起き|寝坊|ストレス|気持ち/.test(text)) add("生活状态");
  if (/本|作品|ミステリー|読み|映画|映像/.test(text)) add("作品感想");
  if (/と思|感じ|リスク|魅力|意見|观点|考え/.test(text)) add("观点表达");
  if (/助かります|ください|もらえたら|お願いします/.test(text)) add("请求");
  if (/だよ|ですね|よね|ちゃう|ってこと/.test(text)) add("口语");
  frequentTags.forEach((tag) => {
    if (candidates.length < 8 && !(item.tags || []).includes(tag)) candidates.push(tag);
  });
  return candidates.slice(0, 8);
}

function sourceLabel(sourceType = ""): string {
  const labels: Record<string, string> = {
    manual: "手动添加",
    quick: "Quick Loop",
    weekly: "Weekly Mode",
    translation: "句子训练",
    listening: "听力练习",
    monologue: "独白打磨"
  };
  return labels[sourceType] || sourceType || "未知来源";
}

function detailStatusLabel(status = "pending"): string {
  if (status === "ready") return "解释已生成";
  if (status === "processing") return "解释生成中";
  if (status === "error") return "解释失败";
  return "等待解释";
}

function getPageNumbers(current: number, total: number): number[] {
  if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
  const pages = new Set<number>([1, total, current, current - 1, current + 1]);
  if (current <= 3) {
    pages.add(2);
    pages.add(3);
    pages.add(4);
  }
  if (current >= total - 2) {
    pages.add(total - 1);
    pages.add(total - 2);
    pages.add(total - 3);
  }
  return Array.from(pages)
    .filter((page) => page >= 1 && page <= total)
    .sort((a, b) => a - b);
}

function MetaLine({ icon, label, value }: { icon: "status" | "created" | "updated" | "due" | "source"; label: string; value: string }) {
  return (
    <span className="flex items-center gap-1.5">
      <MetaIcon name={icon} />
      <span className="font-semibold text-slate-500">{label}：</span>
      <span>{value}</span>
    </span>
  );
}

function FilterPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
  return (
    <button
      onClick={onClick}
      className={`h-8 rounded-full px-3 text-xs font-semibold transition ${
        active ? "bg-things-600 text-white shadow-hairline" : "bg-white text-slate-600 shadow-hairline hover:bg-things-50 hover:text-things-800"
      }`}
    >
      {children}
    </button>
  );
}

function TagSuggestionRow({ title, tags, onAdd }: { title: string; tags: string[]; onAdd: (tag: string) => void }) {
  return (
    <div className="grid gap-2 md:grid-cols-[64px_1fr] md:items-start">
      <div className="pt-1 text-xs font-semibold text-slate-500">{title}</div>
      <div className="flex flex-wrap gap-1.5">
        {tags.map((tag) => (
          <button
            key={`${title}_${tag}`}
            onClick={() => onAdd(tag)}
            className="h-7 rounded-full bg-things-50 px-2.5 text-xs font-semibold text-things-700 hover:bg-things-100"
          >
            + {tag}
          </button>
        ))}
      </div>
    </div>
  );
}

function MetaIcon({ name }: { name: "status" | "created" | "updated" | "due" | "source" }) {
  const paths = {
    status: "M9 12l2 2 4-5M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
    created: "M8 7V3m8 4V3M5 11h14M6 5h12a2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V7a2 2 0 012-2z",
    updated: "M4 4v6h6M20 20v-6h-6M5 15a7 7 0 0011.5 2.5M19 9A7 7 0 007.5 6.5",
    due: "M12 6v6l4 2M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
    source: "M4 7h16M4 12h10M4 17h16"
  };
  return (
    <svg className="h-3.5 w-3.5 shrink-0 text-things-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={paths[name]} />
    </svg>
  );
}

function ShadowingPanel({ text, recorder, compact }: { text: string; recorder: ReturnType<typeof useRecorder>; compact?: boolean }) {
  const [canSpeak, setCanSpeak] = useState(false);
  const [speaking, setSpeaking] = useState(false);
  const [aiAudioUrl, setAiAudioUrl] = useState("");
  const [aiAudioLoading, setAiAudioLoading] = useState(false);
  const [aiAudioError, setAiAudioError] = useState("");

  useEffect(() => {
    setCanSpeak(typeof window !== "undefined" && "speechSynthesis" in window);
  }, []);

  useEffect(() => {
    setAiAudioUrl("");
    setAiAudioError("");
  }, [text]);

  function playReference() {
    if (!canSpeak || !text.trim()) return;
    window.speechSynthesis.cancel();
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.lang = "ja-JP";
    utterance.rate = 0.9;
    utterance.onend = () => setSpeaking(false);
    utterance.onerror = () => setSpeaking(false);
    setSpeaking(true);
    window.speechSynthesis.speak(utterance);
  }

  function stopReference() {
    window.speechSynthesis?.cancel();
    setSpeaking(false);
  }

  async function playAiReference() {
    if (!text.trim()) return;
    setAiAudioError("");
    setAiAudioLoading(true);
    try {
      const url = aiAudioUrl || await apiSpeakText(text);
      setAiAudioUrl(url);
      const audio = new Audio(url);
      audio.play();
    } catch (error) {
      setAiAudioError(error instanceof Error ? error.message : "AI 语音生成失败");
    } finally {
      setAiAudioLoading(false);
    }
  }

  return (
    <div className={`${compact ? "mt-4" : "mt-5"} rounded-2xl border border-line bg-white p-4`}>
      <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
        <div>
          <div className="text-sm font-semibold text-things-700">跟读练习</div>
          <p className="mt-1 text-sm leading-6 text-slate-600">听一遍参考音，再录自己的版本回放对比。</p>
        </div>
        <div className="mobile-action-grid">
          <PrimaryButton onClick={playAiReference} disabled={!text.trim() || aiAudioLoading}>{aiAudioLoading ? "生成中..." : "AI 语音播放"}</PrimaryButton>
          <SecondaryButton onClick={playReference} disabled={!canSpeak || !text.trim() || speaking}>系统播放</SecondaryButton>
          <SecondaryButton onClick={stopReference} disabled={!speaking}>停止播放</SecondaryButton>
        </div>
      </div>
      <div className="mt-3 rounded-xl bg-slate-50 p-3 text-sm leading-7 text-slate-700">{text}</div>
      {aiAudioError && <p className="mt-3 text-sm text-red-600">{aiAudioError}</p>}
      <div className="mobile-action-grid mt-3">
        <PrimaryButton onClick={recorder.start} disabled={recorder.isRecording}>录跟读</PrimaryButton>
        <SecondaryButton onClick={recorder.stop} disabled={!recorder.isRecording}>停止录音</SecondaryButton>
        {recorder.isRecording && <span className="rounded-full bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">录音中</span>}
      </div>
      {recorder.audioUrl && <audio controls src={recorder.audioUrl} className="mt-3 w-full" />}
      {recorder.error && <p className="mt-3 text-sm text-red-600">{recorder.error}</p>}
      {!canSpeak && <p className="mt-3 text-sm text-slate-500">当前浏览器不支持参考音播放，可以直接看文本跟读。</p>}
    </div>
  );
}

function formatDue(value: string): string {
  const diff = new Date(value).getTime() - Date.now();
  if (diff <= 0) return "现在";
  const minutes = Math.ceil(diff / 60000);
  if (minutes < 60) return `${minutes} 分钟后`;
  const hours = Math.ceil(minutes / 60);
  if (hours < 24) return `${hours} 小时后`;
  return `${Math.ceil(hours / 24)} 天后`;
}

function formatDateTime(value: string): string {
  try {
    return new Date(value).toLocaleString();
  } catch {
    return value;
  }
}

function formatSeconds(value: number): string {
  const total = Math.max(0, Math.floor(Number(value) || 0));
  const minutes = Math.floor(total / 60);
  const seconds = total % 60;
  return `${minutes}:${String(seconds).padStart(2, "0")}`;
}

function formatDuration(seconds: number): string {
  if (!Number.isFinite(seconds) || seconds <= 0) return "0 秒";
  const minutes = Math.floor(seconds / 60);
  const rest = Math.floor(seconds % 60);
  if (!minutes) return `${rest} 秒`;
  const hours = Math.floor(minutes / 60);
  const minuteRest = minutes % 60;
  if (!hours) return `${minutes} 分 ${rest} 秒`;
  return `${hours} 小时 ${minuteRest} 分`;
}

function RecordingPractice({ topic, day, corpusItems, onSave }: { topic: Topic | null; day: number; corpusItems: CorpusItem[]; onSave: (session: PracticeSession, aiItems: CorpusItem[]) => void }) {
  const recorder = useRecorder();
  const [rawTranscript, setRawTranscript] = useState("");
  const [selfCorrection, setSelfCorrection] = useState("");
  const [ai, setAi] = useState<AIResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [aiError, setAiError] = useState("");

  async function optimize() {
    if (!selfCorrection.trim()) return;
    setAiError("");
    setLoading(true);
    try {
      const response = await apiOptimizeJapanese(topic, rawTranscript, selfCorrection);
      setAi(response);
    } catch (error) {
      setAiError(error instanceof Error ? error.message : "AI 优化失败");
    } finally {
      setLoading(false);
    }
  }

  function save() {
    const session: PracticeSession = {
      id: id("session"),
      day: 1,
      rawTranscript,
      selfCorrection,
      aiCorrection: ai?.improvedVersion || "",
      notes: ai?.explanation || "",
      createdAt: todayIso()
    };
    onSave(session, ai?.corpusItems || []);
  }

  return (
    <section>
      <Header eyebrow={`Day ${Math.min(day, 1)} Prepare`} title="准备期：先输出，再修正" description="必须先完成自主修正，才能点击 AI 优化。" />
      <ThinkingPanel topic={topic} corpusItems={corpusItems} />
      <RecorderPanel recorder={recorder} />
      <CorrectionGrid
        rawTranscript={rawTranscript}
        selfCorrection={selfCorrection}
        aiCorrection={ai?.improvedVersion || ""}
        onRaw={setRawTranscript}
        onSelf={setSelfCorrection}
      />
      <div className="mobile-action-grid mt-4">
        <PrimaryButton onClick={optimize} disabled={!selfCorrection.trim() || loading}>{loading ? "AI 优化中..." : "AI 优化"}</PrimaryButton>
        <SecondaryButton onClick={save} disabled={!ai}>保存 Day 1 并生成语料池</SecondaryButton>
      </div>
      {aiError && <p className="mt-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{aiError}</p>}
      {!ai && (
        <p className="mt-3 text-sm text-slate-500">
          Day 1 需要先填写“我的自主修正”并完成 AI 优化，才能保存为本周训练起点。
        </p>
      )}
      {ai && <AIResult ai={ai} existingCount={corpusItems.length} />}
    </section>
  );
}

function DailyTraining({ topic, corpusItems, sessions, onSave }: { topic: Topic | null; corpusItems: CorpusItem[]; sessions: PracticeSession[]; onSave: (session: PracticeSession, items: CorpusItem[], nextDay: number) => void }) {
  const day = Math.min(Math.max(topic?.currentDay || 2, 2), 4);
  const todayItems = useMemo(() => chooseDailyItems(corpusItems, day), [corpusItems, day]);
  const recorder = useRecorder();
  const [rawTranscript, setRawTranscript] = useState("");
  const [selfCorrection, setSelfCorrection] = useState("");
  const [aiText, setAiText] = useState("");
  const [loading, setLoading] = useState(false);
  const [aiError, setAiError] = useState("");

  async function optimize() {
    if (!selfCorrection.trim()) return;
    setAiError("");
    setLoading(true);
    try {
      const response = await apiOptimizeJapanese(topic, rawTranscript, selfCorrection);
      setAiText(response.improvedVersion);
    } catch (error) {
      setAiError(error instanceof Error ? error.message : "AI 修正失败");
    } finally {
      setLoading(false);
    }
  }

  function save() {
    const updatedItems = corpusItems.map((item) => {
      if (!todayItems.some((todayItem) => todayItem.id === item.id)) return item;
      return { ...item, masteryStatus: day >= 4 ? "已掌握" : "练习中" as MasteryStatus };
    });
    onSave(
      {
        id: id("session"),
        day,
        rawTranscript,
        selfCorrection,
        aiCorrection: aiText,
        notes: `今日练习表达：${todayItems.map((item) => item.chunk).join(" / ")}`,
        createdAt: todayIso()
      },
      updatedItems,
      Math.min(day + 1, 5)
    );
  }

  if (!sessions.find((session) => session.day === 1) && corpusItems.length === 0) {
    return <Locked title="训练还未解锁" text="请先完成一次 Quick Loop，或在 Corpus Pool 添加至少 1 条表达。" />;
  }

  if (corpusItems.length === 0) {
    return <Locked title="语料池为空" text="请先在 Corpus Pool 添加至少 1 条表达，Day 2-4 只会使用你保存进语料池的表达。" />;
  }

  return (
    <section>
      <Header eyebrow={`Day ${day} Training`} title="执行期：用固定表达重新讲同一主题" description="今天只练 2 个表达，优先抽取语料池中未练习、最近新增的表达。" />
      <ThinkingPanel topic={topic} corpusItems={corpusItems} />
      <div className="mb-4 rounded-2xl bg-white p-4 text-sm text-slate-600 shadow-hairline">
        今日表达来自语料池：{todayItems.map((item) => item.chunk).join(" / ")}
      </div>
      <DailyChunks items={todayItems} />
      <RecorderPanel recorder={recorder} />
      <CorrectionGrid rawTranscript={rawTranscript} selfCorrection={selfCorrection} aiCorrection={aiText} onRaw={setRawTranscript} onSelf={setSelfCorrection} />
      <div className="mobile-action-grid mt-4">
        <PrimaryButton onClick={optimize} disabled={!selfCorrection.trim() || loading}>{loading ? "AI 修正中..." : "AI 辅助修正"}</PrimaryButton>
        <SecondaryButton onClick={save} disabled={!rawTranscript.trim() && !selfCorrection.trim()}>保存今日记录</SecondaryButton>
      </div>
      {aiError && <p className="mt-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{aiError}</p>}
    </section>
  );
}

function FinalReview({ state, onSave }: { state: AppState; onSave: (session: PracticeSession, review: string) => void }) {
  const recorder = useRecorder();
  const [finalText, setFinalText] = useState("");
  const [review, setReview] = useState(state.finalReview);

  function generate() {
    const result = createFinalReview(state.topic, state.sessions, state.corpusItems, finalText);
    setReview(result);
    onSave(
      {
        id: id("session"),
        day: 5,
        rawTranscript: finalText,
        selfCorrection: finalText,
        aiCorrection: result,
        notes: "Day 5 final review",
        createdAt: todayIso()
      },
      result
    );
  }

  if (!state.sessions.length) {
    return <Locked title="复盘还未解锁" text="请先完成至少一轮 Quick Loop 或 Day 1 练习。" />;
  }

  return (
    <section>
      <Header eyebrow="Review" title="最终复述与成果回顾" description="对比初次输出和最终复述，看哪些表达已经进入你的个人语料池。" />
      <RecorderPanel recorder={recorder} />
      <label className="mt-4 grid gap-2">
        <span className="text-sm font-semibold text-slate-700">最终文本</span>
        <textarea className="textarea min-h-44" value={finalText} onChange={(event) => setFinalText(event.target.value)} placeholder="粘贴或输入 Day 5 最终复述文本" />
      </label>
      <PrimaryButton onClick={generate} disabled={!finalText.trim()}>生成本周成果回顾</PrimaryButton>
      {review && <pre className="mt-4 whitespace-pre-wrap rounded-2xl bg-mist p-5 text-sm leading-7 text-slate-800">{review}</pre>}
    </section>
  );
}

function CorpusPool({ items: initialItems, onChange }: { items: CorpusItem[]; onChange: (items: CorpusItem[]) => void }) {
  const [items, setItems] = useState<CorpusItem[]>(initialItems.map(normalizeCorpusItem));
  const [draft, setDraft] = useState({
    chunk: "",
    meaningZh: "",
    usageScene: "",
    exampleJa: "",
    tags: ""
  });
  const [selectedId, setSelectedId] = useState("");
  const [statusFilter, setStatusFilter] = useState<"active" | "archived" | "all">("active");
  const [sourceFilter, setSourceFilter] = useState("全部");
  const [tagFilter, setTagFilter] = useState("全部");
  const [tagDraft, setTagDraft] = useState("");
  const [loading, setLoading] = useState(false);
  const [notice, setNotice] = useState("");
  const [error, setError] = useState("");

  useEffect(() => {
    refresh();
  }, []);

  useEffect(() => {
    setItems(initialItems.map(normalizeCorpusItem));
  }, [initialItems.length]);

  const allTags = Array.from(new Set(items.flatMap((item) => item.tags || []))).sort((a, b) => a.localeCompare(b));
  const allSources = Array.from(new Set(items.map((item) => sourceLabel(item.sourceType)).filter(Boolean)));
  const filteredItems = items
    .filter((item) => statusFilter === "all" ? true : statusFilter === "archived" ? item.status === "archived" : item.status !== "archived")
    .filter((item) => sourceFilter === "全部" || sourceLabel(item.sourceType) === sourceFilter)
    .filter((item) => tagFilter === "全部" || (item.tags || []).includes(tagFilter));
  const selectedItem = items.find((item) => item.id === selectedId) || filteredItems[0];

  const canAdd = Boolean(draft.chunk.trim() && draft.meaningZh.trim());

  useEffect(() => {
    setTagDraft((selectedItem?.tags || []).join(", "));
  }, [selectedItem?.id]);

  async function refresh(syncAppState = false) {
    setError("");
    try {
      const nextItems = (await apiGetCorpusItems(true)).map(normalizeCorpusItem);
      setItems(nextItems);
      if (syncAppState) onChange(nextItems);
    } catch (err) {
      setError(err instanceof Error ? err.message : "读取 Corpus Pool 失败");
    }
  }

  async function addItem() {
    if (!canAdd) return;
    setLoading(true);
    setError("");
    try {
      await apiAddCorpusItem({
        chunk: draft.chunk.trim(),
        meaningZh: draft.meaningZh.trim(),
        usageScene: draft.usageScene.trim() || "全局回顾时需要巩固的表达",
        exampleJa: draft.exampleJa.trim() || draft.chunk.trim(),
        masteryStatus: "未练习",
        tags: draft.tags.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean),
        status: "active",
        sourceType: "manual",
        sourceLabel: "手动添加",
        detailStatus: "pending"
      });
      setDraft({ chunk: "", meaningZh: "", usageScene: "", exampleJa: "", tags: "" });
      setNotice("已添加corpus，后台会补全解释和例句。");
      await refresh(true);
    } catch (err) {
      setError(err instanceof Error ? err.message : "新增语料失败");
    } finally {
      setLoading(false);
    }
  }

  async function updateItem(item: CorpusItem, updates: Partial<Pick<CorpusItem, "masteryStatus" | "tags" | "status">>, message: string) {
    setLoading(true);
    setError("");
    const optimistic = items.map((current) => current.id === item.id ? { ...current, ...updates, updatedAt: todayIso() } : current);
    setItems(optimistic);
    onChange(optimistic);
    try {
      const updated = await apiUpdateCorpusItem(item.id, updates);
      const nextItems = items.map((current) => current.id === item.id ? normalizeCorpusItem(updated) : current);
      setItems(nextItems);
      onChange(nextItems);
      setNotice(message);
    } catch (err) {
      setItems(items);
      onChange(items);
      setError(err instanceof Error ? err.message : "更新语料失败");
    } finally {
      setLoading(false);
    }
  }

  async function removeItem(item: CorpusItem) {
    const ok = window.confirm(`删除语料「${item.chunk}」？`);
    if (!ok) return;
    setLoading(true);
    try {
      await apiDeleteCorpusItem(item.id);
      const nextItems = items.filter((current) => current.id !== item.id);
      setItems(nextItems);
      onChange(nextItems);
      if (selectedId === item.id) setSelectedId("");
      setNotice("语料已删除。");
    } catch (err) {
      setError(err instanceof Error ? err.message : "删除语料失败");
    } finally {
      setLoading(false);
    }
  }

  function saveTags(item: CorpusItem) {
    const tags = tagDraft.split(/[,，\n]/).map((tag) => tag.trim()).filter(Boolean);
    updateItem(item, { tags }, "标签已保存。");
  }

  return (
    <section>
      <Header eyebrow="Corpus Pool" title="全局回顾中心" description="集中管理所有练习里标记的不确定表达，支持来源、标签、归档和 AI 解释。" />
      {notice && <p className="mb-4 rounded-2xl bg-things-50 px-4 py-3 text-sm font-semibold text-things-800">{notice}</p>}
      {error && <p className="mb-4 rounded-2xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-600">{error}</p>}
      <div className="mb-4 rounded-2xl border border-line bg-white p-5">
        <div className="mb-3 text-sm font-semibold text-things-700">手动新增表达</div>
        <div className="grid gap-3 md:grid-cols-2">
          <input className="input" value={draft.chunk} onChange={(event) => setDraft({ ...draft, chunk: event.target.value })} placeholder="表达 chunk，例如：一方で、〜とも感じています" />
          <input className="input" value={draft.meaningZh} onChange={(event) => setDraft({ ...draft, meaningZh: event.target.value })} placeholder="中文含义" />
          <input className="input" value={draft.usageScene} onChange={(event) => setDraft({ ...draft, usageScene: event.target.value })} placeholder="适用场景" />
          <input className="input" value={draft.exampleJa} onChange={(event) => setDraft({ ...draft, exampleJa: event.target.value })} placeholder="日语例句" />
          <input className="input md:col-span-2" value={draft.tags} onChange={(event) => setDraft({ ...draft, tags: event.target.value })} placeholder="标签：面试, 敬语, 观点表达" />
        </div>
        <div className="mt-3">
          <PrimaryButton onClick={addItem} disabled={!canAdd || loading}>{loading ? "添加中..." : "添加corpus"}</PrimaryButton>
        </div>
      </div>
      {items.length === 0 ? (
        <Locked title="还没有语料" text="完成练习后把不确定表达添加corpus，或在上方手动添加。" />
      ) : (
        <div className="grid gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
          <div className="rounded-2xl border border-line bg-white p-4">
            <div className="mb-4 grid gap-2 rounded-2xl bg-slate-50 p-2">
              <div className="flex flex-wrap gap-1.5">
                {[
                  { key: "active", label: "活跃" },
                  { key: "archived", label: "已归档" },
                  { key: "all", label: "全部" }
                ].map((option) => (
                  <FilterPill key={option.key} active={statusFilter === option.key} onClick={() => setStatusFilter(option.key as "active" | "archived" | "all")}>{option.label}</FilterPill>
                ))}
              </div>
              <div className="flex max-h-20 flex-wrap gap-1.5 overflow-auto">
                <FilterPill active={sourceFilter === "全部"} onClick={() => setSourceFilter("全部")}>全部来源</FilterPill>
                {allSources.map((source) => <FilterPill key={source} active={sourceFilter === source} onClick={() => setSourceFilter(source)}>{source}</FilterPill>)}
              </div>
              <div className="flex max-h-20 flex-wrap gap-1.5 overflow-auto">
                <FilterPill active={tagFilter === "全部"} onClick={() => setTagFilter("全部")}>全部标签</FilterPill>
                {allTags.map((tag) => <FilterPill key={tag} active={tagFilter === tag} onClick={() => setTagFilter(tag)}>{tag}</FilterPill>)}
              </div>
            </div>
            <div className="grid gap-2">
              {filteredItems.map((item) => (
                <button
                  key={item.id}
                  onClick={() => {
                    setSelectedId(item.id);
                    setTagDraft((item.tags || []).join(", "));
                  }}
                  className={`rounded-xl p-3 text-left ${selectedItem?.id === item.id ? "bg-things-50 text-things-900 shadow-hairline" : "bg-slate-50 text-slate-600 hover:bg-things-50"}`}
                >
                  <div className="font-semibold text-ink">{item.chunk}</div>
                  <div className="mt-1 line-clamp-2 text-sm">{item.meaningZh || item.explanation || "等待后台解释"}</div>
                  <div className="mt-2 flex flex-wrap gap-1.5 text-xs">
                    <span className="rounded-full bg-white px-2 py-0.5 text-things-700">{sourceLabel(item.sourceType)}</span>
                    <span className="rounded-full bg-white px-2 py-0.5 text-slate-500">{item.status === "archived" ? "已归档" : item.masteryStatus}</span>
                    <span className="rounded-full bg-white px-2 py-0.5 text-slate-500">{detailStatusLabel(item.detailStatus)}</span>
                  </div>
                </button>
              ))}
              {!filteredItems.length && <p className="text-sm text-slate-500">当前筛选下没有语料。</p>}
            </div>
          </div>

          <div className="rounded-2xl border border-line bg-white p-5">
            {!selectedItem ? (
              <Locked title="未选中语料" text="从左侧选择一条语料查看解释、来源和例句。" />
            ) : (
              <div className="grid gap-4">
                <div className="rounded-2xl bg-slate-50 p-4">
                  <div className="grid gap-4">
                    <div className="min-w-0">
                      <div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-things-600">Corpus Detail</div>
                      <h3 className="break-words text-2xl font-semibold leading-9 text-ink">{selectedItem.chunk}</h3>
                      <div className="mt-2 flex flex-wrap gap-2 text-xs font-semibold">
                        <span className="max-w-full break-words rounded-xl bg-white px-3 py-1 leading-5 text-things-700 shadow-hairline">{sourceLabel(selectedItem.sourceType)}</span>
                        <span className="shrink-0 rounded-full bg-white px-3 py-1 text-slate-600 shadow-hairline">{selectedItem.status === "archived" ? "已归档" : "活跃"}</span>
                        <span className="shrink-0 rounded-full bg-white px-3 py-1 text-slate-600 shadow-hairline">{detailStatusLabel(selectedItem.detailStatus)}</span>
                      </div>
                    </div>
                    <div className="grid gap-2 sm:grid-cols-[minmax(0,12rem)_auto_auto]">
                      <select value={selectedItem.masteryStatus} onChange={(event) => updateItem(selectedItem, { masteryStatus: event.target.value as MasteryStatus }, "熟练度已保存。")} className="input min-w-0">
                        {["未练习", "练习中", "已掌握"].map((status) => <option key={status}>{status}</option>)}
                      </select>
                      {selectedItem.status === "archived" ? (
                        <SecondaryButton onClick={() => updateItem(selectedItem, { status: "active" }, "已恢复为活跃语料。")} disabled={loading}>恢复</SecondaryButton>
                      ) : (
                        <SecondaryButton onClick={() => updateItem(selectedItem, { status: "archived" }, "语料已归档。")} disabled={loading}>归档</SecondaryButton>
                      )}
                      <button onClick={() => removeItem(selectedItem)} disabled={loading} className="rounded-xl bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 disabled:opacity-50">删除</button>
                    </div>
                  </div>
                </div>

                <div className="grid gap-3 md:grid-cols-2">
                  <Info label="中文含义" value={selectedItem.meaningZh} />
                  <Info label="词性 / 类型" value={selectedItem.partOfSpeech || "后台处理中"} />
                  <Info label="日语注音" value={selectedItem.reading || "后台处理中"} />
                  <Info label="适用场景" value={selectedItem.usageScene} />
                  <div className="md:col-span-2">
                    <Info label="主例句" value={selectedItem.exampleJa} />
                  </div>
                </div>
                <Info label="AI 解释" value={selectedItem.explanation || (selectedItem.detailStatus === "error" ? selectedItem.detailError || "后台解释失败" : "后台正在静默生成，不影响其他练习。")} />
                {(selectedItem.examples || []).length > 0 && (
                  <div className="rounded-xl bg-slate-50 p-4">
                    <div className="text-sm font-semibold text-things-700">例句</div>
                    <div className="mt-2 grid gap-2">
                      {(selectedItem.examples || []).map((example) => <p key={example} className="rounded-lg bg-white px-3 py-2 text-sm leading-7 text-slate-700 shadow-hairline">{example}</p>)}
                    </div>
                  </div>
                )}

                <div className="rounded-2xl bg-slate-50 p-4">
                  <label className="grid gap-2">
                    <span className="text-sm font-semibold text-things-700">标签</span>
                    <input className="input" value={tagDraft} onChange={(event) => setTagDraft(event.target.value)} placeholder="用逗号分隔标签" />
                  </label>
                  <div className="mt-3">
                    <SecondaryButton onClick={() => saveTags(selectedItem)} disabled={loading}>保存标签</SecondaryButton>
                  </div>
                </div>
              </div>
            )}
          </div>
        </div>
      )}
    </section>
  );
}

function RecorderPanel({ recorder }: { recorder: ReturnType<typeof useRecorder> }) {
  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="mobile-action-grid items-center">
        <PrimaryButton onClick={recorder.start} disabled={recorder.isRecording}>开始录音</PrimaryButton>
        <SecondaryButton onClick={recorder.stop} disabled={!recorder.isRecording}>停止录音</SecondaryButton>
        {recorder.isRecording && <span className="rounded-full bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">录音中</span>}
      </div>
      {recorder.error && <p className="mt-3 text-sm text-red-600">{recorder.error}</p>}
      {recorder.audioUrl && <audio controls src={recorder.audioUrl} className="mt-4 w-full" />}
    </div>
  );
}

function AudioCapturePanel({
  recorder,
  transcriber,
  loading,
  whisperError,
  onStartRecording,
  onTranscribe
}: {
  recorder: ReturnType<typeof useRecorder>;
  transcriber: ReturnType<typeof useSpeechTranscriber>;
  loading: boolean;
  whisperError: string;
  onStartRecording?: () => void;
  onTranscribe: () => void;
}) {
  const [showBrowserTranscriber, setShowBrowserTranscriber] = useState(false);
  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
        <div>
          <div className="text-sm font-semibold text-things-700">录音转写</div>
          <p className="mt-1 text-sm leading-6 text-slate-600">先录音，再用 Whisper 转写到原始转写区。</p>
        </div>
        <div className="mobile-action-grid">
          <PrimaryButton
            onClick={() => {
              onStartRecording?.();
              recorder.start();
            }}
            disabled={recorder.isRecording}
          >
            开始录音
          </PrimaryButton>
          <SecondaryButton onClick={recorder.stop} disabled={!recorder.isRecording}>停止录音</SecondaryButton>
          <PrimaryButton onClick={onTranscribe} disabled={!recorder.audioBlob || loading}>
            ⌁ {loading ? "转写中..." : "Whisper 转写"}
          </PrimaryButton>
        </div>
      </div>

      <div className="mt-4 grid gap-3">
        {recorder.isRecording && <span className="w-fit rounded-full bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">录音中</span>}
        {recorder.audioUrl && <audio controls src={recorder.audioUrl} className="w-full" />}
        {!recorder.audioUrl && <div className="rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-500">还没有录音。</div>}
        {recorder.error && <p className="text-sm text-red-600">{recorder.error}</p>}
        {whisperError && <p className="text-sm text-red-600">{whisperError}</p>}
      </div>

      <div className="mt-4 border-t border-line pt-4">
        <button
          onClick={() => setShowBrowserTranscriber(!showBrowserTranscriber)}
          className="text-sm font-semibold text-things-700 hover:text-things-900"
        >
          {showBrowserTranscriber ? "▴ 隐藏浏览器实时转写" : "▾ 浏览器实时转写备用"}
        </button>
        {showBrowserTranscriber && (
          <div className="mt-3 rounded-xl bg-slate-50 p-4">
            <div className="mobile-action-grid">
              <SecondaryButton onClick={transcriber.start} disabled={transcriber.isListening || !transcriber.isSupported}>● 开始实时转写</SecondaryButton>
              <SecondaryButton onClick={transcriber.stop} disabled={!transcriber.isListening}>■ 停止实时转写</SecondaryButton>
            </div>
            {transcriber.isListening && <p className="mt-3 text-sm font-semibold text-red-600">正在听写日语</p>}
            {transcriber.interimText && <p className="mt-3 text-sm text-things-900">临时识别：{transcriber.interimText}</p>}
            {!transcriber.isSupported && <p className="mt-3 text-sm text-slate-500">当前浏览器不支持原生实时转写。</p>}
            {transcriber.error && <p className="mt-3 text-sm text-red-600">{transcriber.error}</p>}
          </div>
        )}
      </div>
    </div>
  );
}

function AutoTranscriptionPanel({ transcriber }: { transcriber: ReturnType<typeof useSpeechTranscriber> }) {
  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
        <div>
          <div className="text-sm font-semibold text-things-700">自动转写</div>
          <p className="mt-1 text-sm leading-6 text-slate-600">
            使用浏览器语音识别，语言设为日语。识别结果会自动追加到原始转写。
          </p>
        </div>
        <div className="mobile-action-grid">
          <PrimaryButton onClick={transcriber.start} disabled={transcriber.isListening || !transcriber.isSupported}>开始转写</PrimaryButton>
          <SecondaryButton onClick={transcriber.stop} disabled={!transcriber.isListening}>停止转写</SecondaryButton>
        </div>
      </div>
      {transcriber.isListening && <p className="mt-3 rounded-full bg-red-50 px-3 py-2 text-sm font-semibold text-red-600">正在听写日语</p>}
      {transcriber.interimText && (
        <p className="mt-3 rounded-xl bg-things-50 p-3 text-sm leading-6 text-things-900">
          临时识别：{transcriber.interimText}
        </p>
      )}
      {!transcriber.isSupported && (
        <p className="mt-3 text-sm text-slate-500">当前浏览器不支持原生自动转写，建议先用 Chrome 打开，或后续接入服务端语音转文字。</p>
      )}
      {transcriber.error && <p className="mt-3 text-sm text-red-600">{transcriber.error}</p>}
    </div>
  );
}

function WhisperTranscriptionPanel({
  hasAudio,
  loading,
  error,
  onTranscribe
}: {
  hasAudio: boolean;
  loading: boolean;
  error: string;
  onTranscribe: () => void;
}) {
  return (
    <div className="rounded-2xl border border-line bg-white p-5">
      <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
        <div>
          <div className="text-sm font-semibold text-things-700">Whisper 转写录音</div>
          <p className="mt-1 text-sm leading-6 text-slate-600">
            录音停止后，把音频上传到后端，再由后端调用配置好的 STT 服务。API Key 不会暴露到浏览器。
          </p>
        </div>
        <PrimaryButton onClick={onTranscribe} disabled={!hasAudio || loading}>
          {loading ? "转写中..." : "用 Whisper 转写"}
        </PrimaryButton>
      </div>
      {!hasAudio && <p className="mt-3 text-sm text-slate-500">请先完成一段录音。</p>}
      {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
    </div>
  );
}

function CorrectionGrid(props: {
  rawTranscript: string;
  selfCorrection: string;
  aiCorrection: string;
  onRaw: (value: string) => void;
  onSelf: (value: string) => void;
}) {
  return (
    <div className="mt-4 grid gap-3 lg:grid-cols-3">
      <TextPanel label="原始转写" value={props.rawTranscript} onChange={props.onRaw} placeholder="MVP 阶段请手动粘贴录音转写文本" />
      <TextPanel label="我的自主修正" value={props.selfCorrection} onChange={props.onSelf} placeholder="先自己整理表达、修正逻辑和词句" />
      <TextPanel label="AI 优化版本" value={props.aiCorrection} readonly placeholder="填写自主修正后点击 AI 优化" />
    </div>
  );
}

function TextPanel({ label, value, onChange, placeholder, readonly }: { label: string; value: string; onChange?: (value: string) => void; placeholder: string; readonly?: boolean }) {
  return (
    <label className="grid gap-2 rounded-2xl border border-line bg-white p-4">
      <span className="text-sm font-semibold text-slate-700">{label}</span>
      <textarea
        className="textarea min-h-56"
        value={value}
        readOnly={readonly}
        onChange={(event) => onChange?.(event.target.value)}
        placeholder={placeholder}
      />
    </label>
  );
}

function AIResult({ ai, existingCount }: { ai: AIResponse; existingCount: number }) {
  return (
    <div className="mt-4 rounded-2xl bg-mist p-5">
      <h3 className="font-semibold text-ink">AI 修改说明</h3>
      <p className="mt-2 text-sm leading-6 text-slate-700">{ai.explanation}</p>
      <h3 className="mt-5 font-semibold text-ink">将加入语料池的表达</h3>
      <div className="mt-3 grid gap-3 md:grid-cols-2">
        {ai.corpusItems.map((item) => (
          <div key={item.id} className="rounded-xl bg-white p-4">
            <div className="font-semibold text-things-800">{item.chunk}</div>
            <div className="mt-1 text-sm text-slate-600">{item.meaningZh}</div>
          </div>
        ))}
      </div>
      <p className="mt-3 text-xs text-slate-500">当前语料池已有 {existingCount} 条，保存后会按 chunk 去重合并。</p>
    </div>
  );
}

function DailyChunks({ items }: { items: CorpusItem[] }) {
  return (
    <div className="mb-4 grid gap-3 md:grid-cols-2">
      {items.map((item) => (
        <div key={item.id} className="rounded-2xl border border-line bg-things-50 p-5">
          <div className="text-xs font-semibold uppercase tracking-[0.12em] text-things-600">Today Chunk</div>
          <div className="mt-2 text-lg font-semibold text-ink">{item.chunk}</div>
          <div className="mt-2 text-sm text-slate-600">{item.meaningZh}</div>
          <div className="mt-3 text-sm text-slate-700">{item.exampleJa}</div>
        </div>
      ))}
    </div>
  );
}

function Header({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
  const theme = modeThemes[themeKeyForHeader(eyebrow, title)];
  return (
    <header className="mb-4 md:mb-5">
      <div className={`hidden items-center gap-2 rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] md:inline-flex ${theme.softBg} ${theme.accentText}`}>
        <span aria-hidden="true" className="tracking-normal">{theme.icon}</span>
        <span>{eyebrow}</span>
      </div>
      <h2 className="text-2xl font-semibold tracking-normal text-ink md:mt-2 md:text-3xl">{title}</h2>
      <p className="mt-1 max-w-2xl text-sm leading-6 text-slate-500 md:mt-2 md:text-slate-600">{description}</p>
    </header>
  );
}

function ModuleTabs<T extends string>({
  tabs,
  active,
  onChange,
  themeKey = "translation",
  className = ""
}: {
  tabs: { key: T; label: string; icon?: string; disabled?: boolean }[];
  active: T;
  onChange: (key: T) => void;
  themeKey?: ModeThemeKey;
  className?: string;
}) {
  const theme = modeThemes[themeKey];
  return (
    <div className={`flex max-w-full overflow-x-auto rounded-xl bg-slate-50 p-1 ${className}`}>
      <div role="tablist" className="flex max-w-full flex-nowrap gap-1 md:flex-wrap">
        {tabs.map((tab) => {
          const selected = active === tab.key;
          return (
            <button
              key={tab.key}
              type="button"
              role="tab"
              aria-selected={selected}
              disabled={tab.disabled}
              onClick={() => onChange(tab.key)}
              className={`inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-45 ${
                selected
                  ? `bg-white ${theme.activeText} shadow-hairline`
                  : `bg-transparent text-slate-500 ${theme.hoverBg}`
              }`}
            >
              <span aria-hidden="true" className={`grid h-6 w-6 place-items-center rounded-lg text-xs ${selected ? `${theme.activeBg} ${theme.accentText}` : "bg-slate-100 text-slate-400"}`}>
                {tab.icon || "•"}
              </span>
              <span>{tab.label}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function renderButtonChildren(children: React.ReactNode, explicitIcon?: string | null) {
  const icon = explicitIcon === undefined ? defaultButtonIcon(children) : explicitIcon;
  if (!icon) return children;
  return buttonIcon(icon, String(children));
}

function Metric({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-2xl border border-line bg-white p-4">
      <div className="text-xs font-semibold text-slate-500">{label}</div>
      <div className="mt-2 truncate text-xl font-semibold text-ink">{value}</div>
    </div>
  );
}

function MiniBar({ label, value, total, tone }: { label: string; value: number; total: number; tone: "emerald" | "things" | "slate" }) {
  const percent = total ? Math.round((value / total) * 100) : 0;
  const color = tone === "emerald" ? "bg-emerald-500" : tone === "things" ? "bg-things-500" : "bg-slate-400";
  return (
    <div>
      <div className="mb-1 flex items-center justify-between gap-3 text-xs font-semibold">
        <span className="text-slate-600">{label}</span>
        <span className="text-slate-400">{value}/{total}</span>
      </div>
      <div className="h-2 overflow-hidden rounded-full bg-white shadow-hairline">
        <div className={`h-full rounded-full ${color}`} style={{ width: `${percent}%` }} />
      </div>
    </div>
  );
}

function Info({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-xl bg-mist p-4">
      <div className="text-xs font-semibold text-things-700">{label}</div>
      <div className="mt-1 text-sm leading-6 text-slate-700">{value}</div>
    </div>
  );
}

function ExpressionRuby({ expression, readings }: { expression: string; readings: Record<string, string> }) {
  const reading = readings[expression] || "";
  return (
    <ruby className="leading-7">
      {expression}
      {reading && reading !== expression && <rt className="text-[10px] font-medium text-slate-500">{reading}</rt>}
    </ruby>
  );
}

function Locked({ title, text }: { title: string; text: string }) {
  return (
    <div className="rounded-2xl border border-dashed border-line bg-white p-8 text-center">
      <h3 className="text-xl font-semibold text-ink">{title}</h3>
      <p className="mt-2 text-sm text-slate-600">{text}</p>
    </div>
  );
}

function PrimaryButton({ children, onClick, disabled, icon, size = "md" }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; icon?: string | null; size?: "sm" | "md" }) {
  const sizeClass = size === "sm" ? "px-3 py-2 text-sm" : "px-4 py-2.5 text-sm";
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`rounded-xl bg-[var(--mode-primary)] ${sizeClass} font-semibold text-white shadow-[0_12px_32px_var(--mode-primary-shadow)] transition hover:bg-[var(--mode-primary-hover)] disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none`}
    >
      {renderButtonChildren(children, icon)}
    </button>
  );
}

function SecondaryButton({ children, onClick, disabled, icon, size = "md" }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; icon?: string | null; size?: "sm" | "md" }) {
  const sizeClass = size === "sm" ? "px-3 py-2 text-sm" : "px-4 py-2.5 text-sm";
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`rounded-xl bg-[var(--mode-secondary)] ${sizeClass} font-semibold text-[var(--mode-secondary-text)] transition hover:bg-[var(--mode-secondary-hover)] disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400`}
    >
      {renderButtonChildren(children, icon)}
    </button>
  );
}

function CompactButton({ children, onClick, disabled }: { children: React.ReactNode; onClick: () => void; disabled?: boolean }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className="rounded-lg bg-white/80 px-2.5 py-1.5 text-xs font-semibold text-[var(--mode-secondary-text)] shadow-hairline transition hover:bg-white disabled:cursor-not-allowed disabled:text-slate-300"
    >
      {children}
    </button>
  );
}

function mergeCorpus(current: CorpusItem[], incoming: CorpusItem[]): CorpusItem[] {
  const byChunk = new Map(current.map((item) => [item.chunk, item]));
  incoming.forEach((item) => {
    const existing = byChunk.get(item.chunk);
    byChunk.set(item.chunk, existing ? { ...existing, ...item, tags: mergeTags(existing.tags || [], item.tags || []) } : normalizeCorpusItem(item));
  });
  return Array.from(byChunk.values());
}

function normalizeCorpusItem(item: CorpusItem): CorpusItem {
  const sourceType = item.sourceType || "manual";
  return {
    ...item,
    tags: item.tags || [],
    status: item.status || "active",
    sourceType,
    sourceLabel: sourceLabel(sourceType),
    sourceRef: item.sourceRef || "",
    detailStatus: item.detailStatus || "pending",
    reading: item.reading || "",
    examples: item.examples || [],
    createdAt: item.createdAt || todayIso(),
    updatedAt: item.updatedAt || todayIso()
  };
}

function mergeTags(left: string[], right: string[]): string[] {
  return Array.from(new Set([...left, ...right].map((tag) => tag.trim()).filter(Boolean)));
}

function withCorpusSource(items: CorpusItem[], sourceType: string, sourceRef = ""): CorpusItem[] {
  return items.map((item) => normalizeCorpusItem({
    ...item,
    sourceType: item.sourceType || sourceType,
    sourceLabel: sourceLabel(item.sourceType || sourceType),
    sourceRef: item.sourceRef || sourceRef,
    tags: item.tags || []
  }));
}

function upsertSession(sessions: PracticeSession[], session: PracticeSession): PracticeSession[] {
  if (session.day === 0) return [session, ...sessions];
  return [session, ...sessions.filter((item) => item.day !== session.day)];
}

function chooseDailyItems(items: CorpusItem[], day: number): CorpusItem[] {
  if (items.length <= 2) return items;
  const rank: Record<MasteryStatus, number> = {
    "未练习": 0,
    "练习中": 1,
    "已掌握": 2
  };
  return items
    .map((item, index) => ({ item, index }))
    .sort((left, right) => {
      const statusDiff = rank[left.item.masteryStatus] - rank[right.item.masteryStatus];
      if (statusDiff !== 0) return statusDiff;
      return right.index - left.index;
    })
    .slice(0, 2)
    .map(({ item }) => item);
}

const style = document.createElement("style");
style.innerHTML = `
  .input {
    width: 100%;
    border-radius: 14px;
    border: 1px solid #d2d3d9;
    background: #ffffff;
    padding: 0.75rem 0.875rem;
    font-size: 0.95rem;
    outline: none;
  }
  .input:focus, .textarea:focus {
    border-color: #00617a;
    box-shadow: 0 0 0 4px rgba(0, 97, 122, 0.14);
  }
  .textarea {
    width: 100%;
    resize: vertical;
    border-radius: 14px;
    border: 1px solid #d2d3d9;
    background: #ffffff;
    padding: 0.875rem;
    font-size: 0.95rem;
    line-height: 1.65;
    outline: none;
  }
`;
document.head.appendChild(style);

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
