fix(studio): polish interaction flows across major pages

This commit is contained in:
Ma
2026-04-13 03:20:56 +08:00
parent b8ac5b2cee
commit ed1d792ba0
6 changed files with 151 additions and 37 deletions
+1 -6
View File
@@ -20,7 +20,7 @@ import { useSSE } from "./hooks/use-sse";
import { useTheme } from "./hooks/use-theme";
import { useI18n } from "./hooks/use-i18n";
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";
export type Route =
@@ -139,11 +139,6 @@ export function App() {
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</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 */}
<button
onClick={() => setChatOpen((prev) => !prev)}
+91 -11
View File
@@ -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") === "已连接";
@@ -537,8 +617,8 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
>
{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("⋯")) && (
@@ -551,22 +631,22 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
<QuickChip
icon={<Zap size={11} />}
label={t("dash.writeNext")}
onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")}
onClick={() => void handleQuickCommand(isZh ? "写下一章" : "write next")}
/>
<QuickChip
icon={<Search size={11} />}
label={t("book.audit")}
onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
onClick={() => void handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
/>
<QuickChip
icon={<FileOutput size={11} />}
label={t("book.export")}
onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
onClick={() => void handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
/>
<QuickChip
icon={<TrendingUp size={11} />}
label={t("nav.radar")}
onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
onClick={() => void handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
/>
</div>
+27 -8
View File
@@ -57,6 +57,7 @@ interface Nav {
toDashboard: () => void;
toChapter: (bookId: string, num: number) => void;
toAnalytics: (bookId: string) => void;
toTruth: (bookId: string) => void;
}
function translateChapterStatus(status: string, t: TFunction): string {
@@ -266,8 +267,16 @@ export function BookDetail({
const handleApproveAll = async () => {
if (!data) return;
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();
};
@@ -395,7 +404,7 @@ export function BookDetail({
</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"
>
<Database size={14} />
@@ -534,14 +543,20 @@ export function BookDetail({
{ch.status === "ready-for-review" && (
<>
<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"
title={t("book.approve")}
>
<Check size={14} />
</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"
title={t("book.reject")}
>
@@ -551,9 +566,13 @@ export function BookDetail({
)}
<button
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"
title={t("book.audit")}
+12 -4
View File
@@ -94,13 +94,21 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
.trim();
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 () => {
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);
+4 -1
View File
@@ -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">
<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}
className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
isWriting
+16 -7
View File
@@ -4,6 +4,7 @@ import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useI18n } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { Plus, Pencil, Trash2 } from "lucide-react";
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 [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
// Only show genres matching current language, plus custom project genres
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 () => {
if (!validSelected) return;
if (!window.confirm(`Delete genre "${validSelected}"?`)) return;
setConfirmDeleteOpen(false);
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);
refetch();
} catch (e) {
@@ -393,7 +391,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
</button>
{selectedGenre?.source === "project" && (
<button
onClick={handleDelete}
onClick={() => setConfirmDeleteOpen(true)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
>
<Trash2 size={14} />
@@ -449,6 +447,17 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
)}
</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>
);
}