mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
fix(studio): polish interaction flows across major pages
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user