BookFormCard.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import type { Theme } from "../../hooks/use-theme";
  2. import { cn } from "../../lib/utils";
  3. import { Tool, ToolHeader, ToolContent } from "../ai-elements/tool";
  4. import { Loader2 } from "lucide-react";
  5. export interface BookFormArgs {
  6. title?: string;
  7. genre?: string;
  8. platform?: string;
  9. targetChapters?: number;
  10. chapterWordCount?: number;
  11. language?: string;
  12. brief?: string;
  13. }
  14. export interface BookFormCardProps {
  15. readonly args: BookFormArgs;
  16. readonly onArgsChange: (args: BookFormArgs) => void;
  17. readonly onConfirm: () => void;
  18. readonly confirming: boolean;
  19. readonly theme: Theme;
  20. }
  21. const PLATFORM_OPTIONS = [
  22. { label: "番茄小说", value: "tomato" },
  23. { label: "起点中文网", value: "qidian" },
  24. { label: "飞卢", value: "feilu" },
  25. { label: "其他", value: "other" },
  26. ] as const;
  27. const LANGUAGE_OPTIONS = [
  28. { label: "中文", value: "zh" },
  29. { label: "English", value: "en" },
  30. ] as const;
  31. const labelClass = "text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold";
  32. const inputClass = cn(
  33. "w-full rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-sm",
  34. "outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/10 transition-all",
  35. "placeholder:text-muted-foreground/40",
  36. );
  37. function RadioGroup({
  38. options,
  39. value,
  40. onChange,
  41. disabled,
  42. }: {
  43. readonly options: ReadonlyArray<{ readonly label: string; readonly value: string }>;
  44. readonly value: string | undefined;
  45. readonly onChange: (v: string) => void;
  46. readonly disabled?: boolean;
  47. }) {
  48. return (
  49. <div className="flex flex-wrap gap-2">
  50. {options.map((opt) => (
  51. <button
  52. key={opt.value}
  53. type="button"
  54. onClick={() => onChange(opt.value)}
  55. disabled={disabled}
  56. className={cn(
  57. "px-3 py-1 rounded-lg text-xs font-medium border transition-all",
  58. value === opt.value
  59. ? "border-primary bg-primary/10 text-primary"
  60. : "border-border/60 bg-background/60 text-muted-foreground hover:border-primary/30",
  61. disabled && "opacity-50 cursor-not-allowed",
  62. )}
  63. >
  64. {opt.label}
  65. </button>
  66. ))}
  67. </div>
  68. );
  69. }
  70. export function BookFormCard({ args, onArgsChange, onConfirm, confirming }: BookFormCardProps) {
  71. const disabled = confirming;
  72. const update = <K extends keyof BookFormArgs>(key: K, value: BookFormArgs[K]) => {
  73. if (disabled) return;
  74. onArgsChange({ ...args, [key]: value });
  75. };
  76. const disabledInput = disabled ? "opacity-60 cursor-not-allowed" : "";
  77. const toolState = confirming ? "input-available" : "approval-requested";
  78. return (
  79. <Tool defaultOpen>
  80. <ToolHeader
  81. title="创建新书"
  82. type="tool-invocation"
  83. state={toolState}
  84. />
  85. <ToolContent>
  86. <div className={cn("space-y-4", disabled && "pointer-events-none opacity-80")}>
  87. {/* 书名 */}
  88. <div className="space-y-1.5">
  89. <label className={labelClass}>书名</label>
  90. <input
  91. type="text"
  92. value={args.title ?? ""}
  93. onChange={(e) => update("title", e.target.value)}
  94. placeholder="输入书名"
  95. disabled={disabled}
  96. className={cn(inputClass, disabledInput)}
  97. />
  98. </div>
  99. {/* 题材 */}
  100. <div className="space-y-1.5">
  101. <label className={labelClass}>题材</label>
  102. <input
  103. type="text"
  104. value={args.genre ?? ""}
  105. onChange={(e) => update("genre", e.target.value)}
  106. placeholder="如 xuanhuan、urban、romance"
  107. disabled={disabled}
  108. className={cn(inputClass, disabledInput)}
  109. />
  110. </div>
  111. {/* 目标平台 */}
  112. <div className="space-y-1.5">
  113. <label className={labelClass}>目标平台</label>
  114. <RadioGroup
  115. options={PLATFORM_OPTIONS}
  116. value={args.platform}
  117. onChange={(v) => update("platform", v)}
  118. disabled={disabled}
  119. />
  120. </div>
  121. {/* 目标章数 + 每章字数 */}
  122. <div className="grid grid-cols-2 gap-3">
  123. <div className="space-y-1.5">
  124. <label className={labelClass}>目标章数</label>
  125. <input
  126. type="number"
  127. value={args.targetChapters ?? ""}
  128. onChange={(e) => update("targetChapters", e.target.value ? Number(e.target.value) : undefined)}
  129. placeholder="如 200"
  130. disabled={disabled}
  131. className={cn(inputClass, disabledInput)}
  132. />
  133. </div>
  134. <div className="space-y-1.5">
  135. <label className={labelClass}>每章字数</label>
  136. <input
  137. type="number"
  138. value={args.chapterWordCount ?? ""}
  139. onChange={(e) => update("chapterWordCount", e.target.value ? Number(e.target.value) : undefined)}
  140. placeholder="如 2000"
  141. disabled={disabled}
  142. className={cn(inputClass, disabledInput)}
  143. />
  144. </div>
  145. </div>
  146. {/* 写作语言 */}
  147. <div className="space-y-1.5">
  148. <label className={labelClass}>写作语言</label>
  149. <RadioGroup
  150. options={LANGUAGE_OPTIONS}
  151. value={args.language}
  152. onChange={(v) => update("language", v)}
  153. disabled={disabled}
  154. />
  155. </div>
  156. {/* 创意简述 */}
  157. <div className="space-y-1.5">
  158. <label className={labelClass}>创意简述</label>
  159. <div className={cn(
  160. "rounded-lg border border-border/60 bg-background/80 px-3 py-2 text-sm leading-7 whitespace-pre-wrap",
  161. disabled ? "opacity-60" : "",
  162. )}>
  163. {args.brief || <span className="text-muted-foreground/40">AI 会根据你的描述自动生成</span>}
  164. </div>
  165. </div>
  166. </div>
  167. {/* 确认按钮 */}
  168. <div className="flex items-center justify-between pt-4 border-t border-border/40 mt-4">
  169. <p className="text-xs text-muted-foreground">确认后将由 Architect 生成完整 foundation</p>
  170. <button
  171. type="button"
  172. onClick={onConfirm}
  173. disabled={confirming}
  174. className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
  175. >
  176. {confirming && <Loader2 size={14} className="animate-spin" />}
  177. {confirming ? "创建中…" : "开始写这本书"}
  178. </button>
  179. </div>
  180. </ToolContent>
  181. </Tool>
  182. );
  183. }