Просмотр исходного кода

fix(studio): polish interaction flows across major pages

Ma 7 месяцев назад
Родитель
Сommit
ed1d792ba0

+ 1 - 6
packages/studio/src/App.tsx

@@ -20,7 +20,7 @@ import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { useI18n } from "./hooks/use-i18n";
 import { postApi, useApi } from "./hooks/use-api";
 import { postApi, useApi } from "./hooks/use-api";
-import { Sun, Moon, Bell, MessageSquare } from "lucide-react";
+import { Sun, Moon, MessageSquare } from "lucide-react";
 import { DEFAULT_CHAT_OPEN } from "./app-state";
 import { DEFAULT_CHAT_OPEN } from "./app-state";
 
 
 export type Route =
 export type Route =
@@ -139,11 +139,6 @@ export function App() {
               {isDark ? <Sun size={16} /> : <Moon size={16} />}
               {isDark ? <Sun size={16} /> : <Moon size={16} />}
             </button>
             </button>
 
 
-            <button className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-foreground transition-all relative">
-              <Bell size={16} />
-              <span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full border-2 border-background" />
-            </button>
-
             {/* Chat Panel Toggle */}
             {/* Chat Panel Toggle */}
             <button
             <button
               onClick={() => setChatOpen((prev) => !prev)}
               onClick={() => setChatOpen((prev) => !prev)}

+ 91 - 11
packages/studio/src/components/ChatBar.tsx

@@ -426,11 +426,91 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
     }
     }
   };
   };
 
 
-  const handleQuickCommand = (command: string) => {
-    setInput(command);
-    setTimeout(() => {
-      handleSubmit();
-    }, 50);
+  const handleQuickCommand = async (command: string) => {
+    if (loading) return;
+    setInput("");
+    setMessages((prev) => [...prev, { role: "user", content: command, timestamp: Date.now() }]);
+    setLoading(true);
+
+    const lower = command.toLowerCase();
+    try {
+      if (lower.match(/^(写下一章|write next)/)) {
+        const { books } = await fetchJson<{ books: ReadonlyArray<BookRef> }>("/books");
+        const target = resolveDirectWriteTarget(activeBookId, books);
+
+        if (target.bookId) {
+          setMessages((prev) => [...prev, {
+            role: "assistant",
+            content: isZh ? `⋯ 开始处理《${target.bookId}》...` : `⋯ Starting ${target.bookId}...`,
+            timestamp: Date.now(),
+          }]);
+          await postApi(`/books/${target.bookId}/write-next`, {});
+          return;
+        }
+
+        setLoading(false);
+        setMessages((prev) => [...prev, {
+          role: "assistant",
+          content:
+            target.reason === "missing"
+              ? (isZh ? "✗ 还没有书,先创建一本再写。" : "✗ No books yet. Create one first.")
+              : (isZh ? "✗ 当前有多本书,请先打开目标书籍后再执行\u201C写下一章\u201D。" : '✗ Multiple books found. Open the target book first, then run "write next".'),
+          timestamp: Date.now(),
+        }]);
+        return;
+      }
+
+      const data = await fetchJson<{
+        response?: string;
+        error?: string;
+        session?: {
+          activeBookId?: string;
+          automationMode?: string;
+          creationDraft?: {
+            title?: string;
+          };
+          currentExecution?: {
+            status?: string;
+            stageLabel?: string;
+          };
+          pendingDecision?: {
+            summary?: string;
+          };
+          messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
+        };
+      }>("/agent", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ instruction: command, activeBookId }),
+      });
+      setLoading(false);
+      if (data.session) {
+        setSessionMeta({
+          activeBookId: data.session.activeBookId ?? activeBookId,
+          draftTitle: data.session.creationDraft?.title,
+          automationMode: data.session.automationMode,
+          currentStage: data.session.currentExecution?.stageLabel ?? data.session.currentExecution?.status,
+          pendingSummary: data.session.pendingDecision?.summary,
+        });
+        const restored = coerceSharedSessionMessages(data.session.messages ?? []);
+        if (restored.length > 0) {
+          setMessages(restored);
+          return;
+        }
+      }
+      setMessages((prev) => [...prev, {
+        role: "assistant",
+        content: data.response ?? data.error ?? "Acknowledged.",
+        timestamp: Date.now(),
+      }]);
+    } catch (e) {
+      setLoading(false);
+      setMessages((prev) => [...prev, {
+        role: "assistant",
+        content: `✗ ${e instanceof Error ? e.message : String(e)}`,
+        timestamp: Date.now(),
+      }]);
+    }
   };
   };
 
 
   const isZh = t("nav.connected") === "已连接";
   const isZh = t("nav.connected") === "已连接";
