105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useState, type ChangeEvent } from "react";
|
||
|
||
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
|
||
import { useToast } from "@/components/Toast";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { Field } from "@/components/ui/Field";
|
||
import { StatusNote } from "@/components/ui/StatusNote";
|
||
import { TextArea } from "@/components/ui/TextArea";
|
||
import { buttonClass } from "@/lib/ui/variants";
|
||
|
||
interface StyleUploadProps {
|
||
busy: boolean;
|
||
pollStatus: "idle" | "polling" | "done" | "error";
|
||
progress: number;
|
||
// 是否已有指纹(决定默认 mode = update)。
|
||
hasFingerprint: boolean;
|
||
onLearn: (samples: string[], mode: StyleLearnMode) => void;
|
||
}
|
||
|
||
// 学文风输入(UX §6.9):多段文本粘贴 + 文件选择(在浏览器读成文本)。
|
||
// 样本以正文文本入 body(无对象存储,确认决策)。
|
||
export function StyleUpload({
|
||
busy,
|
||
pollStatus,
|
||
progress,
|
||
hasFingerprint,
|
||
onLearn,
|
||
}: StyleUploadProps) {
|
||
const [text, setText] = useState("");
|
||
const toast = useToast();
|
||
|
||
// 读取选中的文本文件,追加到文本框(多文件用空行分隔)。
|
||
const onFiles = useCallback(
|
||
async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||
const files = Array.from(e.target.files ?? []);
|
||
if (files.length === 0) return;
|
||
try {
|
||
const contents = await Promise.all(files.map((f) => f.text()));
|
||
setText((prev) => {
|
||
const joined = contents.join("\n\n");
|
||
return prev.trim().length > 0 ? `${prev}\n\n${joined}` : joined;
|
||
});
|
||
} catch {
|
||
toast("读取文件失败,请改用粘贴。", "error");
|
||
} finally {
|
||
e.target.value = ""; // 允许重复选同一文件。
|
||
}
|
||
},
|
||
[toast],
|
||
);
|
||
|
||
const submit = (): void => {
|
||
// 以空行切分成多段样本(对齐后端 samples:list[str])。
|
||
const samples = text
|
||
.split(/\n{2,}/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
if (!hasUsableSamples(samples)) {
|
||
toast("请粘贴或选择至少一段样本正文。", "error");
|
||
return;
|
||
}
|
||
onLearn(samples, hasFingerprint ? "update" : "create");
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="样本正文(空行分段;可粘贴多段)" htmlFor="style-samples">
|
||
<TextArea
|
||
id="style-samples"
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="粘贴你想学习文风的章节正文…"
|
||
className="min-h-[30vh] bg-panel font-serif text-[15px] leading-[1.9]"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<label className={buttonClass({ variant: "secondary", className: "cursor-pointer" })}>
|
||
选择文本文件
|
||
<input
|
||
type="file"
|
||
accept=".txt,.md,text/plain,text/markdown"
|
||
multiple
|
||
onChange={(e) => void onFiles(e)}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
<Button type="button" onClick={submit} disabled={busy} variant="primary">
|
||
{hasFingerprint ? "重新学习文风" : "学习文风"}
|
||
</Button>
|
||
</div>
|
||
|
||
{pollStatus === "polling" ? (
|
||
<StatusNote variant="info" aria-live="polite">
|
||
提取文风指纹中…({progress}%)
|
||
</StatusNote>
|
||
) : pollStatus === "error" ? (
|
||
<StatusNote variant="danger">学文风失败,请稍后重试。</StatusNote>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|