fix(studio): stabilize book creation defaults

This commit is contained in:
Ma
2026-03-30 09:25:34 +08:00
parent b51a214cd6
commit 094b7edee1
2 changed files with 85 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useApi, postApi } from "../hooks/use-api";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
@@ -16,20 +16,40 @@ interface GenreInfo {
readonly language: "zh" | "en";
}
const PLATFORMS_ZH = [
interface PlatformOption {
readonly value: string;
readonly label: string;
}
const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
{ value: "tomato", label: "番茄小说" },
{ value: "qidian", label: "起点中文网" },
{ value: "feilu", label: "飞卢" },
{ value: "other", label: "其他" },
];
const PLATFORMS_EN = [
{ value: "other", label: "Royal Road" },
{ value: "other", label: "Kindle Unlimited" },
{ value: "other", label: "Scribble Hub" },
const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
{ value: "royal-road", label: "Royal Road" },
{ value: "kindle-unlimited", label: "Kindle Unlimited" },
{ value: "scribble-hub", label: "Scribble Hub" },
{ value: "other", label: "Other" },
];
export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
if (current && available.includes(current)) {
return current;
}
return available[0] ?? "";
}
export function defaultChapterWordsForLanguage(language: "zh" | "en"): string {
return language === "en" ? "2000" : "3000";
}
export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray<PlatformOption> {
return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
}
export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
@@ -40,7 +60,8 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
const [title, setTitle] = useState("");
const [genre, setGenre] = useState("");
const [platform, setPlatform] = useState("");
const [chapterWords, setChapterWords] = useState(projectLang === "en" ? "2000" : "3000");
const [chapterWords, setChapterWords] = useState(defaultChapterWordsForLanguage(projectLang));
const [chapterWordsTouched, setChapterWordsTouched] = useState(false);
const [targetChapters, setTargetChapters] = useState("200");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,15 +69,23 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
// Filter genres by project language + custom genres (always show)
const allGenres = genreData?.genres ?? [];
const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
const platforms = projectLang === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
const platforms = platformOptionsForLanguage(projectLang);
const genreSignature = genres.map((g) => g.id).join("|");
const platformSignature = platforms.map((p) => `${p.value}:${p.label}`).join("|");
// Auto-select first matching genre
if (genres.length > 0 && !genre) {
setGenre(genres[0]!.id);
}
if (platforms.length > 0 && !platform) {
setPlatform(platforms[0]!.value);
}
useEffect(() => {
setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
}, [genreSignature]);
useEffect(() => {
setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
}, [platformSignature]);
useEffect(() => {
if (!chapterWordsTouched) {
setChapterWords(defaultChapterWordsForLanguage(projectLang));
}
}, [projectLang, chapterWordsTouched]);
const handleCreate = async () => {
if (!title.trim()) {
@@ -139,15 +168,15 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
{/* Platform — filtered by language */}
<div>
<label className="block text-sm text-muted-foreground mb-2">
{projectLang === "zh" ? "平台" : "Platform"}
{t("create.platform")}
</label>
<div className="flex gap-2">
{platforms.map((p, i) => (
{platforms.map((p) => (
<button
key={`${p.value}-${i}`}
key={p.value}
onClick={() => setPlatform(p.value)}
className={`px-3 py-2 rounded-md text-sm transition-all ${
platform === p.value && platforms.indexOf(platforms.find((x) => x.value === platform && x.label === p.label)!) === i
platform === p.value
? "bg-primary/15 text-primary border border-primary/30"
: "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
}`}
@@ -165,7 +194,10 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
<input
type="number"
value={chapterWords}
onChange={(e) => setChapterWords(e.target.value)}
onChange={(e) => {
setChapterWordsTouched(true);
setChapterWords(e.target.value);
}}
className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
/>
</div>

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
defaultChapterWordsForLanguage,
platformOptionsForLanguage,
pickValidValue,
} from "./BookCreate";
describe("pickValidValue", () => {
it("keeps the current value when it is still available", () => {
expect(pickValidValue("mystery", ["mystery", "romance"])).toBe("mystery");
});
it("falls back to the first available value when current is blank or invalid", () => {
expect(pickValidValue("", ["mystery", "romance"])).toBe("mystery");
expect(pickValidValue("invalid", ["mystery", "romance"])).toBe("mystery");
expect(pickValidValue("", [])).toBe("");
});
});
describe("defaultChapterWordsForLanguage", () => {
it("uses 3000 for chinese projects and 2000 for english projects", () => {
expect(defaultChapterWordsForLanguage("zh")).toBe("3000");
expect(defaultChapterWordsForLanguage("en")).toBe("2000");
});
});
describe("platformOptionsForLanguage", () => {
it("uses stable, unique values for english platform choices", () => {
const values = platformOptionsForLanguage("en").map((option) => option.value);
expect(new Set(values).size).toBe(values.length);
expect(values).toEqual(["royal-road", "kindle-unlimited", "scribble-hub", "other"]);
});
});