@@ -537,8 +617,8 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
           >
           >
             {messages.length === 0 && !loading && <EmptyState />}
             {messages.length === 0 && !loading && <EmptyState />}
 
 
-            {messages.map((msg) => (
-              <MessageBubble key={msg.timestamp} msg={msg} />
+            {messages.map((msg, i) => (
+              <MessageBubble key={`${msg.timestamp}-${i}`} msg={msg} />
             ))}
             ))}
 
 
             {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
             {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
@@ -551,22 +631,22 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
             <QuickChip
             <QuickChip
               icon={<Zap size={11} />}
               icon={<Zap size={11} />}
               label={t("dash.writeNext")}
               label={t("dash.writeNext")}
-              onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")}
+              onClick={() => void handleQuickCommand(isZh ? "写下一章" : "write next")}
             />
             />
             <QuickChip
             <QuickChip
               icon={<Search size={11} />}
               icon={<Search size={11} />}
               label={t("book.audit")}
               label={t("book.audit")}
-              onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
+              onClick={() => void handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
             />
             />
             <QuickChip
             <QuickChip
               icon={<FileOutput size={11} />}
               icon={<FileOutput size={11} />}
               label={t("book.export")}
               label={t("book.export")}
-              onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
+              onClick={() => void handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
             />
             />
             <QuickChip
             <QuickChip
               icon={<TrendingUp size={11} />}
               icon={<TrendingUp size={11} />}
               label={t("nav.radar")}
               label={t("nav.radar")}
-              onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
+              onClick={() => void handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
             />
             />
           </div>
           </div>
 
 

+ 27 - 8
packages/studio/src/pages/BookDetail.tsx

@@ -57,6 +57,7 @@ interface Nav {
   toDashboard: () => void;
   toDashboard: () => void;
   toChapter: (bookId: string, num: number) => void;
   toChapter: (bookId: string, num: number) => void;
   toAnalytics: (bookId: string) => void;
   toAnalytics: (bookId: string) => void;
+  toTruth: (bookId: string) => void;
 }
 }
 
 
 function translateChapterStatus(status: string, t: TFunction): string {
 function translateChapterStatus(status: string, t: TFunction): string {
@@ -266,8 +267,16 @@ export function BookDetail({
   const handleApproveAll = async () => {
   const handleApproveAll = async () => {
     if (!data) return;
     if (!data) return;
     const reviewable = data.chapters.filter((ch) => ch.status === "ready-for-review");
     const reviewable = data.chapters.filter((ch) => ch.status === "ready-for-review");
-    for (const ch of reviewable) {
-      await postApi(`/books/${bookId}/chapters/${ch.number}/approve`);
+    let failed = 0;
+    for (const chapter of reviewable) {
+      try {
+        await postApi(`/books/${bookId}/chapters/${chapter.number}/approve`);
+      } catch {
+        failed += 1;
+      }
+    }
+    if (failed > 0) {
+      alert(`${failed}/${reviewable.length} approve(s) failed`);
     }
     }
     refetch();
     refetch();
   };
   };
@@ -395,7 +404,7 @@ export function BookDetail({
             </button>
             </button>
           )}
           )}
           <button
           <button
-            onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
+            onClick={() => nav.toTruth(bookId)}
             className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
             className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
           >
           >
             <Database size={14} />
             <Database size={14} />
@@ -534,14 +543,20 @@ export function BookDetail({
                       {ch.status === "ready-for-review" && (
                       {ch.status === "ready-for-review" && (
                         <>
                         <>
                           <button
                           <button
-                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
+                            onClick={async () => {
+                              try { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }
+                              catch (e) { alert(e instanceof Error ? e.message : "Approve failed"); }
+                            }}
                             className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
                             className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
                             title={t("book.approve")}
                             title={t("book.approve")}
                           >
                           >
                             <Check size={14} />
                             <Check size={14} />
                           </button>
                           </button>
                           <button
                           <button
-                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
+                            onClick={async () => {
+                              try { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }
+                              catch (e) { alert(e instanceof Error ? e.message : "Reject failed"); }
+                            }}
                             className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
                             className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
                             title={t("book.reject")}
                             title={t("book.reject")}
                           >
                           >
@@ -551,9 +566,13 @@ export function BookDetail({
                       )}
                       )}
                       <button
                       <button
                         onClick={async () => {
                         onClick={async () => {
-                          const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
-                          alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
-                          refetch();
+                          try {
+                            const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
+                            alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
+                            refetch();
+                          } catch (e) {
+                            alert(e instanceof Error ? e.message : "Audit failed");
+                          }
                         }}
                         }}
                         className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
                         className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
                         title={t("book.audit")}
                         title={t("book.audit")}

+ 12 - 4
packages/studio/src/pages/ChapterReader.tsx

@@ -94,13 +94,21 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
     .trim();
     .trim();
 
 
   const handleApprove = async () => {
   const handleApprove = async () => {
-    await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
-    nav.toBook(bookId);
+    try {
+      await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
+      nav.toBook(bookId);
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Approve failed");
+    }
   };
   };
 
 
   const handleReject = async () => {
   const handleReject = async () => {
-    await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
-    nav.toBook(bookId);
+    try {
+      await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
+      nav.toBook(bookId);
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Reject failed");
+    }
   };
   };
 
 
   const paragraphs = body.split(/\n\n+/).filter(Boolean);
   const paragraphs = body.split(/\n\n+/).filter(Boolean);

+ 4 - 1
packages/studio/src/pages/Dashboard.tsx

@@ -244,7 +244,10 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
 
 
                 <div className="flex items-center gap-3 shrink-0 ml-6">
                 <div className="flex items-center gap-3 shrink-0 ml-6">
                   <button
                   <button
-                    onClick={() => postApi(`/books/${book.id}/write-next`)}
+                    onClick={async () => {
+                      try { await postApi(`/books/${book.id}/write-next`); }
+                      catch (e) { alert(e instanceof Error ? e.message : "Write failed"); }
+                    }}
                     disabled={isWriting}
                     disabled={isWriting}
                     className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
                     className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
                       isWriting
                       isWriting

+ 16 - 7
packages/studio/src/pages/GenreManager.tsx

@@ -4,6 +4,7 @@ import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import { useI18n } from "../hooks/use-i18n";
 import { useI18n } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
 import { useColors } from "../hooks/use-colors";
+import { ConfirmDialog } from "../components/ConfirmDialog";
 import { Plus, Pencil, Trash2 } from "lucide-react";
 import { Plus, Pencil, Trash2 } from "lucide-react";
 
 
 interface GenreInfo {
 interface GenreInfo {
@@ -209,6 +210,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
   const [selected, setSelected] = useState<string | null>(null);
   const [selected, setSelected] = useState<string | null>(null);
   const [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
   const [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
   const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
   const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
+  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
 
 
   // Only show genres matching current language, plus custom project genres
   // Only show genres matching current language, plus custom project genres
   const filteredGenres = data?.genres.filter((g) => g.language === lang || g.source === "project") ?? [];
   const filteredGenres = data?.genres.filter((g) => g.language === lang || g.source === "project") ?? [];
@@ -300,13 +302,9 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
 
 
   const handleDelete = async () => {
   const handleDelete = async () => {
     if (!validSelected) return;
     if (!validSelected) return;
-    if (!window.confirm(`Delete genre "${validSelected}"?`)) return;
+    setConfirmDeleteOpen(false);
     try {
     try {
-      const res = await fetch(`/api/genres/${validSelected}`, { method: "DELETE" });
-      if (!res.ok) {
-        const json = await res.json() as { error?: string };
-        throw new Error(json.error ?? `${res.status}`);
-      }
+      await fetchJson(`/genres/${validSelected}`, { method: "DELETE" });
       setSelected(null);
       setSelected(null);
       refetch();
       refetch();
     } catch (e) {
     } catch (e) {
@@ -393,7 +391,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
                   </button>
                   </button>
                   {selectedGenre?.source === "project" && (
                   {selectedGenre?.source === "project" && (
                     <button
                     <button
-                      onClick={handleDelete}
+                      onClick={() => setConfirmDeleteOpen(true)}
                       className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
                       className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
                     >
                     >
                       <Trash2 size={14} />
                       <Trash2 size={14} />
@@ -449,6 +447,17 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
           )}
           )}
         </div>
         </div>
       </div>
       </div>
+
+      <ConfirmDialog
+        open={confirmDeleteOpen}
+        title="Delete Genre"
+        message={`Delete genre "${validSelected}"?`}
+        confirmLabel={t("common.delete") ?? "Delete"}
+        cancelLabel={t("genre.cancel") ?? "Cancel"}
+        variant="danger"
+        onConfirm={() => void handleDelete()}
+        onCancel={() => setConfirmDeleteOpen(false)}
+      />
     </div>
     </div>
   );
   );
 }
 }